Bundle Linux voice runtimes and improve audio reliability (#44714)

## Why

Linux voice needs system ALSA plugins and enough buffering to accommodate PipeWire graph cycles without losing capture samples. Voice startup failures also need actionable diagnostics without exposing native error details.

## What changed

- Build and bundle GNU voice helpers and runtimes with primary Linux musl release archives, and sign the archives. Keep Python wheels free of these libraries to preserve `manylinux_2_17` compatibility.
- Discover ALSA plugins in fixed system directories and increase Linux capture and playback buffering to support larger PipeWire graph cycles.
- Report voice failures by stage, preserve negotiation timeout classification, and discard native error sources. Suppress the misleading `requested` closure message after failure cleanup.
- Add explicit Windows MSVC, pkgconf, and CMake toolchain configuration and preserve host architecture in native build environments.

## Testing

Add coverage for Linux release assembly, ALSA plugin discovery, PipeWire capture and playback, classified startup failures, failure cleanup rendering, and Windows build environment handling.

GitOrigin-RevId: d805eace96a669ce3a4489f12e2db6f68f9f7f53
This commit is contained in:
Benjamin Carlsson
2026-09-11 03:06:01 +00:00
parent a0806259e5
commit 231e6844cc
27 changed files with 578 additions and 70 deletions

View File

@@ -15,7 +15,7 @@ Usage: build-codex-package-archive.sh \
[--zsh-manifest <path>] \
[--codex-command-runner-bin <path>] \
[--codex-windows-sandbox-setup-bin <path>] \
[--voice-signed-dir <path> --release-version <release-version>] \
[--voice-release-dir <path> --release-version <release-version>] \
[--target-suffixed-entrypoint]
EOF
}
@@ -30,7 +30,7 @@ bwrap_bin_provided="false"
code_mode_host_bin_provided="false"
command_runner_bin_provided="false"
sandbox_setup_bin_provided="false"
voice_signed_dir=""
voice_release_dir=""
release_version=""
while [[ $# -gt 0 ]]; do
@@ -93,8 +93,8 @@ while [[ $# -gt 0 ]]; do
target_suffixed_entrypoint="true"
shift
;;
--voice-signed-dir)
voice_signed_dir="${2:?--voice-signed-dir requires a value}"
--voice-release-dir)
voice_release_dir="${2:?--voice-release-dir requires a value}"
shift 2
;;
--release-version)
@@ -117,8 +117,8 @@ if [[ -z "$target" || -z "$bundle" || -z "$entrypoint_dir" || -z "$archive_dir"
usage >&2
exit 1
fi
if [[ ( -n "$voice_signed_dir" || -n "$release_version" ) && ( -z "$voice_signed_dir" || -z "$release_version" || "$bundle" != "primary" || "$target" != *-apple-darwin ) ]]; then
echo "Signed voice resources require a primary macOS release package version" >&2
if [[ ( -n "$voice_release_dir" || -n "$release_version" ) && ( -z "$voice_release_dir" || -z "$release_version" || "$bundle" != "primary" || ( "$target" != *-apple-darwin && "$target" != *-unknown-linux-musl ) ) ]]; then
echo "Voice resources require a primary macOS or Linux release package version" >&2
exit 1
fi
@@ -204,7 +204,7 @@ python_args=(
--cargo-profile release
--package-dir "$package_dir"
)
if [[ -z "$voice_signed_dir" ]]; then
if [[ -z "$voice_release_dir" ]]; then
python_args+=(--archive-output "$gzip_archive_path" --archive-output "$zstd_archive_path")
fi
if ((${#resource_args[@]} > 0)); then
@@ -214,14 +214,18 @@ python_args+=(--force)
"$python_bin" "${python_args[@]}"
if [[ -n "$voice_signed_dir" ]]; then
if [[ -n "$voice_release_dir" ]]; then
voice_target="$target"
if [[ "$target" == *-unknown-linux-musl ]]; then
voice_target="${target%-musl}-gnu"
fi
voice_package="${RUNNER_TEMP:-/tmp}/${archive_stem}-voice-${target}"
rm -rf "$voice_package"
"$python_bin" "${repo_root}/third_party/voice/assemble_package.py" \
--package "$package_dir" \
--helper "${voice_signed_dir%/}/codex-voice-host" \
--runtime "${voice_signed_dir%/}/runtime" \
--voice-target "$target" \
--helper "${voice_release_dir%/}/codex-voice-host" \
--runtime "${voice_release_dir%/}/runtime" \
--voice-target "$voice_target" \
--build-commit "$(git -C "$repo_root" rev-parse HEAD)" \
--release-version "$release_version" \
--output "$voice_package"

View File

@@ -61,9 +61,8 @@ jobs:
needs: tag-check
name: Build - ${{ matrix.runner }} - ${{ matrix.target }} - ${{ matrix.bundle }}
runs-on: ${{ matrix.runs_on || matrix.runner }}
# Release builds can take a long time, so leave some headroom to avoid
# having to restart the full workflow due to a timeout.
timeout-minutes: 90
# Linux releases also build the native voice runtime in this job.
timeout-minutes: 120
permissions:
contents: read
id-token: write
@@ -292,6 +291,37 @@ jobs:
path: codex-rs/symbols-dist/${{ matrix.artifact_name }}/*
if-no-files-found: error
- name: Set up Bazel for Linux voice
if: ${{ matrix.bundle == 'primary' && contains(matrix.target, 'linux') }}
uses: bazel-contrib/setup-bazel@c5acdfb288317d0b5c0bbd7a396a3dc868bb0f86 # 0.19.0
with:
bazelisk-version: 1.28.1
- name: Build Linux voice runtime
if: ${{ matrix.bundle == 'primary' && contains(matrix.target, 'linux') }}
shell: bash
env:
APP_TARGET: ${{ matrix.target }}
run: |
set -euo pipefail
cd "$GITHUB_WORKSPACE"
voice_target="${APP_TARGET%-musl}-gnu"
case "$voice_target" in
aarch64-unknown-linux-gnu) prefix=linux_aarch64 ;;
x86_64-unknown-linux-gnu) prefix=linux_x86_64 ;;
*) exit 1 ;;
esac
bazel build -c opt //codex-rs/voice-host:codex-voice-host //third_party/voice:native_runtime
source="bazel-bin/third_party/voice/native_runtime_${prefix}"
output="${RUNNER_TEMP}/signed-voice/${APP_TARGET}"
mkdir -p "$output"
python3 third_party/voice/release_runtime.py stage \
--target "$voice_target" --source "$source" --output "$output/runtime"
cp bazel-bin/codex-rs/voice-host/codex-voice-host "$output/codex-voice-host"
chmod 0755 "$output/codex-voice-host"
python3 third_party/voice/release_runtime.py seal \
--target "$voice_target" --output "$output/runtime"
- if: ${{ runner.os == 'macOS' }}
name: Stage unsigned macOS artifacts
shell: bash
@@ -376,12 +406,26 @@ jobs:
BUNDLE: ${{ matrix.bundle }}
run: |
set -euo pipefail
voice_args=()
if [[ "$BUNDLE" == "primary" && "$TARGET" == *-unknown-linux-musl ]]; then
voice_args+=(--voice-release-dir "${RUNNER_TEMP}/signed-voice/${TARGET}")
voice_args+=(--release-version "${GITHUB_REF_NAME#rust-v}")
fi
bash "${GITHUB_WORKSPACE}/.github/scripts/build-codex-package-archive.sh" \
--target "$TARGET" \
--bundle "$BUNDLE" \
--entrypoint-dir "target/${TARGET}/release" \
--archive-dir "dist/${TARGET}" \
--zsh-manifest "${RUNNER_TEMP}/codex-zsh"
--zsh-manifest "${RUNNER_TEMP}/codex-zsh" \
"${voice_args[@]}"
- name: Cosign Linux voice package archives
if: ${{ matrix.bundle == 'primary' && contains(matrix.target, 'linux') }}
uses: ./.github/actions/linux-code-sign
with:
target: ${{ matrix.target }}
artifacts-dir: ${{ github.workspace }}/codex-rs/dist/${{ matrix.target }}
binaries: codex-package-${{ matrix.target }}.tar.gz codex-package-${{ matrix.target }}.tar.zst
- name: Build Python runtime wheel
if: ${{ matrix.bundle == 'primary' && runner.os != 'macOS' }}
@@ -413,13 +457,31 @@ jobs:
# the Homebrew Python as externally managed under PEP 668.
"${RUNNER_TEMP}/python-runtime-build-venv/bin/python" -m pip install build
# Keep the existing manylinux_2_17 wheel compatible with older glibc.
# GNU voice libraries belong in the signed release package archives,
# but their minimum glibc version is not covered by this wheel tag.
wheel_archives="${RUNNER_TEMP}/voice-free-wheel/${{ matrix.target }}"
bash "${GITHUB_WORKSPACE}/.github/scripts/build-codex-package-archive.sh" \
--target "${{ matrix.target }}" \
--bundle primary \
--entrypoint-dir "target/${{ matrix.target }}/release" \
--archive-dir "$wheel_archives" \
--zsh-manifest "${RUNNER_TEMP}/codex-zsh"
wheel_archive="${wheel_archives}/codex-package-${{ matrix.target }}.tar.gz"
python3 - "$wheel_archive" <<'PY'
import sys
import tarfile
with tarfile.open(sys.argv[1]) as archive:
assert not any("codex-resources/voice/" in item.name for item in archive)
PY
stage_dir="${RUNNER_TEMP}/openai-codex-cli-bin-${{ matrix.target }}"
wheel_dir="${GITHUB_WORKSPACE}/python-runtime-dist/${{ matrix.target }}"
stage_runtime_args=(
"${GITHUB_WORKSPACE}/sdk/python/scripts/update_sdk_artifacts.py"
stage-runtime
"$stage_dir"
"dist/${{ matrix.target }}/codex-package-${{ matrix.target }}.tar.gz"
"$wheel_archive"
--codex-version "${GITHUB_REF_NAME}"
--platform-tag "$platform_tag"
)
@@ -915,7 +977,7 @@ jobs:
set -euo pipefail
voice_args=()
if [[ "$BUNDLE" == "primary" ]]; then
voice_args+=(--voice-signed-dir "${RUNNER_TEMP}/signed-voice/${TARGET}")
voice_args+=(--voice-release-dir "${RUNNER_TEMP}/signed-voice/${TARGET}")
voice_args+=(--release-version "${GITHUB_REF_NAME#rust-v}")
fi
bash "${GITHUB_WORKSPACE}/.github/scripts/build-codex-package-archive.sh" \

View File

@@ -142,6 +142,11 @@ use_repo(osx, "macos_sdk")
# Needed to disable xcode...
bazel_dep(name = "apple_support", version = "2.1.0")
bazel_dep(name = "rules_cc", version = "0.2.18")
# Windows voice jobs explicitly select the installed MSVC C toolchains.
cc_configure = use_extension("@rules_cc//cc:extensions.bzl", "cc_configure_extension")
use_repo(cc_configure, "local_config_cc")
single_version_override(
module_name = "rules_cc",
patch_strip = 1,

View File

@@ -25,12 +25,28 @@ const RUNTIME_INITIALIZATION_DEADLINE: Duration = Duration::from_secs(/*secs*/ 3
pub enum ConnectionError {
NegotiationTimedOut,
Failed,
HelperStartup,
RuntimeInitialization,
Transport,
AudioDevices,
AudioControls,
AudioSession,
Shutdown,
}
impl std::fmt::Display for ConnectionError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(match self {
Self::NegotiationTimedOut => "voice negotiation timed out",
Self::Failed => "voice connection failed",
Self::HelperStartup => "voice helper could not start",
Self::RuntimeInitialization => "voice audio runtime could not initialize",
Self::Transport => "voice transport could not connect",
Self::AudioDevices => {
"voice audio devices could not open; check microphone and speaker setup"
}
Self::AudioControls => "voice audio controls failed",
Self::AudioSession => "voice audio session stopped unexpectedly",
Self::Shutdown => "voice helper could not shut down cleanly",
})
}
}
@@ -149,6 +165,16 @@ impl VoiceHost {
"voice helper must be inside the physical package"
);
let environment = child_environment(std::env::vars_os());
#[cfg(target_os = "linux")]
let environment = {
let mut environment = environment;
if let Some(directory) =
crate::linux_alsa::plugin_directory(crate::linux_alsa::PLUGIN_DIRECTORIES)
{
environment.insert("ALSA_PLUGIN_DIR".to_owned(), directory.to_owned());
}
environment
};
let SpawnedProcess {
session,
stdout_rx,

View File

@@ -14,6 +14,7 @@ fn forwards_only_explicit_device_network_and_os_inputs() {
("DYLD_INSERT_LIBRARIES", "loader"),
("PATH", "project"),
("GST_PLUGIN_PATH", "plugins"),
("ALSA_PLUGIN_DIR", "untrusted-alsa-plugins"),
("GST_REGISTRY", "untrusted-registry"),
("GST_REGISTRY_FORK", "yes"),
("OPENAI_API_KEY", "secret"),

View File

@@ -1,5 +1,7 @@
mod client;
mod helper_exit;
#[cfg(any(target_os = "linux", test))]
mod linux_alsa;
mod message_reader;
mod protocol;
mod session;

View File

@@ -0,0 +1,28 @@
//! Locate system ALSA plugins for the statically linked Linux voice helper.
//!
//! Bazel's ALSA defaults to /usr/lib/alsa-lib. Distribution plugins may instead
//! live in a multiarch or lib64 directory. Only fixed system paths are admitted;
//! caller-provided plugin directories and loader search paths remain excluded.
use std::path::Path;
#[cfg(target_os = "linux")]
pub(crate) const PLUGIN_DIRECTORIES: &[&str] = &[
#[cfg(target_arch = "x86_64")]
"/usr/lib/x86_64-linux-gnu/alsa-lib",
#[cfg(target_arch = "aarch64")]
"/usr/lib/aarch64-linux-gnu/alsa-lib",
"/usr/lib64/alsa-lib",
"/usr/lib/alsa-lib",
];
pub(crate) fn plugin_directory<'a>(candidates: &[&'a str]) -> Option<&'a str> {
candidates
.iter()
.copied()
.find(|path| Path::new(path).is_dir())
}
#[cfg(test)]
#[path = "linux_alsa_tests.rs"]
mod tests;

View File

@@ -0,0 +1,38 @@
//! Verify fallback among installed layouts while ignoring non-directory entries.
use pretty_assertions::assert_eq;
use super::plugin_directory;
#[test]
fn discovers_available_layout_without_selecting_a_file() -> std::io::Result<()> {
let root = std::env::temp_dir().join(format!(
"codex-alsa-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
std::fs::create_dir(&root)?;
let multiarch = root.join("multiarch");
let lib64 = root.join("lib64");
let generic = root.join("generic");
let candidates = [
multiarch.to_str().unwrap(),
lib64.to_str().unwrap(),
generic.to_str().unwrap(),
];
assert_eq!(plugin_directory(&candidates), None);
std::fs::create_dir(&generic)?;
assert_eq!(plugin_directory(&candidates), Some(candidates[2]));
std::fs::write(&lib64, "not a directory")?;
assert_eq!(plugin_directory(&candidates), Some(candidates[2]));
std::fs::remove_file(&lib64)?;
std::fs::create_dir(&lib64)?;
assert_eq!(plugin_directory(&candidates), Some(candidates[1]));
std::fs::create_dir(&multiarch)?;
assert_eq!(plugin_directory(&candidates), Some(candidates[0]));
std::fs::remove_dir_all(root)
}

View File

@@ -16,6 +16,7 @@ use futures::future::Abortable;
use tokio::sync::mpsc;
use crate::AudioControls;
use crate::ConnectionError;
use crate::SessionDescription;
use crate::VoiceHost;
@@ -134,30 +135,37 @@ impl RealtimeWebrtcSession {
.spawn(move || {
let task = async {
let host = report_failure(
"connect",
ConnectionError::HelperStartup,
VoiceHost::connect(&package, &build_commit).await,
)?;
let host =
report_failure("initialize_runtime", host.initialize_runtime().await)?;
let host = report_failure(
ConnectionError::RuntimeInitialization,
host.initialize_runtime().await,
)?;
let (host, sdp) =
report_failure("start_transport", host.start_transport().await)?;
report_failure(ConnectionError::Transport, host.start_transport().await)?;
offer
.send(sdp.into_sdp())
.send(Ok(sdp.into_sdp()))
.map_err(|_| anyhow::anyhow!("voice startup cancelled"))?;
run(host, receiver, &state, &controls).await
};
let result = runtime.block_on(Abortable::new(Abortable::new(task, abort), stopped));
if !matches!(result, Err(_) | Ok(Err(_)) | Ok(Ok(Ok(())))) {
if let Ok(Ok(Err(error))) = result {
let failure = error
.downcast_ref::<ConnectionError>()
.copied()
.unwrap_or(ConnectionError::Failed);
let _ = offer.try_send(Err(failure));
*state
.error
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner) =
Some("Voice helper stopped unexpectedly.".into());
Some(failure.to_string());
}
})?;
let offer_sdp = result
.recv_timeout(STARTUP_WAIT)
.map_err(|_| anyhow::anyhow!("voice startup failed"))?;
.map_err(|_| anyhow::anyhow!("voice startup failed"))??;
Ok(StartedRealtimeWebrtcSession { offer_sdp, handle })
}
}
@@ -267,35 +275,39 @@ async fn run(
biased;
command = commands.recv() => match command {
Some(Command::Answer(sdp, complete)) if !connected => {
host = match host.apply_answer(sdp).await {
let startup = async {
let mut host = report_failure(ConnectionError::Transport, host.apply_answer(sdp).await)?;
host = report_failure(ConnectionError::AudioDevices, host.open_devices().await)?;
let applied = startup_controls(&mut commands, controls, |initial| {
host.begin_audio_controls(initial)
});
let applied = report_failure(ConnectionError::AudioControls, applied)?;
report_failure(ConnectionError::AudioControls, applied.await)?;
Ok::<_, anyhow::Error>(host)
}.await;
host = match startup {
Ok(host) => host,
Err(error) => {
let failure = error.downcast_ref::<crate::ConnectionError>()
.copied().unwrap_or(crate::ConnectionError::Failed);
let failure = error.downcast_ref::<ConnectionError>()
.copied().unwrap_or(ConnectionError::Failed);
let _ = complete.send(Err(failure));
// This failure is delivered by the startup completion only.
return Ok(());
}
};
host = report_failure("open_devices", host.open_devices().await)?;
let applied = startup_controls(&mut commands, controls, |initial| {
host.begin_audio_controls(initial)
});
let applied = report_failure("queue_startup_controls", applied)?;
report_failure("apply_startup_controls", applied.await)?;
connected = true;
let _ = complete.send(Ok(()));
}
Some(Command::Controls(next)) => {
if connected {
report_failure("set_audio_controls", host.set_audio_controls(next).await)?;
report_failure(ConnectionError::AudioControls, host.set_audio_controls(next).await)?;
}
}
Some(Command::Answer(..)) => anyhow::bail!("voice answer already applied"),
None => return report_failure("close", host.close().await),
None => return report_failure(ConnectionError::Shutdown, host.close().await),
},
_ = poll.tick() => {
let audio = report_failure("inspect_audio", host.inspect_audio().await)?;
let audio = report_failure(ConnectionError::AudioSession, host.inspect_audio().await)?;
state.microphone.fetch_max(audio.microphone_peak, Ordering::Release);
state.speaker.fetch_max(audio.speaker_peak, Ordering::Release);
}
@@ -304,8 +316,8 @@ async fn run(
}
// Keep diagnostics bounded and independent of untyped native, SDP, or device error text.
fn report_failure<T>(stage: &'static str, result: Result<T>) -> Result<T> {
result.inspect_err(|error| {
fn report_failure<T>(stage: ConnectionError, result: Result<T>) -> Result<T> {
result.map_err(|error| {
let kind = if error.is::<tokio::time::error::Elapsed>() {
"timeout"
} else if let Some(error) = error.downcast_ref::<std::io::Error>() {
@@ -319,7 +331,13 @@ fn report_failure<T>(stage: &'static str, result: Result<T>) -> Result<T> {
} else {
"other"
};
tracing::warn!(stage, kind, "voice session operation failed");
tracing::warn!(?stage, kind, "voice session operation failed");
let failure = error
.downcast_ref::<ConnectionError>()
.copied()
.unwrap_or(stage);
// Replace the original error instead of retaining a potentially sensitive source chain.
anyhow::Error::new(failure)
})
}

View File

@@ -198,3 +198,26 @@ async fn startup_completion_preserves_classified_failure() {
assert_eq!(handle.take_error(), None);
}
}
#[test]
fn classified_errors_discard_sensitive_sources_but_preserve_retry_classification() {
let error = report_failure::<()>(
ConnectionError::AudioDevices,
Err(anyhow::anyhow!("synthetic-secret-sdp-device-path")),
)
.unwrap_err();
assert_eq!(
format!("{error:#}"),
ConnectionError::AudioDevices.to_string()
);
assert!(!format!("{error:?}").contains("synthetic-secret"));
let error = report_failure::<()>(
ConnectionError::Transport,
Err(ConnectionError::NegotiationTimedOut.into()),
)
.unwrap_err();
assert_eq!(
error.downcast_ref::<ConnectionError>(),
Some(&ConnectionError::NegotiationTimedOut)
);
}

View File

@@ -153,6 +153,9 @@ fn helper() -> Result<()> {
if root.join("hold-initialization").exists() {
wait_for(|| root.join("release").exists())?;
}
if root.join("fail-initialization").exists() {
anyhow::bail!("synthetic private runtime diagnostic");
}
stage = Stage::Offer;
Message::RuntimeReady {}
}
@@ -176,6 +179,9 @@ fn helper() -> Result<()> {
Message::TransportReady {}
}
Message::OpenDevices {} if stage == Stage::Devices => {
if root.join("fail-devices").exists() {
anyhow::bail!("synthetic private device diagnostic");
}
stage = Stage::Controls;
Message::DevicesOpened {}
}

View File

@@ -67,7 +67,10 @@ fn startup_controls_meters_and_helper_loss() -> Result<()> {
error = handle.take_error();
error.is_some()
})?;
assert_eq!(error.as_deref(), Some("Voice helper stopped unexpectedly."));
assert_eq!(
error.as_deref(),
Some("voice audio session stopped unexpectedly")
);
assert_eq!(handle.take_error(), None);
Ok(())
}
@@ -107,3 +110,47 @@ fn last_owner_drop_reaps_helper() -> Result<()> {
drop(started);
common::wait_for_helper_reaped(&root)
}
#[test]
fn device_failure_reaches_startup_completion_without_duplicate_error() -> Result<()> {
let Some(root) =
common::package("device_failure_reaches_startup_completion_without_duplicate_error")?
else {
return Ok(());
};
fs::write(root.join("fail-devices"), [])?;
fs::write(root.join("release"), [])?;
let (_abort, registration) = AbortHandle::new_pair();
let started = RealtimeWebrtcSession::start(registration)?;
assert_eq!(
started.handle.apply_answer_sdp("synthetic-answer".into()),
Err(codex_realtime_webrtc::ConnectionError::AudioDevices)
);
#[cfg(unix)]
common::wait_for_helper_reaped(&root)?;
assert_eq!(started.handle.take_error(), None);
Ok(())
}
#[test]
fn runtime_failure_reaches_offer_caller_without_native_error_text() -> Result<()> {
let Some(root) =
common::package("runtime_failure_reaches_offer_caller_without_native_error_text")?
else {
return Ok(());
};
fs::write(root.join("fail-initialization"), [])?;
let (_abort, registration) = AbortHandle::new_pair();
let error = RealtimeWebrtcSession::start(registration).unwrap_err();
assert_eq!(
error.downcast_ref::<codex_realtime_webrtc::ConnectionError>(),
Some(&codex_realtime_webrtc::ConnectionError::RuntimeInitialization)
);
assert_eq!(
format!("{error:#}"),
"voice audio runtime could not initialize"
);
#[cfg(unix)]
common::wait_for_helper_reaped(&root)?;
Ok(())
}

View File

@@ -1599,6 +1599,7 @@ impl ChatWidget {
{
self.record_realtime_failure();
}
let failed = self.realtime_conversation.failure_recorded;
self.reset_realtime_conversation();
if let Some(thread_id) = retry_thread_id {
// The old backend is closed. A late peer result belongs to its attempt ID.
@@ -1615,6 +1616,7 @@ impl ChatWidget {
}
if let Some(reason) = reason
&& reason != "error"
&& !(failed && reason == "requested")
{
self.add_info_message(
format!("Voice conversation ended: {reason}"),

View File

@@ -471,3 +471,34 @@ async fn startup_retry_never_retries_twice_or_retries_other_errors_or_active_ses
assert!(ops.try_recv().is_err(), "no retry may be queued");
}
}
#[tokio::test]
async fn failure_cleanup_does_not_attribute_stop_to_the_user() {
let (mut chat, _sender, mut events, _ops) = make_chatwidget_manual_with_sender().await;
// A local failure initiates backend cleanup; its acknowledgement is still "requested".
chat.realtime_conversation.phase = RealtimeConversationPhase::Stopping;
chat.realtime_conversation.failure_recorded = true;
chat.on_realtime_error(format!(
"Failed to connect voice mode: {}",
codex_realtime_webrtc::ConnectionError::AudioDevices
));
chat.on_realtime_conversation_closed(Some("requested".into()));
assert_eq!(
chat.realtime_conversation.phase,
RealtimeConversationPhase::Inactive
);
let rendered = std::iter::from_fn(|| events.try_recv().ok())
.filter_map(|event| match event {
AppEvent::InsertHistoryCell(cell) => Some(
cell.display_lines(/*width*/ 80)
.into_iter()
.map(|line| line.to_string())
.collect::<Vec<_>>()
.join("\n"),
),
_ => None,
})
.collect::<Vec<_>>()
.join("\n");
insta::assert_snapshot!("voice_device_failure_cleanup", rendered);
}

View File

@@ -0,0 +1,6 @@
---
source: tui/src/chatwidget/realtime_tests/lifecycle.rs
assertion_line: 498
expression: rendered
---
■ Failed to connect voice mode: voice audio devices could not open; check microphone and speaker setup

View File

@@ -243,9 +243,12 @@ fn bounded_stream_config(
if min > max {
return Err(io::Error::other("unsupported audio callback size range"));
}
// Aim for 10 ms without consuming the queue's service headroom.
// Do not fall back to the backend's potentially much larger default buffer.
let frames = (config.sample_rate / 100).clamp(min, max);
// ALSA allocates two periods. A 20 ms ring can be smaller than one PipeWire
// graph cycle (e.g. 2048 frames at 48 kHz), silently losing capture samples
// every cycle. Give Linux a bounded 100 ms ring instead, while retaining
// 10 ms callbacks elsewhere and rejecting incompatible device ranges below.
let periods_per_second = if cfg!(target_os = "linux") { 20 } else { 100 };
let frames = (config.sample_rate / periods_per_second).clamp(min, max);
let callback_duration =
Duration::from_secs_f64(f64::from(frames) / f64::from(config.sample_rate));
// Backends may deliver smaller callbacks than requested. Packing makes queue

View File

@@ -72,17 +72,22 @@ fn startup_output_waits_for_service_and_later_overflow_discards_reference() {
#[test]
fn callback_configuration_fits_supported_range_and_actual_queue() {
for (rate, min, max, frames) in [
(96_000, 0, u32::MAX, 960),
(96_000, 1, 16, 16),
(384_000, 1, 64, 64),
(8_000, 7_680, 8_192, 7_680),
(384_000, 1, 16, 16),
(96_000, 1, 15, 15),
(48_000, 1, 128, 128),
(48_000, 2_048, 16_384, 2_048),
(384_000, 6_016, 16_384, 6_016),
for (rate, min, max, other_frames, linux_frames) in [
(96_000, 0, u32::MAX, 960, 4_800),
(96_000, 1, 16, 16, 16),
(384_000, 1, 64, 64, 64),
(8_000, 7_680, 8_192, 7_680, 7_680),
(384_000, 1, 16, 16, 16),
(96_000, 1, 15, 15, 15),
(48_000, 1, 128, 128, 128),
(48_000, 2_048, 16_384, 2_048, 2_400),
(384_000, 6_016, 16_384, 6_016, 8_192),
] {
let frames = if cfg!(target_os = "linux") {
linux_frames
} else {
other_frames
};
let supported = cpal::SupportedStreamConfig::new(
/*channels*/ 2,
rate,
@@ -116,6 +121,77 @@ fn callback_configuration_fits_supported_range_and_actual_queue() {
}
}
#[test]
#[cfg(target_os = "linux")]
fn pipewire_graph_cycles_support_capture_and_playback() {
let supported = cpal::SupportedStreamConfig::new(
/*channels*/ 2,
/*sample_rate*/ 48_000,
cpal::SupportedBufferSize::Range {
min: 1,
max: 16_384,
},
cpal::SampleFormat::F32,
);
let cpal::BufferSize::Fixed(period) = bounded_stream_config(&supported).unwrap().buffer_size
else {
panic!("callback size must be bounded");
};
let mut processor =
processing::Processor::new(/*input_rate*/ 48_000, /*output_rate*/ 48_000).unwrap();
let mut packer = FramePacker::default();
let buffers = Arc::new(Buffers::new(
/*input_rate*/ 48_000, /*output_rate*/ 48_000,
));
let start = Instant::now();
let mut available = 0;
let mut packets = Vec::new();
// Model measured 2048-frame graph cycles and ALSA's two-period ring.
// Packing and encoding must sustain 20 ms packets despite this cadence.
for cycle in 1..=48 {
available = (available + 2_048).min(2 * period);
let now = start + Duration::from_secs_f64(f64::from(cycle * 2_048) / 48_000.0);
while available >= period {
let captured = now - Duration::from_secs_f64(f64::from(available) / 48_000.0);
packer.discard_capture_gap(captured, /*rate*/ 48_000.0);
for offset in (0..period as usize).step_by(BLOCK) {
let frame = Frame {
samples: [0.25; BLOCK],
len: BLOCK.min(period as usize - offset),
at: captured + Duration::from_secs_f64(offset as f64 / 48_000.0),
generation: 2,
};
assert!(packer.push(frame, /*rate*/ 48_000.0, &buffers.capture));
}
available -= period;
while let Some(frame) = buffers.capture.pop() {
packets.extend(processor.capture(&frame, || now).unwrap());
}
}
}
assert!(packets.len() >= 90, "capture starved Opus");
// Queue a full output callback without racing the producer.
Buffers::set_disabled(&buffers.speaker, /*disabled*/ false).unwrap();
buffers.serviced.store(true, Ordering::Release);
let port = PlaybackPort::new(buffers.clone(), /*rate*/ 48_000);
let writer = port.writer();
let bytes = 0.25_f32.to_le_bytes().repeat(period as usize);
for chunk in bytes.chunks(BLOCK * 4) {
assert_eq!(writer.write(chunk).unwrap(), chunk.len());
}
let mut output = vec![0.0_f32; period as usize];
render_output(
&mut output,
/*channels*/ 1,
/*rate*/ 48_000.0,
Instant::now(),
&buffers,
&mut OutputState::default(),
);
assert_eq!(output, vec![0.25; period as usize]);
}
#[test]
fn callback_configuration_fits_dynamic_queue_with_following_callbacks() {
for min in [6_016, 8_192] {

View File

@@ -84,7 +84,11 @@ impl PlaybackWriter {
.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);
// Linux requests 50 ms callbacks; retain two callbacks of audio so a
// callback can fill without racing the producer. Keep the queue cap.
let windows_per_second = if cfg!(target_os = "linux") { 10 } else { 25 };
let limit = (self.state.rate / windows_per_second)
.min((BLOCK * buffers.playback.capacity()) as u32);
loop {
if self.epoch % 2 == 1
|| buffers.speaker.load(Ordering::Acquire) != self.epoch

View File

@@ -35,7 +35,9 @@ fn partial_writes_account_for_samples_until_the_device_consumes_them() {
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();
for _ in 0..if cfg!(target_os = "linux") { 3 } else { 1 } {
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"));
@@ -129,11 +131,17 @@ fn invalid_samples_fail_but_stalled_consumption_drops_and_resumes() {
writer.write(&f32::NAN.to_le_bytes()),
Err("invalid speaker sample")
);
writer.write(&vec![0; BLOCK * 4]).unwrap();
let blocks = if cfg!(target_os = "linux") { 3 } else { 1 };
for _ in 0..blocks {
writer.write(&vec![0; BLOCK * 4]).unwrap();
}
assert_eq!(writer.write(&vec![0; BLOCK * 4]), Ok(BLOCK * 4));
assert_eq!(buffers.queued.load(Ordering::Acquire), BLOCK as u32);
assert_eq!(
buffers.queued.load(Ordering::Acquire),
(blocks * BLOCK) as u32
);
let mut playback = Playback::default();
for _ in 0..BLOCK {
for _ in 0..blocks * BLOCK {
assert!(playback.next(&buffers).is_some());
}
assert_eq!(writer.write(&vec![0; BLOCK * 4]), Ok(BLOCK * 4));

View File

@@ -133,6 +133,30 @@ alias(
visibility = ["//visibility:public"],
)
# Explicitly select the installed pkgconf and pinned CMake in Windows voice CI.
native_tool_toolchain(
name = "windows_pkg_config_tool",
env = {"PKG_CONFIG": "$(execpath :pkg_config)"},
path = "$(execpath :pkg_config)",
target = ":pkg_config",
)
toolchain(
name = "windows_pkg_config_toolchain",
exec_compatible_with = ["@platforms//os:windows"],
toolchain = ":windows_pkg_config_tool",
toolchain_type = "@rules_foreign_cc//toolchains:pkgconfig_toolchain",
visibility = ["//visibility:public"],
)
toolchain(
name = "windows_cmake_toolchain",
exec_compatible_with = ["@platforms//os:windows"],
toolchain = "@cmake-3.31.8-windows-x86_64//:cmake_tool",
toolchain_type = "@rules_foreign_cc//toolchains:cmake_toolchain",
visibility = ["//visibility:public"],
)
filegroup(
name = "archives",
srcs = [

View File

@@ -1,6 +1,6 @@
# Native voice libraries in Codex releases
The macOS Codex release package includes dynamically linked GStreamer and GLib
Codex release packages with voice include dynamically linked GStreamer and GLib
libraries and selected plugins, plus their native library dependencies. These
components have their own copyrights and licenses. Their notices accompany
this file in `licenses/`: `LGPL-2.1.txt` for GStreamer and GLib,
@@ -12,6 +12,6 @@ The exact upstream versions, source archive URLs and SHA-256 digests are in
projection and package scripts are in the public Codex source tree under
`third_party/voice/`. The source commit for this package is recorded in
`manifest.json`. The native libraries remain separate dynamic libraries in
`lib/` and `plugins/`; replacing them requires compatible binaries and valid
macOS code signatures. Build tools listed in `sources.json` are build inputs,
not bundled runtime libraries.
platform-specific runtime directories. Replacements must be compatible with
the package and, on macOS, have valid code signatures. Build tools listed in
`sources.json` are build inputs, not bundled runtime libraries.

View File

@@ -258,11 +258,14 @@ The module does not download that installed tree or accept compiler licenses.
After provisioning, a native PowerShell invocation is:
```powershell
$hostArch = $env:PROCESSOR_ARCHITEW6432
if (-not $hostArch) { $hostArch = $env:PROCESSOR_ARCHITECTURE }
bazel build //third_party/voice:native_link_windows_x86_64 `
--platforms=//:local_windows_msvc `
--inject_repository="voice_windows_tools=$env:VOICE_WINDOWS_BAZEL_REPOSITORY" `
--//third_party/voice:windows_installed_tools=@voice_windows_tools//:tools `
--action_env="SystemRoot=$env:SystemRoot" --host_action_env="SystemRoot=$env:SystemRoot"
--action_env="SystemRoot=$env:SystemRoot" --host_action_env="SystemRoot=$env:SystemRoot" `
--action_env="PROCESSOR_ARCHITECTURE=$hostArch" --host_action_env="PROCESSOR_ARCHITECTURE=$hostArch"
```
Use `native_link_windows_aarch64` on native ARM64 Windows. Existing MSVC license

View File

@@ -64,9 +64,13 @@ def main():
environment, _ = build_environment(
document, inputs["target"], {"python": Path(sys.executable)}
)
# Keep executor scratch directories, never its developer tool search path.
# Keep host identity and scratch directories, never developer tool search paths.
environment.update(
{name: os.environ[name] for name in ("TMP", "TEMP") if name in os.environ}
{
name: os.environ[name]
for name in ("TMP", "TEMP", "PROCESSOR_ARCHITECTURE")
if name in os.environ
}
)
home = temporary / "home"
home.mkdir()

View File

@@ -1,4 +1,4 @@
"""Stage verified macOS voice libraries and seal their post-signing release receipt."""
"""Stage verified voice libraries and seal their public-release receipt."""
import argparse
import json
@@ -10,8 +10,13 @@ from runtime import digest
def stage(source: Path, destination: Path, target: str) -> None:
if target not in {"aarch64-apple-darwin", "x86_64-apple-darwin"}:
raise ValueError("public voice runtime requires a macOS target")
if target not in {
"aarch64-apple-darwin",
"x86_64-apple-darwin",
"aarch64-unknown-linux-gnu",
"x86_64-unknown-linux-gnu",
}:
raise ValueError("unsupported public release voice runtime target")
source = source.resolve(strict=True)
files = runtime_files(source, target)
destination.mkdir()

View File

@@ -276,6 +276,38 @@ class AssembleTests(unittest.TestCase):
)
self.assertEqual(manifest["appVersion"], version)
def test_linux_release_pairs_musl_app_with_gnu_voice_runtime(self):
self.commit = "b" * 40
target = "aarch64-unknown-linux-gnu"
runtime, _ = self.make_runtime(target)
staged = self.root / "staged"
stage(runtime, staged, target)
seal(staged, target)
for version in ("0.154.0-alpha.8", "0.154.0-beta.2", "0.154.0"):
with self.subTest(version=version):
self.metadata["version"] = version
(self.package / "codex-package.json").write_text(
json.dumps(self.metadata)
)
output = self.root / f"linux-{version}"
assemble(
self.package,
self.helper,
target,
self.commit,
output,
runtime=staged,
release_version=version,
)
voice = output / "codex-resources/voice"
manifest = json.loads((voice / "manifest.json").read_text())
self.assertEqual(manifest["appTarget"], "aarch64-unknown-linux-musl")
self.assertEqual(manifest["voiceTarget"], target)
self.assertEqual(
manifest["sha256"]["codex-resources/voice/runtime.json"],
digest(staged / "runtime.json"),
)
def test_rejects_invalid_runtime_receipts_before_creating_package(self):
runtime, original = self.make_runtime()
changes = [

View File

@@ -5,13 +5,56 @@ import errno
import json
from pathlib import Path
import tempfile
from types import SimpleNamespace
import unittest
from unittest.mock import patch
import bazel_windows
from bazel_copy import copy_payloads
from bazel_windows import selected_inputs
class WindowsInputsTests(unittest.TestCase):
def test_adapter_preserves_host_architecture_without_developer_path(self):
with tempfile.TemporaryDirectory() as directory:
config = Path(directory) / "action.json"
config.write_text("{}")
for architecture in ("AMD64", "ARM64"):
environment = {"PROCESSOR_ARCHITECTURE": architecture, "PATH": "unsafe"}
with (
self.subTest(architecture=architecture),
patch.object(
bazel_windows, "os", SimpleNamespace(environ=environment)
),
patch.object(
bazel_windows.sys, "argv", ["driver", "unknown", str(config)]
),
patch.object(
bazel_windows,
"selected_inputs",
return_value={"target": "unused"},
),
patch.object(
bazel_windows,
"build_environment",
return_value=({"PATH": "declared"}, {}),
),
):
with self.assertRaisesRegex(
ValueError, "unknown Windows native action"
):
bazel_windows.main()
self.assertEqual(
environment,
{
"PROCESSOR_ARCHITECTURE": architecture,
"PATH": "declared",
"HOME": environment["HOME"],
"USERPROFILE": environment["HOME"],
},
)
def setUp(self):
temporary = tempfile.TemporaryDirectory()
self.addCleanup(temporary.cleanup)

View File

@@ -25,6 +25,9 @@ def _windows_tools_impl(ctx):
system_root = ctx.configuration.default_shell_env.get("SystemRoot")
if not system_root:
fail("Pass --action_env=SystemRoot=<actual Windows directory> as a fixed value")
host_architecture = ctx.configuration.default_shell_env.get("PROCESSOR_ARCHITECTURE")
if not host_architecture:
fail("Pass --action_env=PROCESSOR_ARCHITECTURE=<actual host architecture> as a fixed value")
msvc = ctx.attr.msvc[DirectoryInfo]
sdk = ctx.attr.sdk[DirectoryInfo]
tools = {
@@ -68,7 +71,11 @@ def _windows_tools_impl(ctx):
python = python,
manifest = manifests[0],
installed_files = installed,
environment = {"SystemRoot": system_root, "SYSTEMROOT": system_root},
environment = {
"SystemRoot": system_root,
"SYSTEMROOT": system_root,
"PROCESSOR_ARCHITECTURE": host_architecture,
},
inputs = {
"schemaVersion": 1,
"target": ctx.attr.target,