mirror of
https://github.com/openai/codex.git
synced 2026-09-16 12:13:30 +00:00
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:
26
.github/scripts/build-codex-package-archive.sh
vendored
26
.github/scripts/build-codex-package-archive.sh
vendored
@@ -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"
|
||||
|
||||
74
.github/workflows/rust-release.yml
vendored
74
.github/workflows/rust-release.yml
vendored
@@ -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" \
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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;
|
||||
|
||||
28
codex-rs/realtime-webrtc/src/linux_alsa.rs
Normal file
28
codex-rs/realtime-webrtc/src/linux_alsa.rs
Normal 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;
|
||||
38
codex-rs/realtime-webrtc/src/linux_alsa_tests.rs
Normal file
38
codex-rs/realtime-webrtc/src/linux_alsa_tests.rs
Normal 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)
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 {}
|
||||
}
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
@@ -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}"),
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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] {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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));
|
||||
|
||||
24
third_party/voice/BUILD.bazel
vendored
24
third_party/voice/BUILD.bazel
vendored
@@ -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 = [
|
||||
|
||||
8
third_party/voice/NOTICE.md
vendored
8
third_party/voice/NOTICE.md
vendored
@@ -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.
|
||||
|
||||
5
third_party/voice/README.md
vendored
5
third_party/voice/README.md
vendored
@@ -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
|
||||
|
||||
8
third_party/voice/bazel_windows.py
vendored
8
third_party/voice/bazel_windows.py
vendored
@@ -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()
|
||||
|
||||
11
third_party/voice/release_runtime.py
vendored
11
third_party/voice/release_runtime.py
vendored
@@ -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()
|
||||
|
||||
32
third_party/voice/test_assemble_package.py
vendored
32
third_party/voice/test_assemble_package.py
vendored
@@ -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 = [
|
||||
|
||||
43
third_party/voice/test_bazel_windows.py
vendored
43
third_party/voice/test_bazel_windows.py
vendored
@@ -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)
|
||||
|
||||
9
third_party/voice/windows_native.bzl
vendored
9
third_party/voice/windows_native.bzl
vendored
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user