From 231e6844cc167cf5b50e5f7b53e88dd2308617c4 Mon Sep 17 00:00:00 2001 From: Benjamin Carlsson Date: Fri, 11 Sep 2026 03:06:01 +0000 Subject: [PATCH] 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 --- .../scripts/build-codex-package-archive.sh | 26 ++--- .github/workflows/rust-release.yml | 74 ++++++++++++-- MODULE.bazel | 5 + codex-rs/realtime-webrtc/src/client.rs | 26 +++++ codex-rs/realtime-webrtc/src/client_tests.rs | 1 + codex-rs/realtime-webrtc/src/lib.rs | 2 + codex-rs/realtime-webrtc/src/linux_alsa.rs | 28 ++++++ .../realtime-webrtc/src/linux_alsa_tests.rs | 38 ++++++++ codex-rs/realtime-webrtc/src/session.rs | 64 ++++++++----- codex-rs/realtime-webrtc/src/session_tests.rs | 23 +++++ codex-rs/realtime-webrtc/tests/common/mod.rs | 6 ++ .../realtime-webrtc/tests/session_actor.rs | 49 +++++++++- codex-rs/tui/src/chatwidget/realtime.rs | 2 + .../chatwidget/realtime_tests/lifecycle.rs | 31 ++++++ ...fecycle__voice_device_failure_cleanup.snap | 6 ++ codex-rs/voice-host/src/devices.rs | 9 +- codex-rs/voice-host/src/devices_tests.rs | 96 +++++++++++++++++-- codex-rs/voice-host/src/playback.rs | 6 +- codex-rs/voice-host/src/playback_tests.rs | 16 +++- third_party/voice/BUILD.bazel | 24 +++++ third_party/voice/NOTICE.md | 8 +- third_party/voice/README.md | 5 +- third_party/voice/bazel_windows.py | 8 +- third_party/voice/release_runtime.py | 11 ++- third_party/voice/test_assemble_package.py | 32 +++++++ third_party/voice/test_bazel_windows.py | 43 +++++++++ third_party/voice/windows_native.bzl | 9 +- 27 files changed, 578 insertions(+), 70 deletions(-) create mode 100644 codex-rs/realtime-webrtc/src/linux_alsa.rs create mode 100644 codex-rs/realtime-webrtc/src/linux_alsa_tests.rs create mode 100644 codex-rs/tui/src/chatwidget/realtime_tests/snapshots/codex_tui__chatwidget__realtime__tests__lifecycle__voice_device_failure_cleanup.snap diff --git a/.github/scripts/build-codex-package-archive.sh b/.github/scripts/build-codex-package-archive.sh index 7b5ac74865..1ac54c0cce 100644 --- a/.github/scripts/build-codex-package-archive.sh +++ b/.github/scripts/build-codex-package-archive.sh @@ -15,7 +15,7 @@ Usage: build-codex-package-archive.sh \ [--zsh-manifest ] \ [--codex-command-runner-bin ] \ [--codex-windows-sandbox-setup-bin ] \ - [--voice-signed-dir --release-version ] \ + [--voice-release-dir --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" diff --git a/.github/workflows/rust-release.yml b/.github/workflows/rust-release.yml index d351371dfa..293c4bdf79 100644 --- a/.github/workflows/rust-release.yml +++ b/.github/workflows/rust-release.yml @@ -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" \ diff --git a/MODULE.bazel b/MODULE.bazel index 6f74ef307b..e014c6d32c 100644 --- a/MODULE.bazel +++ b/MODULE.bazel @@ -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, diff --git a/codex-rs/realtime-webrtc/src/client.rs b/codex-rs/realtime-webrtc/src/client.rs index aa56305646..ed2f50e37f 100644 --- a/codex-rs/realtime-webrtc/src/client.rs +++ b/codex-rs/realtime-webrtc/src/client.rs @@ -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, diff --git a/codex-rs/realtime-webrtc/src/client_tests.rs b/codex-rs/realtime-webrtc/src/client_tests.rs index 7f77d719ff..5fde5b791f 100644 --- a/codex-rs/realtime-webrtc/src/client_tests.rs +++ b/codex-rs/realtime-webrtc/src/client_tests.rs @@ -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"), diff --git a/codex-rs/realtime-webrtc/src/lib.rs b/codex-rs/realtime-webrtc/src/lib.rs index 6ce6b1d732..d4776cfcdd 100644 --- a/codex-rs/realtime-webrtc/src/lib.rs +++ b/codex-rs/realtime-webrtc/src/lib.rs @@ -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; diff --git a/codex-rs/realtime-webrtc/src/linux_alsa.rs b/codex-rs/realtime-webrtc/src/linux_alsa.rs new file mode 100644 index 0000000000..bd98648bd5 --- /dev/null +++ b/codex-rs/realtime-webrtc/src/linux_alsa.rs @@ -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; diff --git a/codex-rs/realtime-webrtc/src/linux_alsa_tests.rs b/codex-rs/realtime-webrtc/src/linux_alsa_tests.rs new file mode 100644 index 0000000000..9afd97e5a3 --- /dev/null +++ b/codex-rs/realtime-webrtc/src/linux_alsa_tests.rs @@ -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) +} diff --git a/codex-rs/realtime-webrtc/src/session.rs b/codex-rs/realtime-webrtc/src/session.rs index a0f95b4bfe..fee0c39669 100644 --- a/codex-rs/realtime-webrtc/src/session.rs +++ b/codex-rs/realtime-webrtc/src/session.rs @@ -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::() + .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::() - .copied().unwrap_or(crate::ConnectionError::Failed); + let failure = error.downcast_ref::() + .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(stage: &'static str, result: Result) -> Result { - result.inspect_err(|error| { +fn report_failure(stage: ConnectionError, result: Result) -> Result { + result.map_err(|error| { let kind = if error.is::() { "timeout" } else if let Some(error) = error.downcast_ref::() { @@ -319,7 +331,13 @@ fn report_failure(stage: &'static str, result: Result) -> Result { } else { "other" }; - tracing::warn!(stage, kind, "voice session operation failed"); + tracing::warn!(?stage, kind, "voice session operation failed"); + let failure = error + .downcast_ref::() + .copied() + .unwrap_or(stage); + // Replace the original error instead of retaining a potentially sensitive source chain. + anyhow::Error::new(failure) }) } diff --git a/codex-rs/realtime-webrtc/src/session_tests.rs b/codex-rs/realtime-webrtc/src/session_tests.rs index a5afc9b912..87227e6516 100644 --- a/codex-rs/realtime-webrtc/src/session_tests.rs +++ b/codex-rs/realtime-webrtc/src/session_tests.rs @@ -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::(), + Some(&ConnectionError::NegotiationTimedOut) + ); +} diff --git a/codex-rs/realtime-webrtc/tests/common/mod.rs b/codex-rs/realtime-webrtc/tests/common/mod.rs index 4996a90777..63f8884b48 100644 --- a/codex-rs/realtime-webrtc/tests/common/mod.rs +++ b/codex-rs/realtime-webrtc/tests/common/mod.rs @@ -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 {} } diff --git a/codex-rs/realtime-webrtc/tests/session_actor.rs b/codex-rs/realtime-webrtc/tests/session_actor.rs index 44a8917c1b..ded26288ce 100644 --- a/codex-rs/realtime-webrtc/tests/session_actor.rs +++ b/codex-rs/realtime-webrtc/tests/session_actor.rs @@ -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::(), + 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(()) +} diff --git a/codex-rs/tui/src/chatwidget/realtime.rs b/codex-rs/tui/src/chatwidget/realtime.rs index 186c289596..64a50f1df6 100644 --- a/codex-rs/tui/src/chatwidget/realtime.rs +++ b/codex-rs/tui/src/chatwidget/realtime.rs @@ -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}"), diff --git a/codex-rs/tui/src/chatwidget/realtime_tests/lifecycle.rs b/codex-rs/tui/src/chatwidget/realtime_tests/lifecycle.rs index fe39a83754..aedbcc2fb1 100644 --- a/codex-rs/tui/src/chatwidget/realtime_tests/lifecycle.rs +++ b/codex-rs/tui/src/chatwidget/realtime_tests/lifecycle.rs @@ -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::>() + .join("\n"), + ), + _ => None, + }) + .collect::>() + .join("\n"); + insta::assert_snapshot!("voice_device_failure_cleanup", rendered); +} diff --git a/codex-rs/tui/src/chatwidget/realtime_tests/snapshots/codex_tui__chatwidget__realtime__tests__lifecycle__voice_device_failure_cleanup.snap b/codex-rs/tui/src/chatwidget/realtime_tests/snapshots/codex_tui__chatwidget__realtime__tests__lifecycle__voice_device_failure_cleanup.snap new file mode 100644 index 0000000000..a7a017646f --- /dev/null +++ b/codex-rs/tui/src/chatwidget/realtime_tests/snapshots/codex_tui__chatwidget__realtime__tests__lifecycle__voice_device_failure_cleanup.snap @@ -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 diff --git a/codex-rs/voice-host/src/devices.rs b/codex-rs/voice-host/src/devices.rs index 1f1d58f1f1..41d34d4148 100644 --- a/codex-rs/voice-host/src/devices.rs +++ b/codex-rs/voice-host/src/devices.rs @@ -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 diff --git a/codex-rs/voice-host/src/devices_tests.rs b/codex-rs/voice-host/src/devices_tests.rs index 2bfcffee77..e9f7b40dab 100644 --- a/codex-rs/voice-host/src/devices_tests.rs +++ b/codex-rs/voice-host/src/devices_tests.rs @@ -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] { diff --git a/codex-rs/voice-host/src/playback.rs b/codex-rs/voice-host/src/playback.rs index 282f32aa5d..2fffbfc6b4 100644 --- a/codex-rs/voice-host/src/playback.rs +++ b/codex-rs/voice-host/src/playback.rs @@ -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 diff --git a/codex-rs/voice-host/src/playback_tests.rs b/codex-rs/voice-host/src/playback_tests.rs index a3a45d7db8..70773a1519 100644 --- a/codex-rs/voice-host/src/playback_tests.rs +++ b/codex-rs/voice-host/src/playback_tests.rs @@ -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)); diff --git a/third_party/voice/BUILD.bazel b/third_party/voice/BUILD.bazel index 3cf29ebde6..b3c5f0930e 100644 --- a/third_party/voice/BUILD.bazel +++ b/third_party/voice/BUILD.bazel @@ -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 = [ diff --git a/third_party/voice/NOTICE.md b/third_party/voice/NOTICE.md index b6557717f7..c5b79744a8 100644 --- a/third_party/voice/NOTICE.md +++ b/third_party/voice/NOTICE.md @@ -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. diff --git a/third_party/voice/README.md b/third_party/voice/README.md index d79e3de68d..48ea1fa46c 100644 --- a/third_party/voice/README.md +++ b/third_party/voice/README.md @@ -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 diff --git a/third_party/voice/bazel_windows.py b/third_party/voice/bazel_windows.py index f14941d041..5cd172fb5a 100644 --- a/third_party/voice/bazel_windows.py +++ b/third_party/voice/bazel_windows.py @@ -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() diff --git a/third_party/voice/release_runtime.py b/third_party/voice/release_runtime.py index 6cb0f74efc..6b705ca314 100644 --- a/third_party/voice/release_runtime.py +++ b/third_party/voice/release_runtime.py @@ -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() diff --git a/third_party/voice/test_assemble_package.py b/third_party/voice/test_assemble_package.py index 2eb4999671..7758bd4b12 100644 --- a/third_party/voice/test_assemble_package.py +++ b/third_party/voice/test_assemble_package.py @@ -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 = [ diff --git a/third_party/voice/test_bazel_windows.py b/third_party/voice/test_bazel_windows.py index 945c7e270c..1bb4296e62 100644 --- a/third_party/voice/test_bazel_windows.py +++ b/third_party/voice/test_bazel_windows.py @@ -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) diff --git a/third_party/voice/windows_native.bzl b/third_party/voice/windows_native.bzl index e1a135319e..51c9968dd5 100644 --- a/third_party/voice/windows_native.bzl +++ b/third_party/voice/windows_native.bzl @@ -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= as a fixed value") + host_architecture = ctx.configuration.default_shell_env.get("PROCESSOR_ARCHITECTURE") + if not host_architecture: + fail("Pass --action_env=PROCESSOR_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,