cat / goq
clone

turns a linux box into a private steam streaming appliance: plug in, tap your key, play from another computer

Merge remote-tracking branch 'origin/main' into feat/freeze-compatibility-identifiers

Dami committed 10249de
commit
10249dedede9b03bd68fe48156fd5ca82db1de42
parent
57616407c9ccd56c8803f485a4bf6faa1b0098f3
parent
f41e3896d871edfe59c7f0a13c2f41658800268e
author
Dami <dami.etoile@gmail.com>
62 files changed, +4467 -570
M.github/workflows/ci.yml35+
M.github/workflows/portal-release.yml8+-
M.github/workflows/sigil-release.yml10+-
MAGENTS.md16+
MREADME.md8+-
Mcrates/sigil-host/Cargo.toml1+
Mcrates/sigil-host/src/config.rs6+-
Mcrates/sigil-host/src/cursor.rs345++++++-
Mcrates/sigil-host/src/input.rs201++++-
Mcrates/sigil-host/src/server/adaptive.rs1372++++++++++++++++++++++++------
Mcrates/sigil-host/src/source/annexb.rs189++++-
Mcrates/sigil-host/src/source/in_process.rs14+
Mcrates/sigil-protocol/src/input.rs38+-
Mdocs/fresh-bazzite-host.md71+-
Adocs/portal-platform-support.md65+
Mdocs/portal-release.md7+
Mdocs/public-alpha-uat.md4+-
Mdocs/public-release-delivery.md25+-
Mportal/control-runtime.test.mjs23+
Mportal/enrollment-ui.test.mjs7+
Mportal/index.html1+
Mportal/input-runtime.test.mjs4+-
Mportal/input-state.test.mjs15+
Mportal/keyboard-map.mjs124++-
Mportal/keyboard-map.test.mjs127++-
Mportal/main.js36+-
Mportal/stream-runtime.test.mjs27+
Mportal/video-capability.mjs10+-
Mportal/video-capability.test.mjs34+-
Mportal/video-pipeline.mjs134+++-
Mportal/video-pipeline.test.mjs195++++-
Arelease/sigil-target-contract.txt1+
Mscripts/install-bazzite-package.sh6+-
Mscripts/package-bazzite-release.sh15+-
Mscripts/package-macos-client.sh6+-
Mscripts/public-alpha-uat.sh6+-
Ascripts/run-linux-cross-build-gate.sh58+
Ascripts/run-shell-tests.sh61+
Mscripts/sign-bazzite-release.sh2+-
Mscripts/tests/bazzite-package-install-e2e.sh2+-
Ascripts/tests/ci-cross-build-policy.sh11+
Ascripts/tests/linux-cross-build-gate.sh95++
Mscripts/tests/package-bazzite-product-contract.sh5+-
Mscripts/tests/package-bazzite-provenance.sh16+-
Mscripts/tests/public-alpha-uat.sh6+-
Ascripts/tests/shell-test-discovery.sh99++
Ascripts/tests/sigil-release-target-contract.sh72++
Mscripts/tests/sigil-release-verifier.sh16+-
Ascripts/tests/test_ci_cross_build_policy.py398+++++++++
Mscripts/tests/test_portal_release.py16+-
Mscripts/tests/test_sigil_bootstrap.py7+-
Mscripts/verify-demo-build.sh59+-
Mscripts/verify-portal-release.py13+-
Mscripts/verify-sigil-bootstrap.py6+
Mscripts/verify-sigil-release.sh19+-
Ascripts/verify_ci_cross_build_policy.py509+++++++++++
Msrc-tauri/Cargo.toml3+
Msrc-tauri/src/commands/state.rs146+++-
Msrc-tauri/src/main.rs1+
Msrc-tauri/src/media/input_delivery.rs191+++-
Asrc-tauri/src/platform_capabilities.rs37+
Mwebsite/install-sigil3+-
M .github/workflows/ci.yml +35 -0
@@ -82,9 +82,44 @@ jobs:
8282 libwebkit2gtk-4.1-dev \8383 libxdo-dev \8484 pkg-config \85+ python3-venv \8586 shellcheck8687 88+ - name: Restore pinned cargo-zigbuild89+ uses: actions/cache@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.490+ with:91+ path: ~/.cargo/bin/cargo-zigbuild92+ key: cargo-zigbuild-${{ runner.os }}-${{ runner.arch }}-0.23.093+94+ - name: Install pinned cross-build tools95+ run: |96+ python3 -m venv "$RUNNER_TEMP/zig-venv"97+ printf '%s\n' \98+ 'ziglang==0.16.0 --hash=sha256:9fcda73f62b851dd72a54b710ad40a209896db14cfb13649e62191243556342b' \99+ | "$RUNNER_TEMP/zig-venv/bin/pip" install \100+ --disable-pip-version-check \101+ --only-binary=:all: \102+ --require-hashes \103+ --requirement /dev/stdin104+ install -d "$RUNNER_TEMP/ci-bin"105+ # Expand runner paths when the generated wrapper executes.106+ # shellcheck disable=SC2016107+ printf '%s\n' \108+ '#!/usr/bin/env bash' \109+ 'exec "$RUNNER_TEMP/zig-venv/bin/python" -m ziglang "$@"' \110+ >"$RUNNER_TEMP/ci-bin/zig"111+ chmod 0755 "$RUNNER_TEMP/ci-bin/zig"112+ echo "$RUNNER_TEMP/ci-bin" >>"$GITHUB_PATH"113+ export PATH="$RUNNER_TEMP/ci-bin:$PATH"114+ if ! cargo-zigbuild --version 2>/dev/null \115+ | grep -Fxq 'cargo-zigbuild 0.23.0'; then116+ cargo install cargo-zigbuild --locked --version 0.23.0117+ fi118+ test "$(zig version)" = 0.16.0119+ test "$(cargo-zigbuild --version)" = 'cargo-zigbuild 0.23.0'120+87121 - name: Run complete demo gate88122 env:89123 GOQ_VERIFY_IN_PROCESS_GSTREAMER: "1"124+ GOQ_REQUIRE_LINUX_CROSS_BUILD: "1"90125 run: ./scripts/verify-demo-build.sh
M .github/workflows/portal-release.yml +6 -2
M .github/workflows/sigil-release.yml +7 -3
@@ -94,7 +94,9 @@ jobs:
9494 id: candidate9595 run: |9696 source_commit="$(git rev-parse HEAD)"97- asset_name="sigil-$RELEASE_TAG-bazzite-x86_64.tar.gz"97+ target_contract="$(<release/sigil-target-contract.txt)"98+ test "$target_contract" = linux-glibc2.17-x86_6499+ asset_name="sigil-$RELEASE_TAG-$target_contract.tar.gz"98100 output_dir="$RUNNER_TEMP/sigil-release"99101 install -d -m 0700 "$output_dir"100102 archive="$output_dir/$asset_name"
@@ -174,7 +176,9 @@ jobs:
174176 release_json="$(gh release view "$RELEASE_TAG" --json isDraft,tagName,assets)"175177 test "$(jq -r '.isDraft' <<<"$release_json")" = true176178 test "$(jq -r '.tagName' <<<"$release_json")" = "$RELEASE_TAG"177- asset_name="sigil-$RELEASE_TAG-bazzite-x86_64.tar.gz"179+ target_contract="$(<release/sigil-target-contract.txt)"180+ test "$target_contract" = linux-glibc2.17-x86_64181+ asset_name="sigil-$RELEASE_TAG-$target_contract.tar.gz"178182 version="${RELEASE_TAG#v}"179183 expected="$(printf '%s\n' \180184 "$asset_name" \
@@ -233,6 +237,6 @@ jobs:
233237 gh release edit "$RELEASE_TAG" \234238 --draft=false \235239 --prerelease \236- --notes "Verified public alpha for $RELEASE_TAG: signed Bazzite Sigil host package plus Developer ID signed, notarized, stapled macOS arm64 Portal. Hardware UAT remains tracked separately."240+ --notes "Verified public alpha for $RELEASE_TAG: signed Linux glibc 2.17 x86_64 Sigil host package, validated on the supported Bazzite appliance, plus Developer ID signed, notarized, stapled macOS arm64 Portal. Hardware UAT remains tracked separately."237241 test "$(gh release view "$RELEASE_TAG" --json isDraft --jq '.isDraft')" = false238242 test "$(gh release view "$RELEASE_TAG" --json isPrerelease --jq '.isPrerelease')" = true
M AGENTS.md +16 -0
@@ -75,6 +75,10 @@ Sigil host.
7575 coverage. Hardware acceptance that remains incomplete is listed below.7676 - Portal's window scales to the incoming stream while preserving aspect ratio;7777 larger client screens do not stretch the host image.78+- Portal's proven relative-pointer capture path is macOS-only. Non-macOS79+ builds must mask relative-pointer negotiation unless explicitly built with80+ `experimental-non-macos-pointer-capture`; that feature is for platform UAT81+ only and must not enter an ordinary or published build.7882 7983 ## Security boundary8084
@@ -105,6 +109,11 @@ Sigil host.
105109 the allowlisted runtime payload, carry complete checksums/provenance, and have106110 a detached Minisign signature. Installation must preserve identity and host107111 configuration and must not silently start, restart, or enable the service.112+- Product Sigil assets use the target-contract suffix113+ `linux-glibc2.17-x86_64`, pinned by114+ `release/sigil-target-contract.txt`. That name describes the binary ABI, not115+ validated host support; the public bootstrap remains Bazzite-only until116+ another environment passes its own packaging and hardware gates.108117 - Portal is a compiled desktop download, never a shell install. The first109118 public target is macOS arm64 and requires Developer ID signing, hardened110119 runtime, notarization, stapling, and strict Gatekeeper verification. Its
@@ -128,6 +137,9 @@ Sigil host.
128137 measured end to end. Do not select codecs by compression ratio alone.129138 - Preserve native pointer coordinates independently from encoded resolution or130139 client window size.140+- Map a differently shaped focused Xwayland root through a centered aspect-fit141+ region in the native pointer surface; never stretch pointer axes142+ independently.131143 - Session teardown must release all held input transitions and emit a neutral132144 gamepad state even after an error or disconnect.133145
@@ -214,6 +226,10 @@ Sigil host.
214226 credential material.215227 216228 - On Linux with NVIDIA, set `WEBKIT_DISABLE_DMABUF_RENDERER=1` for Portal.229+- Treat `experimental-non-macos-pointer-capture` as an explicit non-release230+ build mode. Complete the per-platform checklist in231+ `docs/portal-platform-support.md`, including a real Portal -> UMPC session,232+ before changing a platform's support status or its default feature policy.217233 - Preserve `/Users/dami/Developer/sigil` untouched; it is the inherited source,218234 not this working repository.219235 - Preserve unrelated worktree changes. Never sweep generated credentials,
M README.md +7 -1
M crates/sigil-host/Cargo.toml +1 -0
@@ -56,3 +56,4 @@ x11rb = { version = "0.13.2", default-features = false }
5656 5757 [dev-dependencies]5858 tempfile = "3"59+tokio = { version = "1", features = ["test-util"] }
M crates/sigil-host/src/config.rs +4 -2
M crates/sigil-host/src/cursor.rs +281 -64
@@ -162,7 +162,7 @@ impl PointerPositionTracker {
162162 .context("neither gamescope_pipewire.xwayland_display nor DISPLAY is configured")?,163163 };164164 validate_display_name(&bootstrap_display)?;165- let bootstrap = connect_bootstrap_display(&bootstrap_display)?;165+ let mut bootstrap = connect_bootstrap_display(&bootstrap_display)?;166166 anyhow::ensure!(167167 bootstrap.pointer_surface_dimensions == pointer_surface_dimensions,168168 "bootstrap Gamescope pointer surface is {}x{}, but capture preflight resolved {}x{}",
@@ -171,10 +171,10 @@ impl PointerPositionTracker {
171171 pointer_surface_dimensions.width,172172 pointer_surface_dimensions.height173173 );174- let focus_display =175- query_focus_display(&bootstrap.connection, bootstrap.root, bootstrap.focus_atom)176- .context("querying initial Gamescope mouse-focus display")?;177- let active = connect_active_display(&focus_display)174+ let focus_display = bootstrap175+ .query_focus_display()176+ .context("querying initial Gamescope mouse-focus display")?;177+ let mut active = connect_active_display(&focus_display)178178 .context("connecting to active Gamescope Xwayland")?;179179 let initial_position = query_pointer_position(180180 &active.connection,
@@ -182,7 +182,8 @@ impl PointerPositionTracker {
182182 bootstrap.pointer_surface_dimensions,183183 )184184 .context("querying initial Gamescope Xwayland pointer position")?;185- let initial = available_pointer_state(initial_position, query_cursor_visible(&active).ok());185+ let initial =186+ available_pointer_state(initial_position, active.query_cursor_visible_bounded());186187 let (latest, _) = tokio::sync::watch::channel(initial);187188 let stop = Arc::new(AtomicBool::new(false));188189 let thread_latest = latest.clone();
@@ -203,24 +204,20 @@ impl PointerPositionTracker {
203204 continue;204205 }205206 match connect_bootstrap_display(&bootstrap_display) {206- Ok(reconnected) => {207- let focus_display = query_focus_display(208- &reconnected.connection,209- reconnected.root,210- reconnected.focus_atom,211- );207+ Ok(mut reconnected) => {208+ let focus_display = reconnected.query_focus_display();212209 let recovered = focus_display.and_then(|focus_display| {213- let active = connect_active_display(&focus_display)?;210+ let mut active = connect_active_display(&focus_display)?;214211 let position = query_pointer_position(215212 &active.connection,216213 active.root,217214 reconnected.pointer_surface_dimensions,218215 )?;219- Ok((focus_display, active, position))216+ let visible = active.query_cursor_visible_bounded();217+ Ok((focus_display, active, position, visible))220218 });221219 match recovered {222- Ok((focus_display, active, position)) => {223- let visible = query_cursor_visible(&active).ok();220+ Ok((focus_display, active, position, visible)) => {224221 let pointer_surface_dimensions =225222 reconnected.pointer_surface_dimensions;226223 bootstrap = Some(reconnected);
@@ -267,13 +264,9 @@ impl PointerPositionTracker {
267264 continue;268265 }269266 let bootstrap_connection = bootstrap270- .as_ref()267+ .as_mut()271268 .expect("bootstrap connection was checked above");272- let focus_display = match query_focus_display(273- &bootstrap_connection.connection,274- bootstrap_connection.root,275- bootstrap_connection.focus_atom,276- ) {269+ let focus_display = match bootstrap_connection.query_focus_display() {277270 Ok(display) => display,278271 Err(error) => {279272 tracing::warn!(
@@ -294,7 +287,7 @@ impl PointerPositionTracker {
294287 active_connection = connect_active_display(&active_display).ok();295288 activity_visibility.reset();296289 }297- let Some(active) = active_connection.as_ref() else {290+ let Some(active) = active_connection.as_mut() else {298291 publish_unavailable(&thread_latest);299292 continue;300293 };
@@ -306,7 +299,7 @@ impl PointerPositionTracker {
306299 Ok(position) => {307300 let visible = activity_visibility.resolve(308301 position,309- query_cursor_visible(active).ok(),302+ active.query_cursor_visible_bounded(),310303 Instant::now(),311304 );312305 let state = available_pointer_state(position, Some(visible));
@@ -367,9 +360,11 @@ fn connect_display(display: &str) -> Result<(x11rb::rust_connection::RustConnect
367360 368361 #[cfg(target_os = "linux")]369362 struct BootstrapXwayland {363+ display: String,370364 connection: x11rb::rust_connection::RustConnection,371365 root: u32,372- focus_atom: u32,366+ focus_atom: Option<u32>,367+ focus_fallback_reported: bool,373368 pointer_surface_dimensions: PointerSurfaceDimensions,374369 }375370
@@ -380,7 +375,10 @@ fn connect_bootstrap_display(display: &str) -> Result<BootstrapXwayland> {
380375 let (connection, root) =381376 connect_display(display).context("connecting to bootstrap Gamescope Xwayland")?;382377 let focus_atom = connection383- .intern_atom(false, FOCUS_DISPLAY_PROPERTY)378+ // SteamOS and older Gamescope builds may not publish this Bazzite-era379+ // extension. Do not create the atom and mistake its existence for380+ // compositor support.381+ .intern_atom(true, FOCUS_DISPLAY_PROPERTY)384382 .context("interning Gamescope mouse-focus property")?385383 .reply()386384 .context("receiving Gamescope mouse-focus atom")?
@@ -393,13 +391,54 @@ fn connect_bootstrap_display(display: &str) -> Result<BootstrapXwayland> {
393391 let pointer_surface_dimensions = PointerSurfaceDimensions::new(geometry.width, geometry.height)394392 .context("validating bootstrap Gamescope pointer surface dimensions")?;395393 Ok(BootstrapXwayland {394+ display: display.to_owned(),396395 connection,397396 root,398- focus_atom,397+ focus_atom: (focus_atom != x11rb::NONE).then_some(focus_atom),398+ focus_fallback_reported: false,399399 pointer_surface_dimensions,400400 })401401 }402402 403+#[cfg(target_os = "linux")]404+impl BootstrapXwayland {405+ fn query_focus_display(&mut self) -> Result<String> {406+ let discovered = match self.focus_atom {407+ Some(focus_atom) => query_focus_display(&self.connection, self.root, focus_atom)?,408+ None => None,409+ };410+ let (focused_display, used_fallback) = select_focus_display(&self.display, discovered);411+ if !used_fallback {412+ if self.focus_fallback_reported {413+ tracing::info!(414+ display = %self.display,415+ focus_display = %focused_display,416+ "Gamescope mouse-focus discovery recovered"417+ );418+ }419+ self.focus_fallback_reported = false;420+ } else if !self.focus_fallback_reported {421+ tracing::warn!(422+ display = %self.display,423+ "Gamescope mouse-focus property is unavailable; pointer feedback is bounded to the configured Xwayland display"424+ );425+ self.focus_fallback_reported = true;426+ }427+ Ok(focused_display)428+ }429+}430+431+#[cfg(any(target_os = "linux", test))]432+fn select_focus_display(433+ configured_display: &str,434+ discovered_display: Option<String>,435+) -> (String, bool) {436+ match discovered_display {437+ Some(discovered_display) => (discovered_display, false),438+ None => (configured_display.to_owned(), true),439+ }440+}441+403442 #[cfg(target_os = "linux")]404443 fn publish_unavailable(latest: &tokio::sync::watch::Sender<PointerState>) {405444 latest.send_if_modified(|latest| {
@@ -414,9 +453,11 @@ fn publish_unavailable(latest: &tokio::sync::watch::Sender<PointerState>) {
414453 415454 #[cfg(target_os = "linux")]416455 struct ActiveXwayland {456+ display: String,417457 connection: x11rb::rust_connection::RustConnection,418458 root: u32,419- cursor_visible_atom: u32,459+ cursor_visible_atom: Option<u32>,460+ visibility_fallback_reported: bool,420461 }421462 422463 #[cfg(target_os = "linux")]
@@ -425,32 +466,73 @@ fn connect_active_display(display: &str) -> Result<ActiveXwayland> {
425466 426467 let (connection, root) = connect_display(display)?;427468 let cursor_visible_atom = connection428- .intern_atom(false, CURSOR_VISIBLE_PROPERTY)469+ .intern_atom(true, CURSOR_VISIBLE_PROPERTY)429470 .context("interning Gamescope cursor-visibility property")?430471 .reply()431472 .context("receiving Gamescope cursor-visibility atom")?432473 .atom;433474 Ok(ActiveXwayland {475+ display: display.to_owned(),434476 connection,435477 root,436- cursor_visible_atom,478+ cursor_visible_atom: (cursor_visible_atom != x11rb::NONE).then_some(cursor_visible_atom),479+ visibility_fallback_reported: false,437480 })438481 }439482 440483 #[cfg(target_os = "linux")]441-fn query_cursor_visible(active: &ActiveXwayland) -> Result<bool> {484+impl ActiveXwayland {485+ fn query_cursor_visible_bounded(&mut self) -> Option<bool> {486+ match self487+ .cursor_visible_atom488+ .map(|atom| query_cursor_visible(&self.connection, self.root, atom))489+ .transpose()490+ {491+ Ok(Some(visible)) => {492+ if self.visibility_fallback_reported {493+ tracing::info!(494+ display = %self.display,495+ "Gamescope cursor-visibility feedback recovered"496+ );497+ }498+ self.visibility_fallback_reported = false;499+ Some(visible)500+ }501+ Ok(None) => {502+ if !self.visibility_fallback_reported {503+ tracing::warn!(504+ display = %self.display,505+ "Gamescope cursor-visibility property is unavailable; visibility will follow bounded pointer activity"506+ );507+ self.visibility_fallback_reported = true;508+ }509+ None510+ }511+ Err(error) => {512+ if !self.visibility_fallback_reported {513+ tracing::warn!(514+ display = %self.display,515+ %error,516+ "Gamescope cursor-visibility property is invalid; visibility will follow bounded pointer activity"517+ );518+ self.visibility_fallback_reported = true;519+ }520+ None521+ }522+ }523+ }524+}525+526+#[cfg(target_os = "linux")]527+fn query_cursor_visible(528+ connection: &x11rb::rust_connection::RustConnection,529+ root: u32,530+ cursor_visible_atom: u32,531+) -> Result<bool> {442532 use x11rb::protocol::xproto::{AtomEnum, ConnectionExt};443533 444- let reply = active445- .connection446- .get_property(447- false,448- active.root,449- active.cursor_visible_atom,450- AtomEnum::CARDINAL,451- 0,452- 1,453- )534+ let reply = connection535+ .get_property(false, root, cursor_visible_atom, AtomEnum::CARDINAL, 0, 1)454536 .context("sending Gamescope cursor-visibility property query")?455537 .reply()456538 .context("receiving Gamescope cursor-visibility property")?;
@@ -485,7 +567,7 @@ fn query_focus_display(
485567 connection: &x11rb::rust_connection::RustConnection,486568 root: u32,487569 focus_atom: u32,488-) -> Result<String> {570+) -> Result<Option<String>> {489571 use x11rb::protocol::xproto::{AtomEnum, ConnectionExt};490572 491573 let reply = connection
@@ -493,11 +575,19 @@ fn query_focus_display(
493575 .context("sending Gamescope mouse-focus property query")?494576 .reply()495577 .context("receiving Gamescope mouse-focus property")?;578+ parse_optional_focus_display(reply.format, &reply.value)579+}580+581+#[cfg(any(target_os = "linux", test))]582+fn parse_optional_focus_display(format: u8, value: &[u8]) -> Result<Option<String>> {583+ if format == 0 && value.is_empty() {584+ return Ok(None);585+ }496586 anyhow::ensure!(497- reply.format == 32,587+ format == 32,498588 "Gamescope mouse-focus property is not CARDINAL/32"499589 );500- parse_focus_display(&reply.value)590+ parse_focus_display(value).map(Some)501591 }502592 503593 #[cfg(any(target_os = "linux", test))]
@@ -535,6 +625,10 @@ fn query_pointer_position(
535625 .context("sending X11 QueryPointer")?536626 .reply()537627 .context("receiving X11 QueryPointer reply")?;628+ anyhow::ensure!(629+ reply.same_screen && reply.root == root,630+ "Xwayland pointer is not on the queried active root"631+ );538632 map_pointer_position(539633 geometry.width,540634 geometry.height,
@@ -546,11 +640,20 @@ fn query_pointer_position(
546640 }547641 548642 #[cfg(any(target_os = "linux", test))]549-fn validate_root_geometry(643+#[derive(Clone, Copy, Debug, PartialEq, Eq)]644+struct PointerSurfaceRegion {645+ offset_x: u16,646+ offset_y: u16,647+ width: u16,648+ height: u16,649+}650+651+#[cfg(any(target_os = "linux", test))]652+fn pointer_surface_region(550653 width: u16,551654 height: u16,552655 pointer_surface_dimensions: PointerSurfaceDimensions,553-) -> Result<()> {656+) -> Result<PointerSurfaceRegion> {554657 pointer_surface_dimensions555658 .validate()556659 .context("validating captured pointer surface dimensions")?;
@@ -559,19 +662,46 @@ fn validate_root_geometry(
559662 "active Xwayland root dimensions must be non-zero"560663 );561664 562- // Widen before cross multiplication so every pair of u16 dimensions is563- // overflow-safe. Exact aspect equality prevents a focus switch from564- // silently stretching pointer coordinates into a differently shaped565- // captured surface.566- let root_aspect = u64::from(width) * u64::from(pointer_surface_dimensions.height);567- let surface_aspect = u64::from(height) * u64::from(pointer_surface_dimensions.width);665+ // Gamescope centers a focused game surface while preserving its aspect666+ // ratio. Reproduce that transform in the compositor's native pointer667+ // coordinate space: independent axis stretching would make the overlay668+ // disagree with the game whenever the active Xwayland root is letterboxed.669+ let root_width = u64::from(width);670+ let root_height = u64::from(height);671+ let surface_width = u64::from(pointer_surface_dimensions.width);672+ let surface_height = u64::from(pointer_surface_dimensions.height);673+ let root_is_wider = root_width * surface_height >= root_height * surface_width;674+ let (fitted_width, fitted_height) = if root_is_wider {675+ (676+ surface_width,677+ root_height678+ .checked_mul(surface_width)679+ .context("pointer surface height scaling overflowed")?680+ / root_width,681+ )682+ } else {683+ (684+ root_width685+ .checked_mul(surface_height)686+ .context("pointer surface width scaling overflowed")?687+ / root_height,688+ surface_height,689+ )690+ };568691 anyhow::ensure!(569- root_aspect == surface_aspect,570- "active Xwayland root is {width}x{height}, but the captured pointer surface {}x{} has a different aspect ratio",571- pointer_surface_dimensions.width,572- pointer_surface_dimensions.height692+ fitted_width > 0 && fitted_height > 0,693+ "active Xwayland root aspect cannot be represented on the captured pointer surface"573694 );574- Ok(())695+ let fitted_width =696+ u16::try_from(fitted_width).context("fitted pointer surface width exceeds u16")?;697+ let fitted_height =698+ u16::try_from(fitted_height).context("fitted pointer surface height exceeds u16")?;699+ Ok(PointerSurfaceRegion {700+ offset_x: (pointer_surface_dimensions.width - fitted_width) / 2,701+ offset_y: (pointer_surface_dimensions.height - fitted_height) / 2,702+ width: fitted_width,703+ height: fitted_height,704+ })575705 }576706 577707 #[cfg(any(target_os = "linux", test))]
@@ -582,9 +712,9 @@ fn map_pointer_position(
582712 root_y: i32,583713 pointer_surface_dimensions: PointerSurfaceDimensions,584714 ) -> Result<PointerPosition> {585- validate_root_geometry(root_width, root_height, pointer_surface_dimensions)?;586- let x = map_pointer_axis(root_x, root_width, pointer_surface_dimensions.width, "x")?;587- let y = map_pointer_axis(root_y, root_height, pointer_surface_dimensions.height, "y")?;715+ let region = pointer_surface_region(root_width, root_height, pointer_surface_dimensions)?;716+ let x = map_pointer_axis(root_x, root_width, region.offset_x, region.width, "x")?;717+ let y = map_pointer_axis(root_y, root_height, region.offset_y, region.height, "y")?;588718 PointerPosition::new(x, y).context("scaled pointer position is outside the protocol range")589719 }590720
@@ -592,6 +722,7 @@ fn map_pointer_position(
592722 fn map_pointer_axis(593723 coordinate: i32,594724 root_extent: u16,725+ surface_offset: u16,595726 surface_extent: u16,596727 axis: &str,597728 ) -> Result<i32> {
@@ -603,12 +734,15 @@ fn map_pointer_axis(
603734 coordinate < root_extent,604735 "Xwayland {axis} pointer coordinate is outside the active root"605736 );606- let scaled = coordinate737+ let scaled_within_region = coordinate607738 .checked_mul(surface_extent)608739 .context("pointer coordinate scaling overflowed")?609740 / root_extent;741+ let scaled = u64::from(surface_offset)742+ .checked_add(scaled_within_region)743+ .context("pointer surface offset overflowed")?;610744 anyhow::ensure!(611- scaled < surface_extent,745+ scaled_within_region < surface_extent,612746 "scaled {axis} pointer coordinate is outside the captured surface"613747 );614748 i32::try_from(scaled).context("scaled pointer coordinate exceeds the protocol integer range")
@@ -626,6 +760,13 @@ mod tests {
626760 627761 #[test]628762 fn parses_gamescope_cardinal_string_abi_fail_closed() {763+ assert_eq!(parse_optional_focus_display(0, &[]).unwrap(), None);764+ assert_eq!(765+ parse_optional_focus_display(32, &[b':', b'0', 0, 0]).unwrap(),766+ Some(":0".to_owned())767+ );768+ assert!(parse_optional_focus_display(8, b":0").is_err());769+ assert!(parse_optional_focus_display(0, b":0").is_err());629770 assert_eq!(parse_focus_display(&[b':', b'0', 0, 0]).unwrap(), ":0");630771 assert_eq!(631772 parse_focus_display(&[b':', b'1', 0, 0, 78, 0, 0, 0]).unwrap(),
@@ -636,11 +777,28 @@ mod tests {
636777 assert!(parse_focus_display(b":0").is_err());637778 }638779 780+ #[test]781+ fn missing_gamescope_focus_extension_is_bounded_to_configured_display() {782+ assert_eq!(select_focus_display(":0", None), (":0".to_owned(), true));783+ assert_eq!(784+ select_focus_display(":0", Some(":1".to_owned())),785+ (":1".to_owned(), false)786+ );787+ }788+639789 #[test]640790 fn xwayland_pointer_mapping_is_identity_for_equal_surfaces() {641791 let expected = PointerSurfaceDimensions::new(2_560, 1_600).unwrap();642792 643- validate_root_geometry(2_560, 1_600, expected).unwrap();793+ assert_eq!(794+ pointer_surface_region(2_560, 1_600, expected).unwrap(),795+ PointerSurfaceRegion {796+ offset_x: 0,797+ offset_y: 0,798+ width: 2_560,799+ height: 1_600,800+ }801+ );644802 assert_eq!(645803 map_pointer_position(2_560, 1_600, 212, 140, expected).unwrap(),646804 PointerPosition::new(212, 140).unwrap()
@@ -671,12 +829,71 @@ mod tests {
671829 assert_eq!(logical, native_root);672830 }673831 832+ #[test]833+ fn wide_xwayland_root_maps_into_centered_letterbox_region() {834+ let native = PointerSurfaceDimensions::new(2_560, 1_600).unwrap();835+836+ assert_eq!(837+ pointer_surface_region(1_280, 720, native).unwrap(),838+ PointerSurfaceRegion {839+ offset_x: 0,840+ offset_y: 80,841+ width: 2_560,842+ height: 1_440,843+ }844+ );845+ assert_eq!(846+ map_pointer_position(1_280, 720, 0, 0, native).unwrap(),847+ PointerPosition::new(0, 80).unwrap()848+ );849+ assert_eq!(850+ map_pointer_position(1_280, 720, 640, 360, native).unwrap(),851+ PointerPosition::new(1_280, 800).unwrap()852+ );853+ assert_eq!(854+ map_pointer_position(1_280, 720, 1_279, 719, native).unwrap(),855+ PointerPosition::new(2_558, 1_518).unwrap()856+ );857+ }858+859+ #[test]860+ fn narrow_xwayland_root_maps_into_centered_pillarbox_region() {861+ let native = PointerSurfaceDimensions::new(2_560, 1_600).unwrap();862+863+ assert_eq!(864+ pointer_surface_region(1_024, 768, native).unwrap(),865+ PointerSurfaceRegion {866+ offset_x: 213,867+ offset_y: 0,868+ width: 2_133,869+ height: 1_600,870+ }871+ );872+ assert_eq!(873+ map_pointer_position(1_024, 768, 0, 0, native).unwrap(),874+ PointerPosition::new(213, 0).unwrap()875+ );876+ assert_eq!(877+ map_pointer_position(1_024, 768, 512, 384, native).unwrap(),878+ PointerPosition::new(1_279, 800).unwrap()879+ );880+ }881+674882 #[test]675883 fn pointer_mapping_rejects_invalid_geometry_and_coordinates() {676884 let native = PointerSurfaceDimensions::new(2_560, 1_600).unwrap();677885 678886 assert!(map_pointer_position(0, 800, 0, 0, native).is_err());679- assert!(map_pointer_position(1_280, 720, 0, 0, native).is_err());887+ assert!(888+ map_pointer_position(889+ u16::MAX,890+ 1,891+ 0,892+ 0,893+ PointerSurfaceDimensions::new(64, 64).unwrap(),894+ )895+ .is_err()896+ );680897 assert!(map_pointer_position(1_280, 800, -1, 0, native).is_err());681898 assert!(map_pointer_position(1_280, 800, 1_280, 0, native).is_err());682899 assert!(map_pointer_position(1_280, 800, 0, 800, native).is_err());
M crates/sigil-host/src/input.rs +199 -2
@@ -403,15 +403,22 @@ enum MappedKey {
403403 Comma,404404 Dot,405405 Slash,406+ NonUsBackslash,407+ Ro,408+ Yen,406409 Enter,407410 Tab,408411 Space,409412 Backspace,410413 Escape,411414 LeftShift,415+ RightShift,412416 LeftCtrl,417+ RightCtrl,413418 LeftAlt,419+ RightAlt,414420 LeftMeta,421+ RightMeta,415422 Up,416423 Down,417424 Left,
@@ -420,10 +427,24 @@ enum MappedKey {
420427 End,421428 PageUp,422429 PageDown,430+ Insert,423431 Delete,432+ PrintScreen,433+ F1,434+ F2,435+ F3,436+ F4,437+ F5,438+ F6,439+ F7,440+ F8,441+ F9,442+ F10,443+ F11,444+ F12,424445 }425446 426-#[cfg(target_os = "linux")]447+#[cfg(any(target_os = "linux", test))]427448 const ALL_MAPPED_KEYS: &[MappedKey] = &[428449 MappedKey::A,429450 MappedKey::B,
@@ -472,15 +493,22 @@ const ALL_MAPPED_KEYS: &[MappedKey] = &[
472493 MappedKey::Comma,473494 MappedKey::Dot,474495 MappedKey::Slash,496+ MappedKey::NonUsBackslash,497+ MappedKey::Ro,498+ MappedKey::Yen,475499 MappedKey::Enter,476500 MappedKey::Tab,477501 MappedKey::Space,478502 MappedKey::Backspace,479503 MappedKey::Escape,480504 MappedKey::LeftShift,505+ MappedKey::RightShift,481506 MappedKey::LeftCtrl,507+ MappedKey::RightCtrl,482508 MappedKey::LeftAlt,509+ MappedKey::RightAlt,483510 MappedKey::LeftMeta,511+ MappedKey::RightMeta,484512 MappedKey::Up,485513 MappedKey::Down,486514 MappedKey::Left,
@@ -489,21 +517,97 @@ const ALL_MAPPED_KEYS: &[MappedKey] = &[
489517 MappedKey::End,490518 MappedKey::PageUp,491519 MappedKey::PageDown,520+ MappedKey::Insert,492521 MappedKey::Delete,522+ MappedKey::PrintScreen,523+ MappedKey::F1,524+ MappedKey::F2,525+ MappedKey::F3,526+ MappedKey::F4,527+ MappedKey::F5,528+ MappedKey::F6,529+ MappedKey::F7,530+ MappedKey::F8,531+ MappedKey::F9,532+ MappedKey::F10,533+ MappedKey::F11,534+ MappedKey::F12,493535 ];494536 495537 #[cfg(any(target_os = "linux", test))]496538 fn map_key(value: &str) -> Option<MappedKey> {497539 match value {540+ "KeyA" => Some(MappedKey::A),541+ "KeyB" => Some(MappedKey::B),542+ "KeyC" => Some(MappedKey::C),543+ "KeyD" => Some(MappedKey::D),544+ "KeyE" => Some(MappedKey::E),545+ "KeyF" => Some(MappedKey::F),546+ "KeyG" => Some(MappedKey::G),547+ "KeyH" => Some(MappedKey::H),548+ "KeyI" => Some(MappedKey::I),549+ "KeyJ" => Some(MappedKey::J),550+ "KeyK" => Some(MappedKey::K),551+ "KeyL" => Some(MappedKey::L),552+ "KeyM" => Some(MappedKey::M),553+ "KeyN" => Some(MappedKey::N),554+ "KeyO" => Some(MappedKey::O),555+ "KeyP" => Some(MappedKey::P),556+ "KeyQ" => Some(MappedKey::Q),557+ "KeyR" => Some(MappedKey::R),558+ "KeyS" => Some(MappedKey::S),559+ "KeyT" => Some(MappedKey::T),560+ "KeyU" => Some(MappedKey::U),561+ "KeyV" => Some(MappedKey::V),562+ "KeyW" => Some(MappedKey::W),563+ "KeyX" => Some(MappedKey::X),564+ "KeyY" => Some(MappedKey::Y),565+ "KeyZ" => Some(MappedKey::Z),566+ "Digit0" => Some(MappedKey::Num0),567+ "Digit1" => Some(MappedKey::Num1),568+ "Digit2" => Some(MappedKey::Num2),569+ "Digit3" => Some(MappedKey::Num3),570+ "Digit4" => Some(MappedKey::Num4),571+ "Digit5" => Some(MappedKey::Num5),572+ "Digit6" => Some(MappedKey::Num6),573+ "Digit7" => Some(MappedKey::Num7),574+ "Digit8" => Some(MappedKey::Num8),575+ "Digit9" => Some(MappedKey::Num9),576+ "Minus" => Some(MappedKey::Minus),577+ "Equal" => Some(MappedKey::Equal),578+ "BracketLeft" => Some(MappedKey::LeftBrace),579+ "BracketRight" => Some(MappedKey::RightBrace),580+ "Backslash" => Some(MappedKey::Backslash),581+ "IntlBackslash" => Some(MappedKey::NonUsBackslash),582+ "IntlRo" => Some(MappedKey::Ro),583+ "IntlYen" => Some(MappedKey::Yen),584+ "Semicolon" => Some(MappedKey::Semicolon),585+ "Quote" => Some(MappedKey::Apostrophe),586+ "Backquote" => Some(MappedKey::Grave),587+ "Comma" => Some(MappedKey::Comma),588+ "Period" => Some(MappedKey::Dot),589+ "Slash" => Some(MappedKey::Slash),498590 "Enter" => Some(MappedKey::Enter),499591 "Tab" => Some(MappedKey::Tab),500592 "Space" => Some(MappedKey::Space),501593 "Backspace" => Some(MappedKey::Backspace),502594 "Escape" => Some(MappedKey::Escape),595+ "ShiftLeft" => Some(MappedKey::LeftShift),596+ "ShiftRight" => Some(MappedKey::RightShift),597+ "ControlLeft" => Some(MappedKey::LeftCtrl),598+ "ControlRight" => Some(MappedKey::RightCtrl),599+ "AltLeft" => Some(MappedKey::LeftAlt),600+ "AltRight" => Some(MappedKey::RightAlt),601+ "MetaLeft" => Some(MappedKey::LeftMeta),602+ "MetaRight" => Some(MappedKey::RightMeta),503603 "Shift" => Some(MappedKey::LeftShift),504604 "Control" => Some(MappedKey::LeftCtrl),505605 "Alt" => Some(MappedKey::LeftAlt),506606 "Meta" => Some(MappedKey::LeftMeta),607+ "ArrowUp" => Some(MappedKey::Up),608+ "ArrowDown" => Some(MappedKey::Down),609+ "ArrowLeft" => Some(MappedKey::Left),610+ "ArrowRight" => Some(MappedKey::Right),507611 "Up" => Some(MappedKey::Up),508612 "Down" => Some(MappedKey::Down),509613 "Left" => Some(MappedKey::Left),
@@ -512,7 +616,21 @@ fn map_key(value: &str) -> Option<MappedKey> {
512616 "End" => Some(MappedKey::End),513617 "PageUp" => Some(MappedKey::PageUp),514618 "PageDown" => Some(MappedKey::PageDown),619+ "Insert" => Some(MappedKey::Insert),515620 "Delete" => Some(MappedKey::Delete),621+ "PrintScreen" => Some(MappedKey::PrintScreen),622+ "F1" => Some(MappedKey::F1),623+ "F2" => Some(MappedKey::F2),624+ "F3" => Some(MappedKey::F3),625+ "F4" => Some(MappedKey::F4),626+ "F5" => Some(MappedKey::F5),627+ "F6" => Some(MappedKey::F6),628+ "F7" => Some(MappedKey::F7),629+ "F8" => Some(MappedKey::F8),630+ "F9" => Some(MappedKey::F9),631+ "F10" => Some(MappedKey::F10),632+ "F11" => Some(MappedKey::F11),633+ "F12" => Some(MappedKey::F12),516634 _ => map_printable_us_key(value),517635 }518636 }
@@ -1346,7 +1464,7 @@ mod linux {
13461464 .into_raw()13471465 }13481466 1349- fn linux_key(key: MappedKey) -> Key {1467+ pub(super) fn linux_key(key: MappedKey) -> Key {13501468 match key {13511469 MappedKey::A => Key::A,13521470 MappedKey::B => Key::B,
@@ -1395,15 +1513,22 @@ mod linux {
13951513 MappedKey::Comma => Key::Comma,13961514 MappedKey::Dot => Key::Dot,13971515 MappedKey::Slash => Key::Slash,1516+ MappedKey::NonUsBackslash => Key::NonUsBackslashAndPipe,1517+ MappedKey::Ro => Key::Ro,1518+ MappedKey::Yen => Key::Yen,13981519 MappedKey::Enter => Key::Enter,13991520 MappedKey::Tab => Key::Tab,14001521 MappedKey::Space => Key::Space,14011522 MappedKey::Backspace => Key::Backspace,14021523 MappedKey::Escape => Key::Esc,14031524 MappedKey::LeftShift => Key::LeftShift,1525+ MappedKey::RightShift => Key::RightShift,14041526 MappedKey::LeftCtrl => Key::LeftCtrl,1527+ MappedKey::RightCtrl => Key::RightCtrl,14051528 MappedKey::LeftAlt => Key::LeftAlt,1529+ MappedKey::RightAlt => Key::RightAlt,14061530 MappedKey::LeftMeta => Key::LeftMeta,1531+ MappedKey::RightMeta => Key::RightMeta,14071532 MappedKey::Up => Key::Up,14081533 MappedKey::Down => Key::Down,14091534 MappedKey::Left => Key::Left,
@@ -1412,7 +1537,21 @@ mod linux {
14121537 MappedKey::End => Key::End,14131538 MappedKey::PageUp => Key::PageUp,14141539 MappedKey::PageDown => Key::PageDown,1540+ MappedKey::Insert => Key::Insert,14151541 MappedKey::Delete => Key::Delete,1542+ MappedKey::PrintScreen => Key::Sysrq,1543+ MappedKey::F1 => Key::F1,1544+ MappedKey::F2 => Key::F2,1545+ MappedKey::F3 => Key::F3,1546+ MappedKey::F4 => Key::F4,1547+ MappedKey::F5 => Key::F5,1548+ MappedKey::F6 => Key::F6,1549+ MappedKey::F7 => Key::F7,1550+ MappedKey::F8 => Key::F8,1551+ MappedKey::F9 => Key::F9,1552+ MappedKey::F10 => Key::F10,1553+ MappedKey::F11 => Key::F11,1554+ MappedKey::F12 => Key::F12,14161555 }14171556 }14181557 }
@@ -1612,9 +1751,67 @@ mod tests {
16121751 assert_eq!(map_key("Control"), Some(MappedKey::LeftCtrl));16131752 assert_eq!(map_key("PageDown"), Some(MappedKey::PageDown));16141753 assert_eq!(map_key("F13"), None);1754+ assert_eq!(map_key("CapsLock"), None);1755+ assert_eq!(map_key("Numpad1"), None);16151756 assert_eq!(map_key(""), None);16161757 }16171758 1759+ #[test]1760+ fn maps_bounded_browser_physical_keys_for_games_and_layouts() {1761+ for (value, expected) in [1762+ ("KeyQ", MappedKey::Q),1763+ ("Digit2", MappedKey::Num2),1764+ ("BracketLeft", MappedKey::LeftBrace),1765+ ("IntlBackslash", MappedKey::NonUsBackslash),1766+ ("IntlRo", MappedKey::Ro),1767+ ("IntlYen", MappedKey::Yen),1768+ ("ShiftLeft", MappedKey::LeftShift),1769+ ("ShiftRight", MappedKey::RightShift),1770+ ("ControlLeft", MappedKey::LeftCtrl),1771+ ("ControlRight", MappedKey::RightCtrl),1772+ ("AltLeft", MappedKey::LeftAlt),1773+ ("AltRight", MappedKey::RightAlt),1774+ ("MetaLeft", MappedKey::LeftMeta),1775+ ("MetaRight", MappedKey::RightMeta),1776+ ("ArrowUp", MappedKey::Up),1777+ ("Insert", MappedKey::Insert),1778+ ("PrintScreen", MappedKey::PrintScreen),1779+ ("F1", MappedKey::F1),1780+ ("F12", MappedKey::F12),1781+ ] {1782+ assert_eq!(map_key(value), Some(expected), "{value}");1783+ assert!(ALL_MAPPED_KEYS.contains(&expected), "{value}");1784+ }1785+ let unique = ALL_MAPPED_KEYS1786+ .iter()1787+ .copied()1788+ .collect::<std::collections::BTreeSet<_>>();1789+ assert_eq!(unique.len(), ALL_MAPPED_KEYS.len());1790+ }1791+1792+ #[cfg(target_os = "linux")]1793+ #[test]1794+ fn extended_physical_keys_translate_to_exact_linux_evdev_codes() {1795+ for (mapped, expected) in [1796+ (MappedKey::F1, input_linux::Key::F1),1797+ (MappedKey::F12, input_linux::Key::F12),1798+ (MappedKey::Insert, input_linux::Key::Insert),1799+ (MappedKey::PrintScreen, input_linux::Key::Sysrq),1800+ (MappedKey::RightShift, input_linux::Key::RightShift),1801+ (MappedKey::RightCtrl, input_linux::Key::RightCtrl),1802+ (MappedKey::RightAlt, input_linux::Key::RightAlt),1803+ (MappedKey::RightMeta, input_linux::Key::RightMeta),1804+ (1805+ MappedKey::NonUsBackslash,1806+ input_linux::Key::NonUsBackslashAndPipe,1807+ ),1808+ (MappedKey::Ro, input_linux::Key::Ro),1809+ (MappedKey::Yen, input_linux::Key::Yen),1810+ ] {1811+ assert_eq!(linux::linux_key(mapped), expected);1812+ }1813+ }1814+16181815 #[test]16191816 fn maps_us_printable_keys_to_physical_keys() {16201817 assert_eq!(map_key("a"), Some(MappedKey::A));
M crates/sigil-host/src/server/adaptive.rs +1092 -280
@@ -1,5 +1,6 @@
11 use super::session::{2- AdaptiveEncoderProposal, MediaV3TelemetrySnapshot, ResolutionEncoderProposal, SessionRegistry,2+ AdaptiveEncoderProposal, FeedbackLease, MediaV3TelemetrySnapshot, ResolutionEncoderProposal,3+ SessionRegistry,34 };45 use super::*;56
@@ -260,15 +261,6 @@ impl MotionResolutionPolicy {
260261 }261262 }262263 263- fn commit_observation_from(&mut self, candidate: &Self) {264- self.motion_active = candidate.motion_active;265- self.still_since = candidate.still_since;266- self.pressure_active = candidate.pressure_active;267- self.moderate_windows = candidate.moderate_windows;268- self.clean_windows = candidate.clean_windows;269- self.last_motion_sequence = candidate.last_motion_sequence;270- }271-272264 fn downshift(273265 &mut self,274266 observed_at: Instant,
@@ -825,15 +817,6 @@ impl ShadowBitrateController {
825817 trusted_host_pressure,826818 )827819 }828-829- fn commit_observation_from(&mut self, candidate: &Self) {830- self.next_decision_id = candidate.next_decision_id;831- self.last_report_id = candidate.last_report_id;832- self.last_report_received_at = candidate.last_report_received_at;833- self.previous_telemetry = candidate.previous_telemetry;834- self.baseline_rtt_micros = candidate.baseline_rtt_micros;835- self.last_sequence = candidate.last_sequence;836- }837820 }838821 839822 fn add_feedback_signal(
@@ -860,6 +843,265 @@ fn queue_ratio_at_least(depth: u8, capacity: u8, numerator: u16, denominator: u1
860843 >= u16::from(capacity).saturating_mul(numerator)861844 }862845 846+#[derive(Clone, Copy, Debug)]847+struct AdaptiveCommitPlan {848+ decision: AdaptiveBitrateDecisionV1,849+ telemetry: MediaV3TelemetrySnapshot,850+ resolution_decision: Option<MotionResolutionDecision>,851+ force_keyframe: bool,852+}853+854+#[derive(Debug)]855+struct AdaptiveCommitAcknowledgement {856+ plan: AdaptiveCommitPlan,857+ generation_ended: bool,858+ recovery_acknowledged: Option<bool>,859+ resolution_applied: Option<bool>,860+ applied_bitrate_kbps: Option<u32>,861+ applied_resolution: Option<VideoDimensions>,862+}863+864+impl AdaptiveCommitAcknowledgement {865+ fn immediate(plan: AdaptiveCommitPlan) -> Self {866+ Self {867+ plan,868+ generation_ended: false,869+ recovery_acknowledged: None,870+ resolution_applied: None,871+ applied_bitrate_kbps: None,872+ applied_resolution: None,873+ }874+ }875+}876+877+/// Bounded adaptive-control actuator.878+///879+/// Feedback evaluation remains synchronous and deterministic, while this880+/// coordinator owns the slower encoder acknowledgements. At most one commit881+/// task and one latest coalesced plan exist. A queued plan carries the latest882+/// desired bitrate and resolution even when its feedback decision is a hold,883+/// so a slow or failed predecessor cannot strand an uncommitted target.884+struct AdaptiveCommitCoordinator {885+ sessions: Arc<SessionRegistry>,886+ remote: EndpointId,887+ session_id: u64,888+ actuation_enabled: bool,889+ desired_bitrate_kbps: u32,890+ requested_bitrate_kbps: u32,891+ committed_bitrate_kbps: u32,892+ desired_resolution: Option<VideoDimensions>,893+ requested_resolution: Option<VideoDimensions>,894+ committed_resolution: Option<VideoDimensions>,895+ pending: bool,896+ queued: Option<AdaptiveCommitPlan>,897+ acknowledgements: tokio::task::JoinSet<AdaptiveCommitAcknowledgement>,898+}899+900+impl AdaptiveCommitCoordinator {901+ fn new(902+ sessions: Arc<SessionRegistry>,903+ remote: EndpointId,904+ session_id: u64,905+ actuation_enabled: bool,906+ committed_bitrate_kbps: u32,907+ committed_resolution: Option<VideoDimensions>,908+ ) -> Self {909+ Self {910+ sessions,911+ remote,912+ session_id,913+ actuation_enabled,914+ desired_bitrate_kbps: committed_bitrate_kbps,915+ requested_bitrate_kbps: committed_bitrate_kbps,916+ committed_bitrate_kbps,917+ desired_resolution: committed_resolution,918+ requested_resolution: committed_resolution,919+ committed_resolution,920+ pending: false,921+ queued: None,922+ acknowledgements: tokio::task::JoinSet::new(),923+ }924+ }925+926+ fn has_pending(&self) -> bool {927+ self.pending928+ }929+930+ fn bitrate_is_applied(&self, target_kbps: u32) -> bool {931+ self.actuation_enabled932+ && !self.pending933+ && target_kbps == self.requested_bitrate_kbps934+ && target_kbps == self.committed_bitrate_kbps935+ }936+937+ fn submit(&mut self, mut plan: AdaptiveCommitPlan) -> Option<AdaptiveCommitAcknowledgement> {938+ self.desired_bitrate_kbps = plan.decision.target_kbps;939+ if let Some(resolution) = plan.resolution_decision {940+ self.desired_resolution = Some(resolution.target);941+ }942+ if self.pending {943+ if let Some(queued) = self.queued {944+ // The latest decision replaces older diagnostics, but a945+ // recovery barrier requested by any coalesced desired update946+ // must survive until that desired state is actuated.947+ plan.force_keyframe |= queued.force_keyframe;948+ }949+ self.queued = Some(plan);950+ return None;951+ }952+ if let Some(queued) = self.queued.take() {953+ // A completion can deliberately defer this slot while an unread954+ // feedback generation is waiting. The newly evaluated plan is the955+ // actual latest state, but it must inherit any recovery barrier956+ // carried by the superseded slot.957+ plan.force_keyframe |= queued.force_keyframe;958+ }959+ self.start(plan)960+ }961+962+ fn start_queued(&mut self) -> Option<AdaptiveCommitAcknowledgement> {963+ let plan = self.queued.take()?;964+ self.start(plan)965+ }966+967+ fn start(&mut self, plan: AdaptiveCommitPlan) -> Option<AdaptiveCommitAcknowledgement> {968+ if !self.actuation_enabled {969+ return Some(AdaptiveCommitAcknowledgement::immediate(plan));970+ }971+972+ let desired_resolution = plan.resolution_decision.map(|change| change.target);973+ debug_assert_eq!(plan.decision.target_kbps, self.desired_bitrate_kbps);974+ if desired_resolution.is_some() {975+ debug_assert_eq!(desired_resolution, self.desired_resolution);976+ }977+978+ // A proposal mutates EncoderControl's persistent desired watch before979+ // its acknowledgement arrives. Reconcile against both the last980+ // requested and last committed values: after a timeout, returning to981+ // the old committed target must overwrite the still-pending stale982+ // request rather than allowing it to apply later.983+ let reconciles_resolution = desired_resolution.is_some()984+ && (desired_resolution != self.requested_resolution985+ || desired_resolution != self.committed_resolution);986+ let resolution_proposal = if reconciles_resolution {987+ let target = desired_resolution.expect("resolution target was checked as present");988+ match self989+ .sessions990+ .propose_resolution_update(self.remote, self.session_id, target)991+ {992+ Ok(Some(proposal)) => {993+ self.requested_resolution = Some(target);994+ Some(proposal)995+ }996+ Ok(None) => {997+ warn!(998+ remote = %self.remote,999+ session_id = self.session_id,1000+ "active adaptive session lost encoder control; retaining committed resolution"1001+ );1002+ None1003+ }1004+ Err(error) => {1005+ warn!(1006+ remote = %self.remote,1007+ session_id = self.session_id,1008+ %error,1009+ "adaptive resolution proposal failed; retaining committed resolution"1010+ );1011+ None1012+ }1013+ }1014+ } else {1015+ None1016+ };1017+1018+ let reconciles_bitrate = plan.decision.target_kbps != self.requested_bitrate_kbps1019+ || plan.decision.target_kbps != self.committed_bitrate_kbps;1020+ let force_keyframe = plan.force_keyframe && resolution_proposal.is_none();1021+ let bitrate_proposal = if reconciles_bitrate || force_keyframe {1022+ match self.sessions.propose_adaptive_encoder_update(1023+ self.remote,1024+ self.session_id,1025+ plan.decision.target_kbps,1026+ force_keyframe,1027+ ) {1028+ Ok(Some(proposal)) => {1029+ self.requested_bitrate_kbps = plan.decision.target_kbps;1030+ Some(proposal)1031+ }1032+ Ok(None) => {1033+ warn!(1034+ remote = %self.remote,1035+ session_id = self.session_id,1036+ "active adaptive session lost encoder control; retaining committed bitrate"1037+ );1038+ None1039+ }1040+ Err(error) => {1041+ warn!(1042+ remote = %self.remote,1043+ session_id = self.session_id,1044+ %error,1045+ "adaptive encoder proposal failed; retaining committed bitrate"1046+ );1047+ None1048+ }1049+ }1050+ } else {1051+ None1052+ };1053+1054+ if bitrate_proposal.is_none() && resolution_proposal.is_none() {1055+ return Some(AdaptiveCommitAcknowledgement::immediate(plan));1056+ }1057+1058+ self.pending = true;1059+ let sessions = Arc::clone(&self.sessions);1060+ let remote = self.remote;1061+ let session_id = self.session_id;1062+ self.acknowledgements.spawn(async move {1063+ run_adaptive_commit(1064+ sessions,1065+ remote,1066+ session_id,1067+ plan,1068+ bitrate_proposal,1069+ resolution_proposal,1070+ )1071+ .await1072+ });1073+ None1074+ }1075+1076+ fn complete(1077+ &mut self,1078+ result: Option<std::result::Result<AdaptiveCommitAcknowledgement, tokio::task::JoinError>>,1079+ ) -> Result<AdaptiveCommitAcknowledgement> {1080+ ensure!(1081+ self.pending,1082+ "adaptive commit completed without pending work"1083+ );1084+ self.pending = false;1085+ let acknowledgement = result1086+ .context("adaptive commit task ended without a result")?1087+ .context("adaptive commit task failed")?;1088+ if let Some(applied) = acknowledgement.applied_bitrate_kbps {1089+ self.committed_bitrate_kbps = applied;1090+ }1091+ if let Some(applied) = acknowledgement.applied_resolution {1092+ self.committed_resolution = Some(applied);1093+ }1094+ Ok(acknowledgement)1095+ }1096+1097+ async fn abort_and_drain(&mut self) {1098+ self.pending = false;1099+ self.queued = None;1100+ self.acknowledgements.abort_all();1101+ while self.acknowledgements.join_next().await.is_some() {}1102+ }1103+}1104+8631105 pub(super) async fn serve_media_feedback(8641106 connection: Connection,8651107 config: &HostConfig,
@@ -924,7 +1166,45 @@ pub(super) async fn serve_media_feedback(
9241166 9251167 let (feedback_sender, mut feedback_receiver) =9261168 tokio::sync::watch::channel(CumulativeFeedbackIngress::default());927- let mut reader_task = tokio::spawn(forward_media_feedback_reports(recv, feedback_sender));1169+ let reader_task = tokio::spawn(forward_media_feedback_reports(recv, feedback_sender));1170+ let connection_closed = async {1171+ let closed = connection.closed().await;1172+ debug!(%remote, ?closed, "media feedback connection closed");1173+ };1174+ let result = run_media_feedback_loop(1175+ &mut send,1176+ connection_closed,1177+ remote,1178+ sessions,1179+ &lease,1180+ encoder_actuation_available,1181+ ceiling_kbps,1182+ &mut feedback_receiver,1183+ reader_task,1184+ )1185+ .await;1186+ let _ = send.finish();1187+ drop(lease);1188+ info!(%remote, "media feedback client released");1189+ result1190+}1191+1192+#[allow(clippy::too_many_arguments)]1193+async fn run_media_feedback_loop<W, C>(1194+ send: &mut W,1195+ connection_closed: C,1196+ remote: EndpointId,1197+ sessions: &Arc<SessionRegistry>,1198+ lease: &FeedbackLease,1199+ encoder_actuation_available: bool,1200+ ceiling_kbps: u32,1201+ feedback_receiver: &mut tokio::sync::watch::Receiver<CumulativeFeedbackIngress>,1202+ mut reader_task: tokio::task::JoinHandle<Result<()>>,1203+) -> Result<()>1204+where1205+ W: tokio::io::AsyncWrite + Unpin,1206+ C: Future<Output = ()>,1207+{9281208 let mut reader_finished = false;9291209 let mut receiver_open = true;9301210 let mut controller = ShadowBitrateController::new(ceiling_kbps, lease.telemetry.snapshot());
@@ -938,267 +1218,317 @@ pub(super) async fn serve_media_feedback(
9381218 } else {9391219 None9401220 };1221+ let initial_resolution = resolution_controller1222+ .as_ref()1223+ .map(MotionResolutionPolicy::target);1224+ let mut commit_coordinator = AdaptiveCommitCoordinator::new(1225+ Arc::clone(sessions),1226+ remote,1227+ lease.session_id,1228+ encoder_actuation_available,1229+ ceiling_kbps,1230+ initial_resolution,1231+ );9411232 let mut feedback_cursor = FeedbackIngressCursor::default();942-943- let result: Result<()> = loop {944- let session_changed = sessions.session_changed.notified();945- tokio::pin!(session_changed);946- session_changed.as_mut().enable();947- if !sessions.is_active(remote, lease.session_id) {948- break Ok(());949- }950- tokio::select! {951- biased;952- reader_result = &mut reader_task, if !reader_finished => {953- reader_finished = true;954- match reader_result {955- Ok(Ok(())) => {956- debug!(%remote, "media feedback report stream closed");957- }958- Ok(Err(error)) => {959- break Err(error).context("reading media feedback reports");960- }961- Err(error) => {962- break Err(error).context("media feedback reader task failed");1233+ tokio::pin!(connection_closed);1234+1235+ // Keep cleanup on every error path: `?` below exits this async block, not1236+ // the outer function that owns the reader and actuator tasks.1237+ let result: Result<()> = async {1238+ loop {1239+ let session_changed = sessions.session_changed.notified();1240+ tokio::pin!(session_changed);1241+ session_changed.as_mut().enable();1242+ if !sessions.is_active(remote, lease.session_id) {1243+ break Ok(());1244+ }1245+ tokio::select! {1246+ biased;1247+ reader_result = &mut reader_task, if !reader_finished => {1248+ reader_finished = true;1249+ match reader_result {1250+ Ok(Ok(())) => {1251+ debug!(%remote, "media feedback report stream closed");1252+ }1253+ Ok(Err(error)) => {1254+ break Err(error).context("reading media feedback reports");1255+ }1256+ Err(error) => {1257+ break Err(error).context("media feedback reader task failed");1258+ }9631259 }9641260 }965- }966- changed = feedback_receiver.changed(), if receiver_open => {967- if changed.is_err() {968- receiver_open = false;969- if reader_finished {1261+ commit_result = commit_coordinator.acknowledgements.join_next(),1262+ if commit_coordinator.has_pending() =>1263+ {1264+ let acknowledgement = commit_coordinator.complete(commit_result)?;1265+ if acknowledgement.generation_ended {9701266 break Ok(());9711267 }972- continue;973- }974- let ingress = *feedback_receiver.borrow_and_update();975- let Some((report, next_cursor)) = ingress.report_since(feedback_cursor)? else {976- continue;977- };978- // This branch now owns the complete aggregate. Advance the979- // ingress baseline exactly once before evaluation; reports980- // arriving during an encoder wait accumulate beyond it.981- feedback_cursor = next_cursor;982- let telemetry = lease.telemetry.snapshot();983- let observed_at = Instant::now();984- let mut candidate = controller.clone();985- let evaluation = candidate.evaluate(&report, telemetry, observed_at)?;986- let mut decision = evaluation.decision;987- let mut resolution_candidate = resolution_controller.clone();988- let resolution_decision = resolution_candidate989- .as_mut()990- .map(|policy| policy.observe_window(&report, &evaluation, observed_at));991- let changes_bitrate = decision.state != AdaptiveBitrateStateV1::Hold;992- let changes_resolution = resolution_decision.is_some_and(|change| change.changed);993- let can_actuate_bitrate = changes_bitrate && encoder_actuation_available;994- let can_actuate_resolution = changes_resolution && encoder_actuation_available;995- let mut recovery_acknowledged = None;996- let bitrate_proposal = if can_actuate_bitrate {997- let force_keyframe =998- adaptive_recovery_keyframe_required(&report, &decision)999- && !changes_resolution;1000- match sessions.propose_adaptive_encoder_update(1268+ log_adaptive_acknowledgement(10011269 remote,10021270 lease.session_id,1003- decision.target_kbps,1004- force_keyframe,1005- ) {1006- Ok(Some(proposal)) => Some(proposal),1007- Ok(None) => {1008- warn!(1009- %remote,1010- session_id = lease.session_id,1011- "active adaptive session lost encoder control; retaining committed bitrate"1012- );1013- None1014- }1015- Err(error) => {1016- warn!(1017- %remote,1018- session_id = lease.session_id,1019- %error,1020- "adaptive encoder proposal failed; retaining committed bitrate"1021- );1022- None1023- }1271+ encoder_actuation_available,1272+ &commit_coordinator,1273+ &acknowledgement,1274+ );1275+ // If a newer report is already waiting, let its evaluation1276+ // replace the queued actuator slot before starting more1277+ // work. This avoids spending another full timeout on a1278+ // plan that was stale at completion time.1279+ if !matches!(feedback_receiver.has_changed(), Ok(true))1280+ && let Some(immediate) = commit_coordinator.start_queued()1281+ {1282+ log_adaptive_acknowledgement(1283+ remote,1284+ lease.session_id,1285+ encoder_actuation_available,1286+ &commit_coordinator,1287+ &immediate,1288+ );10241289 }1025- } else {1026- None1027- };1028- let resolution_proposal = if can_actuate_resolution {1029- let target = resolution_decision1030- .expect("resolution change is required for actuation")1031- .target;1032- match sessions.propose_resolution_update(remote, lease.session_id, target) {1033- Ok(Some(proposal)) => Some(proposal),1034- Ok(None) => {1035- warn!(1036- %remote,1037- session_id = lease.session_id,1038- "active adaptive session lost encoder control; retaining committed resolution"1039- );1040- None1041- }1042- Err(error) => {1043- warn!(1044- %remote,1045- session_id = lease.session_id,1046- %error,1047- "adaptive resolution proposal failed; retaining committed resolution"1048- );1049- None1290+ }1291+ changed = feedback_receiver.changed(), if receiver_open => {1292+ if changed.is_err() {1293+ receiver_open = false;1294+ if reader_finished {1295+ break Ok(());10501296 }1297+ continue;10511298 }1052- } else {1053- None1054- };1299+ let ingress = *feedback_receiver.borrow_and_update();1300+ let Some((report, next_cursor)) = ingress.report_since(feedback_cursor)? else {1301+ continue;1302+ };1303+ // This branch now owns the complete aggregate. Advance the1304+ // ingress baseline exactly once before evaluation; reports1305+ // arriving during an encoder wait accumulate beyond it.1306+ feedback_cursor = next_cursor;1307+ let telemetry = lease.telemetry.snapshot();1308+ let observed_at = Instant::now();1309+ let mut candidate = controller.clone();1310+ let evaluation = candidate.evaluate(&report, telemetry, observed_at)?;1311+ let mut decision = evaluation.decision;1312+ decision.applied =1313+ commit_coordinator.bitrate_is_applied(decision.target_kbps);1314+ let mut resolution_candidate = resolution_controller.clone();1315+ let resolution_decision = resolution_candidate1316+ .as_mut()1317+ .map(|policy| policy.observe_window(&report, &evaluation, observed_at));1318+ let force_keyframe =1319+ adaptive_recovery_keyframe_required(&report, &decision);1320+ let plan = AdaptiveCommitPlan {1321+ decision,1322+ telemetry,1323+ resolution_decision,1324+ force_keyframe,1325+ };1326+1327+ // One fixed-width decision is written for every evaluated1328+ // report before actuator work is submitted. Encoder1329+ // acknowledgements are correlated in bounded host1330+ // diagnostics and never gate the Portal-facing cadence.1331+ write_adaptive_decision(1332+ send,1333+ remote,1334+ lease.session_id,1335+ encoder_actuation_available,1336+ &plan,1337+ )1338+ .await?;10551339 1056- if let Some(proposal) = bitrate_proposal {1057- match commit_adaptive_encoder_proposal(1058- sessions,1340+ // Evaluation state advances at feedback cadence. The1341+ // actuator independently retains the exact requested,1342+ // committed, and latest desired targets.1343+ controller = candidate;1344+ resolution_controller = resolution_candidate;1345+ if let Some(immediate) = commit_coordinator.submit(plan) {1346+ log_adaptive_acknowledgement(10591347 remote,10601348 lease.session_id,1061- &proposal,1062- )1063- .await1064- {1065- AdaptiveEncoderCommit::GenerationEnded => break Ok(()),1066- AdaptiveEncoderCommit::NotApplied(error) => {1067- warn!(1068- %remote,1069- session_id = lease.session_id,1070- %error,1071- "adaptive encoder application failed; retaining committed bitrate"1072- );1073- }1074- AdaptiveEncoderCommit::Applied => {1075- decision.applied = true;1076- controller = candidate.clone();1077- if proposal.force_keyframe_revision.is_some() {1078- match await_adaptive_recovery_keyframe(1079- sessions,1080- remote,1081- lease.session_id,1082- &proposal,1083- )1084- .await1085- {1086- None => break Ok(()),1087- Some(Ok(())) => recovery_acknowledged = Some(true),1088- Some(Err(error)) => {1089- recovery_acknowledged = Some(false);1090- warn!(1091- %remote,1092- session_id = lease.session_id,1093- %error,1094- "adaptive bitrate applied but forced-IDR recovery was not acknowledged"1095- );1096- }1097- }1098- }1099- }1100- }1349+ encoder_actuation_available,1350+ &commit_coordinator,1351+ &immediate,1352+ );1353+ }11011354 }1102- if can_actuate_bitrate {1103- if !decision.applied {1104- controller.commit_observation_from(&candidate);1355+ _ = &mut session_changed => {1356+ if !sessions.is_active(remote, lease.session_id) {1357+ break Ok(());11051358 }1106- } else {1107- // External gst-launch and sources without EncoderControl1108- // remain intentionally shadow-only. Hold decisions do not1109- // need encoder actuation.1110- controller = candidate;11111359 }1360+ _ = &mut connection_closed => {1361+ break Ok(());1362+ }1363+ }1364+ if reader_finished1365+ && !receiver_open1366+ && !commit_coordinator.has_pending()1367+ && commit_coordinator.queued.is_none()1368+ {1369+ break Ok(());1370+ }1371+ }1372+ }1373+ .await;11121374 1113- let mut resolution_applied = None;1114- if let Some(proposal) = resolution_proposal {1115- match commit_resolution_encoder_proposal(1116- sessions,1117- remote,1118- lease.session_id,1119- &proposal,1120- )1121- .await1375+ if !reader_finished {1376+ reader_task.abort();1377+ let _ = reader_task.await;1378+ }1379+ commit_coordinator.abort_and_drain().await;1380+ result1381+}1382+1383+async fn write_adaptive_decision<W>(1384+ send: &mut W,1385+ remote: EndpointId,1386+ session_id: u64,1387+ encoder_actuation_available: bool,1388+ plan: &AdaptiveCommitPlan,1389+) -> Result<()>1390+where1391+ W: tokio::io::AsyncWrite + Unpin,1392+{1393+ let decision = plan.decision;1394+ let telemetry = plan.telemetry;1395+ let resolution_decision = plan.resolution_decision;1396+ tokio::time::timeout(1397+ FEEDBACK_WRITE_TIMEOUT,1398+ write_adaptive_bitrate_decision_v1(send, &decision),1399+ )1400+ .await1401+ .context("timed out writing adaptive bitrate decision")??;1402+ debug!(1403+ %remote,1404+ session_id,1405+ report_id = decision.report_id,1406+ decision_id = decision.decision_id,1407+ target_kbps = decision.target_kbps,1408+ ?decision.state,1409+ reasons = decision.reasons.bits(),1410+ applied = decision.applied,1411+ resolution_width = resolution_decision.map(|change| change.target.width),1412+ resolution_height = resolution_decision.map(|change| change.target.height),1413+ resolution_trigger = ?resolution_decision.map(|change| change.trigger),1414+ path_rtt_micros = telemetry.selected_path_rtt_micros,1415+ path_lost_packets = telemetry.selected_path_lost_packets,1416+ path_congestion_events = telemetry.selected_path_congestion_events,1417+ scheduler_cancellations = telemetry.scheduler_cancellations,1418+ send_failures = telemetry.send_failures,1419+ mode = if encoder_actuation_available { "active" } else { "shadow" },1420+ "adaptive bitrate decision"1421+ );1422+ Ok(())1423+}1424+1425+fn log_adaptive_acknowledgement(1426+ remote: EndpointId,1427+ session_id: u64,1428+ encoder_actuation_available: bool,1429+ coordinator: &AdaptiveCommitCoordinator,1430+ acknowledgement: &AdaptiveCommitAcknowledgement,1431+) {1432+ let decision = acknowledgement.plan.decision;1433+ let resolution_decision = acknowledgement.plan.resolution_decision;1434+ debug!(1435+ %remote,1436+ session_id,1437+ report_id = decision.report_id,1438+ decision_id = decision.decision_id,1439+ target_kbps = decision.target_kbps,1440+ bitrate_applied = acknowledgement.applied_bitrate_kbps.is_some(),1441+ applied_bitrate_kbps = ?acknowledgement.applied_bitrate_kbps,1442+ recovery_acknowledged = ?acknowledgement.recovery_acknowledged,1443+ resolution_width = resolution_decision.map(|change| change.target.width),1444+ resolution_height = resolution_decision.map(|change| change.target.height),1445+ resolution_applied = ?acknowledgement.resolution_applied,1446+ applied_resolution = ?acknowledgement.applied_resolution,1447+ desired_bitrate_kbps = coordinator.desired_bitrate_kbps,1448+ requested_bitrate_kbps = coordinator.requested_bitrate_kbps,1449+ committed_bitrate_kbps = coordinator.committed_bitrate_kbps,1450+ desired_resolution = ?coordinator.desired_resolution,1451+ requested_resolution = ?coordinator.requested_resolution,1452+ committed_resolution = ?coordinator.committed_resolution,1453+ mode = if encoder_actuation_available { "active" } else { "shadow" },1454+ "adaptive actuator acknowledgement"1455+ );1456+}1457+1458+async fn run_adaptive_commit(1459+ sessions: Arc<SessionRegistry>,1460+ remote: EndpointId,1461+ session_id: u64,1462+ plan: AdaptiveCommitPlan,1463+ bitrate_proposal: Option<AdaptiveEncoderProposal>,1464+ resolution_proposal: Option<ResolutionEncoderProposal>,1465+) -> AdaptiveCommitAcknowledgement {1466+ let mut acknowledgement = AdaptiveCommitAcknowledgement::immediate(plan);1467+ if let Some(proposal) = bitrate_proposal {1468+ match commit_adaptive_encoder_proposal(&sessions, remote, session_id, &proposal).await {1469+ AdaptiveEncoderCommit::GenerationEnded => {1470+ acknowledgement.generation_ended = true;1471+ return acknowledgement;1472+ }1473+ AdaptiveEncoderCommit::NotApplied(error) => {1474+ warn!(1475+ %remote,1476+ session_id,1477+ %error,1478+ "adaptive encoder application failed; retaining committed bitrate"1479+ );1480+ }1481+ AdaptiveEncoderCommit::Applied => {1482+ acknowledgement.plan.decision.applied = true;1483+ acknowledgement.applied_bitrate_kbps = Some(proposal.target_kbps);1484+ if proposal.force_keyframe_revision.is_some() {1485+ match await_adaptive_recovery_keyframe(&sessions, remote, session_id, &proposal)1486+ .await11221487 {1123- AdaptiveEncoderCommit::GenerationEnded => break Ok(()),1124- AdaptiveEncoderCommit::NotApplied(error) => {1125- resolution_applied = Some(false);1488+ None => {1489+ acknowledgement.generation_ended = true;1490+ return acknowledgement;1491+ }1492+ Some(Ok(())) => acknowledgement.recovery_acknowledged = Some(true),1493+ Some(Err(error)) => {1494+ acknowledgement.recovery_acknowledged = Some(false);11261495 warn!(11271496 %remote,1128- session_id = lease.session_id,1129- target_width = proposal.target.width,1130- target_height = proposal.target.height,1497+ session_id,11311498 %error,1132- "adaptive resolution application failed; retaining committed resolution"1499+ "adaptive bitrate applied but forced-IDR recovery was not acknowledged"11331500 );11341501 }1135- AdaptiveEncoderCommit::Applied => {1136- resolution_applied = Some(true);1137- resolution_controller = resolution_candidate.clone();1138- }11391502 }11401503 }1141- if can_actuate_resolution && resolution_applied != Some(true) {1142- if let (Some(committed), Some(candidate)) =1143- (resolution_controller.as_mut(), resolution_candidate.as_ref())1144- {1145- committed.commit_observation_from(candidate);1146- }1147- } else if !changes_resolution {1148- resolution_controller = resolution_candidate;1149- }1150- tokio::time::timeout(1151- FEEDBACK_WRITE_TIMEOUT,1152- write_adaptive_bitrate_decision_v1(&mut send, &decision),1153- )1154- .await1155- .context("timed out writing adaptive bitrate decision")??;1156- debug!(1504+ }1505+ }1506+ }1507+1508+ if let Some(proposal) = resolution_proposal {1509+ match commit_resolution_encoder_proposal(&sessions, remote, session_id, &proposal).await {1510+ AdaptiveEncoderCommit::GenerationEnded => {1511+ acknowledgement.generation_ended = true;1512+ return acknowledgement;1513+ }1514+ AdaptiveEncoderCommit::NotApplied(error) => {1515+ acknowledgement.resolution_applied = Some(false);1516+ warn!(11571517 %remote,1158- session_id = lease.session_id,1159- report_id = decision.report_id,1160- decision_id = decision.decision_id,1161- target_kbps = decision.target_kbps,1162- ?decision.state,1163- reasons = decision.reasons.bits(),1164- applied = decision.applied,1165- ?recovery_acknowledged,1166- resolution_width = resolution_decision.map(|change| change.target.width),1167- resolution_height = resolution_decision.map(|change| change.target.height),1168- resolution_trigger = ?resolution_decision.map(|change| change.trigger),1169- ?resolution_applied,1170- path_rtt_micros = telemetry.selected_path_rtt_micros,1171- path_lost_packets = telemetry.selected_path_lost_packets,1172- path_congestion_events = telemetry.selected_path_congestion_events,1173- scheduler_cancellations = telemetry.scheduler_cancellations,1174- send_failures = telemetry.send_failures,1175- mode = if encoder_actuation_available { "active" } else { "shadow" },1176- "adaptive bitrate decision"1518+ session_id,1519+ target_width = proposal.target.width,1520+ target_height = proposal.target.height,1521+ %error,1522+ "adaptive resolution application failed; retaining committed resolution"11771523 );11781524 }1179- _ = &mut session_changed => {1180- if !sessions.is_active(remote, lease.session_id) {1181- break Ok(());1182- }1183- }1184- closed = connection.closed() => {1185- debug!(%remote, ?closed, "media feedback connection closed");1186- break Ok(());1525+ AdaptiveEncoderCommit::Applied => {1526+ acknowledgement.resolution_applied = Some(true);1527+ acknowledgement.applied_resolution = Some(proposal.target);11871528 }11881529 }1189- if reader_finished && !receiver_open {1190- break Ok(());1191- }1192- };1193-1194- if !reader_finished {1195- reader_task.abort();1196- let _ = reader_task.await;11971530 }1198- let _ = send.finish();1199- drop(lease);1200- info!(%remote, "media feedback client released");1201- result1531+ acknowledgement12021532 }12031533 12041534 async fn forward_media_feedback_reports<R>(
@@ -1439,6 +1769,25 @@ mod tests {
14391769 .unwrap()14401770 }14411771 1772+ fn evaluate_commit_plan(1773+ controller: &mut ShadowBitrateController,1774+ resolution_controller: &mut Option<MotionResolutionPolicy>,1775+ report: &MediaFeedbackReportV1,1776+ observed_at: Instant,1777+ ) -> AdaptiveCommitPlan {1778+ let telemetry = MediaV3TelemetrySnapshot::default();1779+ let evaluation = controller.evaluate(report, telemetry, observed_at).unwrap();1780+ let resolution_decision = resolution_controller1781+ .as_mut()1782+ .map(|policy| policy.observe_window(report, &evaluation, observed_at));1783+ AdaptiveCommitPlan {1784+ decision: evaluation.decision,1785+ telemetry,1786+ resolution_decision,1787+ force_keyframe: adaptive_recovery_keyframe_required(report, &evaluation.decision),1788+ }1789+ }1790+14421791 fn native_dimensions() -> VideoDimensions {14431792 VideoDimensions {14441793 width: 1_280,
@@ -2155,36 +2504,6 @@ mod tests {
21552504 ));21562505 }21572506 2158- #[test]2159- fn failed_adaptive_application_commits_observation_but_not_target() {2160- let mut committed =2161- ShadowBitrateController::new(12_000, MediaV3TelemetrySnapshot::default());2162- controller_decide(&mut committed, &clean_feedback(1), 0);2163- let old_target = committed.target_kbps;2164- let old_decision_id = committed.next_decision_id;2165- let mut severe = clean_feedback(2);2166- severe.flags = MediaFeedbackFlags::RESYNC_ACTIVE;2167-2168- let mut candidate = committed.clone();2169- let decision = controller_decide(&mut candidate, &severe, 1);2170- assert_eq!(decision.state, AdaptiveBitrateStateV1::Decrease);2171- assert!(candidate.target_kbps < old_target);2172- committed.commit_observation_from(&candidate);2173-2174- assert_eq!(committed.target_kbps, old_target);2175- assert_eq!(committed.last_report_id, Some(2));2176- assert_eq!(committed.next_decision_id, old_decision_id + 1);2177- assert!(2178- committed2179- .decide(2180- &clean_feedback(3),2181- MediaV3TelemetrySnapshot::default(),2182- Instant::now() + Duration::from_secs(2)2183- )2184- .is_ok()2185- );2186- }2187-21882507 #[test]21892508 fn adaptive_recovery_idr_is_only_requested_for_a_bitrate_change_during_resync() {21902509 let mut report = clean_feedback(1);
@@ -2306,4 +2625,497 @@ mod tests {
23062625 "a clamped aggregate must be stale rather than disguised as one fresh report"23072626 );23082627 }2628+2629+ #[tokio::test]2630+ async fn queued_adaptive_plan_keeps_latest_ids_and_recovery_barrier() {2631+ let sessions = Arc::new(SessionRegistry::default());2632+ let remote = endpoint(1);2633+ let media = sessions2634+ .claim(remote, [3; 16], InvitationGrants::VIEW)2635+ .unwrap();2636+ let harness = crate::source::EncoderControlTestHarness::new();2637+ sessions2638+ .install_encoder_control(remote, media.session_id, Some(harness.control.clone()))2639+ .unwrap();2640+ let mut coordinator = AdaptiveCommitCoordinator::new(2641+ Arc::clone(&sessions),2642+ remote,2643+ media.session_id,2644+ true,2645+ 12_000,2646+ Some(native_dimensions()),2647+ );2648+ let mut controller =2649+ ShadowBitrateController::new(12_000, MediaV3TelemetrySnapshot::default());2650+ let mut resolution_controller = None;2651+ let observed_at = Instant::now();2652+2653+ let seed = evaluate_commit_plan(2654+ &mut controller,2655+ &mut resolution_controller,2656+ &clean_feedback(1),2657+ observed_at,2658+ );2659+ assert!(coordinator.submit(seed).is_some());2660+2661+ let mut first_pressure = clean_feedback(2);2662+ first_pressure.decoder_dropped_delta = 1;2663+ let first = evaluate_commit_plan(2664+ &mut controller,2665+ &mut resolution_controller,2666+ &first_pressure,2667+ observed_at + Duration::from_secs(1),2668+ );2669+ assert!(!first.force_keyframe);2670+ assert!(coordinator.submit(first).is_none());2671+2672+ let mut recovery_pressure = clean_feedback(3);2673+ recovery_pressure.decoder_dropped_delta = 1;2674+ recovery_pressure.flags = MediaFeedbackFlags::RESYNC_ACTIVE;2675+ let recovery = evaluate_commit_plan(2676+ &mut controller,2677+ &mut resolution_controller,2678+ &recovery_pressure,2679+ observed_at + Duration::from_secs(2),2680+ );2681+ assert!(recovery.force_keyframe);2682+ assert!(coordinator.submit(recovery).is_none());2683+2684+ let latest = evaluate_commit_plan(2685+ &mut controller,2686+ &mut resolution_controller,2687+ &clean_feedback(4),2688+ observed_at + Duration::from_secs(3),2689+ );2690+ assert!(!latest.force_keyframe);2691+ assert!(coordinator.submit(latest).is_none());2692+ let retained = coordinator.queued.expect("latest plan must be retained");2693+ assert_eq!(retained.decision.decision_id, latest.decision.decision_id);2694+ assert_eq!(retained.decision.report_id, latest.decision.report_id);2695+ assert!(2696+ retained.force_keyframe,2697+ "a coalesced recovery barrier must survive a newer hold decision"2698+ );2699+2700+ coordinator.abort_and_drain().await;2701+ drop(media);2702+ }2703+2704+ #[tokio::test(start_paused = true)]2705+ async fn timed_out_actuator_requests_are_overwritten_when_policy_returns_to_committed_state() {2706+ let sessions = Arc::new(SessionRegistry::default());2707+ let remote = endpoint(1);2708+ let media = sessions2709+ .claim(remote, [8; 16], InvitationGrants::VIEW)2710+ .unwrap();2711+ let harness = crate::source::EncoderControlTestHarness::new();2712+ sessions2713+ .install_encoder_control(remote, media.session_id, Some(harness.control.clone()))2714+ .unwrap();2715+ let native = native_dimensions();2716+ let reduced = native.three_quarter().unwrap();2717+ let mut coordinator = AdaptiveCommitCoordinator::new(2718+ Arc::clone(&sessions),2719+ remote,2720+ media.session_id,2721+ true,2722+ 12_000,2723+ Some(native),2724+ );2725+ let timed_out = AdaptiveCommitPlan {2726+ decision: AdaptiveBitrateDecisionV1 {2727+ decision_id: 1,2728+ report_id: 1,2729+ target_kbps: 9_000,2730+ floor_kbps: 1_000,2731+ ceiling_kbps: 12_000,2732+ state: AdaptiveBitrateStateV1::Decrease,2733+ reasons: AdaptiveBitrateReasonFlagsV1::RECEIVER_QUEUE,2734+ applied: false,2735+ },2736+ telemetry: MediaV3TelemetrySnapshot::default(),2737+ resolution_decision: Some(MotionResolutionDecision {2738+ tier: ResolutionTier::Reduced,2739+ target: reduced,2740+ trigger: ResolutionTrigger::Pressure,2741+ changed: true,2742+ }),2743+ force_keyframe: false,2744+ };2745+ assert!(coordinator.submit(timed_out).is_none());2746+ let stale_bitrate = harness2747+ .requested_bitrate()2748+ .expect("the first bitrate request must reach the actuator");2749+ let stale_resolution = harness2750+ .requested_resolution()2751+ .expect("the first resolution request must reach the actuator");2752+ assert_eq!(stale_bitrate.1, 9_000);2753+ assert_eq!(2754+ (stale_resolution.1, stale_resolution.2),2755+ (reduced.width, reduced.height)2756+ );2757+2758+ // Feedback evaluation recovers all the way to the previously2759+ // committed state while the old request is still awaiting readback.2760+ let recovered = AdaptiveCommitPlan {2761+ decision: AdaptiveBitrateDecisionV1 {2762+ decision_id: 2,2763+ report_id: 2,2764+ target_kbps: 12_000,2765+ floor_kbps: 1_000,2766+ ceiling_kbps: 12_000,2767+ state: AdaptiveBitrateStateV1::Increase,2768+ reasons: AdaptiveBitrateReasonFlagsV1::CLEAN_RECOVERY,2769+ applied: false,2770+ },2771+ telemetry: MediaV3TelemetrySnapshot::default(),2772+ resolution_decision: Some(MotionResolutionDecision {2773+ tier: ResolutionTier::Native,2774+ target: native,2775+ trigger: ResolutionTrigger::Recovery,2776+ changed: true,2777+ }),2778+ force_keyframe: false,2779+ };2780+ assert!(coordinator.submit(recovered).is_none());2781+2782+ // The first task times out its bitrate wait and then its resolution2783+ // wait. Neither timeout changes the last acknowledged state.2784+ tokio::task::yield_now().await;2785+ tokio::time::advance(ENCODER_CONTROL_COMMIT_TIMEOUT).await;2786+ tokio::task::yield_now().await;2787+ tokio::time::advance(ENCODER_CONTROL_COMMIT_TIMEOUT).await;2788+ tokio::task::yield_now().await;2789+ let result = coordinator.acknowledgements.join_next().await;2790+ let acknowledgement = coordinator.complete(result).unwrap();2791+ assert!(!acknowledgement.plan.decision.applied);2792+ assert_eq!(acknowledgement.resolution_applied, Some(false));2793+ assert_eq!(coordinator.committed_bitrate_kbps, 12_000);2794+ assert_eq!(coordinator.committed_resolution, Some(native));2795+ assert_eq!(coordinator.requested_bitrate_kbps, 9_000);2796+ assert_eq!(coordinator.requested_resolution, Some(reduced));2797+2798+ // Starting the recovered plan must overwrite the stale persistent2799+ // actuator watch even though its targets equal the old committed2800+ // values. Otherwise the timed-out request would apply on resume.2801+ assert!(coordinator.start_queued().is_none());2802+ assert!(coordinator.has_pending());2803+ let corrected_bitrate = harness2804+ .requested_bitrate()2805+ .expect("recovered bitrate must overwrite stale actuator intent");2806+ let corrected_resolution = harness2807+ .requested_resolution()2808+ .expect("recovered resolution must overwrite stale actuator intent");2809+ assert!(corrected_bitrate.0 > stale_bitrate.0);2810+ assert!(corrected_resolution.0 > stale_resolution.0);2811+ assert_eq!(corrected_bitrate.1, 12_000);2812+ assert_eq!(2813+ (corrected_resolution.1, corrected_resolution.2),2814+ (native.width, native.height)2815+ );2816+ assert_eq!(coordinator.requested_bitrate_kbps, 12_000);2817+ assert_eq!(coordinator.requested_resolution, Some(native));2818+2819+ coordinator.abort_and_drain().await;2820+ drop(media);2821+ }2822+2823+ #[tokio::test]2824+ async fn feedback_loop_writes_every_decision_while_combined_commit_is_hung() {2825+ let sessions = Arc::new(SessionRegistry::default());2826+ let remote = endpoint(1);2827+ let nonce = [9; 16];2828+ let media = sessions2829+ .claim(remote, nonce, InvitationGrants::VIEW)2830+ .unwrap();2831+ let harness = crate::source::EncoderControlTestHarness::new();2832+ sessions2833+ .install_encoder_control(remote, media.session_id, Some(harness.control.clone()))2834+ .unwrap();2835+ let feedback = sessions.claim_feedback(remote, nonce).unwrap();2836+ let session_id = feedback.session_id;2837+2838+ let (mut report_writer, report_reader) = tokio::io::duplex(4_096);2839+ let (feedback_sender, mut feedback_receiver) =2840+ tokio::sync::watch::channel(CumulativeFeedbackIngress::default());2841+ let reader_task = tokio::spawn(forward_media_feedback_reports(2842+ report_reader,2843+ feedback_sender,2844+ ));2845+ let (mut decision_writer, mut decision_reader) = tokio::io::duplex(4_096);2846+ let loop_sessions = Arc::clone(&sessions);2847+ let loop_task = tokio::spawn(async move {2848+ run_media_feedback_loop(2849+ &mut decision_writer,2850+ std::future::pending(),2851+ remote,2852+ &loop_sessions,2853+ &feedback,2854+ true,2855+ 12_000,2856+ &mut feedback_receiver,2857+ reader_task,2858+ )2859+ .await2860+ });2861+2862+ let mut observed = Vec::new();2863+ for report_id in 1..=5 {2864+ if report_id > 1 {2865+ tokio::time::sleep(Duration::from_millis(800)).await;2866+ }2867+ let mut report = clean_feedback(report_id);2868+ if report_id >= 2 {2869+ report.flags = MediaFeedbackFlags::RESYNC_ACTIVE;2870+ report.decoder_dropped_delta = 4;2871+ }2872+ sigil_protocol::write_media_feedback_report_v1(&mut report_writer, &report)2873+ .await2874+ .unwrap();2875+ let decision = tokio::time::timeout(2876+ Duration::from_secs(1),2877+ sigil_protocol::read_adaptive_bitrate_decision_v1(&mut decision_reader),2878+ )2879+ .await2880+ .expect("each evaluated report must be written before the hung commit timeout")2881+ .unwrap()2882+ .expect("feedback loop closed before writing its decision");2883+ observed.push((decision.decision_id, decision.report_id));2884+ }2885+ assert_eq!(2886+ observed,2887+ (1..=5).map(|id| (id, id)).collect::<Vec<_>>(),2888+ "slow bitrate and resolution readbacks must not coalesce Portal decisions"2889+ );2890+ assert!(2891+ harness.requested_bitrate().is_some(),2892+ "the adversarial run must actually have pending bitrate work"2893+ );2894+ assert!(2895+ harness.requested_resolution().is_some(),2896+ "the adversarial run must actually have pending resolution work"2897+ );2898+2899+ drop(media);2900+ drop(report_writer);2901+ tokio::time::timeout(Duration::from_secs(1), loop_task)2902+ .await2903+ .expect("session teardown must abort the hung commit promptly")2904+ .unwrap()2905+ .unwrap();2906+ assert!(!sessions.is_active(remote, session_id));2907+ }2908+2909+ #[tokio::test(start_paused = true)]2910+ async fn slow_adaptive_commit_does_not_pause_feedback_evaluation() {2911+ let sessions = Arc::new(SessionRegistry::default());2912+ let remote = endpoint(1);2913+ let media = sessions2914+ .claim(remote, [4; 16], InvitationGrants::VIEW)2915+ .unwrap();2916+ let harness = crate::source::EncoderControlTestHarness::new();2917+ sessions2918+ .install_encoder_control(remote, media.session_id, Some(harness.control.clone()))2919+ .unwrap();2920+ let mut coordinator = AdaptiveCommitCoordinator::new(2921+ Arc::clone(&sessions),2922+ remote,2923+ media.session_id,2924+ true,2925+ 12_000,2926+ Some(native_dimensions()),2927+ );2928+ let mut controller =2929+ ShadowBitrateController::new(12_000, MediaV3TelemetrySnapshot::default());2930+ let mut resolution_controller = None;2931+ let observed_at = Instant::now();2932+2933+ let seed = evaluate_commit_plan(2934+ &mut controller,2935+ &mut resolution_controller,2936+ &clean_feedback(1),2937+ observed_at,2938+ );2939+ assert_eq!(2940+ coordinator.submit(seed).unwrap().plan.decision.decision_id,2941+ 12942+ );2943+2944+ let mut severe = clean_feedback(2);2945+ severe.decoder_dropped_delta = 1;2946+ let first = evaluate_commit_plan(2947+ &mut controller,2948+ &mut resolution_controller,2949+ &severe,2950+ observed_at + Duration::from_secs(1),2951+ );2952+ assert_eq!(first.decision.state, AdaptiveBitrateStateV1::Decrease);2953+ assert!(coordinator.submit(first).is_none());2954+ assert!(coordinator.has_pending());2955+2956+ tokio::time::advance(Duration::from_secs(1)).await;2957+ let evaluation_tick = tokio::time::Instant::now();2958+ let mut newer_severe = clean_feedback(3);2959+ newer_severe.decoder_dropped_delta = 1;2960+ let queued = evaluate_commit_plan(2961+ &mut controller,2962+ &mut resolution_controller,2963+ &newer_severe,2964+ observed_at + Duration::from_secs(2),2965+ );2966+ assert_eq!(queued.decision.decision_id, 3);2967+ assert_eq!(queued.decision.state, AdaptiveBitrateStateV1::Decrease);2968+ assert!(coordinator.submit(queued).is_none());2969+ assert_eq!(tokio::time::Instant::now(), evaluation_tick);2970+ assert_eq!(coordinator.acknowledgements.len(), 1);2971+ assert_eq!(2972+ coordinator.queued.unwrap().decision.decision_id,2973+ queued.decision.decision_id2974+ );2975+2976+ // Revision one is acknowledged only after another feedback window was2977+ // evaluated. The decision retains that exact readback result.2978+ harness.status.send_modify(|status| {2979+ status.applied_bitrate_revision = Some(1);2980+ status.applied_bitrate_kbps = Some(first.decision.target_kbps);2981+ });2982+ let commit_result = coordinator.acknowledgements.join_next().await;2983+ let completed = coordinator.complete(commit_result).unwrap();2984+ assert_eq!(completed.plan.decision.decision_id, 2);2985+ assert!(completed.plan.decision.applied);2986+ assert_eq!(2987+ completed.applied_bitrate_kbps,2988+ Some(first.decision.target_kbps)2989+ );2990+2991+ assert!(coordinator.start_queued().is_none());2992+ assert!(coordinator.has_pending());2993+ assert_eq!(coordinator.acknowledgements.len(), 1);2994+2995+ let session_id = media.session_id;2996+ drop(media);2997+ assert!(!sessions.is_active(remote, session_id));2998+ let session_end_result = coordinator.acknowledgements.join_next().await;2999+ let session_end = coordinator.complete(session_end_result).unwrap();3000+ assert!(session_end.generation_ended);3001+ coordinator.abort_and_drain().await;3002+ assert!(!coordinator.has_pending());3003+ assert!(coordinator.queued.is_none());3004+ assert!(coordinator.acknowledgements.is_empty());3005+ }3006+3007+ #[tokio::test(start_paused = true)]3008+ async fn timing_out_commits_keep_uniform_hysteresis_and_bounded_work() {3009+ let sessions = Arc::new(SessionRegistry::default());3010+ let remote = endpoint(1);3011+ let media = sessions3012+ .claim(remote, [5; 16], InvitationGrants::VIEW)3013+ .unwrap();3014+ let harness = crate::source::EncoderControlTestHarness::new();3015+ sessions3016+ .install_encoder_control(remote, media.session_id, Some(harness.control.clone()))3017+ .unwrap();3018+ let mut coordinator = AdaptiveCommitCoordinator::new(3019+ Arc::clone(&sessions),3020+ remote,3021+ media.session_id,3022+ true,3023+ 12_000,3024+ Some(native_dimensions()),3025+ );3026+ let mut controller =3027+ ShadowBitrateController::new(12_000, MediaV3TelemetrySnapshot::default());3028+ let mut resolution_controller =3029+ Some(MotionResolutionPolicy::new(native_dimensions()).unwrap());3030+ let observed_at = Instant::now();3031+ let virtual_started_at = tokio::time::Instant::now();3032+ let mut evaluation_ticks = Vec::new();3033+ let mut decision_ids = Vec::new();3034+ let mut states = Vec::new();3035+ let mut completed = Vec::new();3036+3037+ for report_id in 1..=9 {3038+ evaluation_ticks.push(tokio::time::Instant::now());3039+ let mut report = clean_feedback(report_id);3040+ if report_id > 1 {3041+ report.frontend_queue_depth = 2;3042+ }3043+ let plan = evaluate_commit_plan(3044+ &mut controller,3045+ &mut resolution_controller,3046+ &report,3047+ observed_at + Duration::from_secs(report_id - 1),3048+ );3049+ decision_ids.push(plan.decision.decision_id);3050+ states.push(plan.decision.state);3051+ if let Some(immediate) = coordinator.submit(plan) {3052+ completed.push(immediate);3053+ }3054+ assert!(3055+ coordinator.acknowledgements.len() <= 1,3056+ "the coordinator must own at most one commit task"3057+ );3058+ assert!(3059+ coordinator.queued.iter().count() <= 1,3060+ "the coordinator must retain only the latest plan"3061+ );3062+3063+ tokio::time::advance(Duration::from_secs(1)).await;3064+ tokio::task::yield_now().await;3065+ if let Some(result) = coordinator.acknowledgements.try_join_next() {3066+ completed.push(coordinator.complete(Some(result)).unwrap());3067+ if let Some(immediate) = coordinator.start_queued() {3068+ completed.push(immediate);3069+ }3070+ }3071+ }3072+3073+ assert_eq!(3074+ decision_ids,3075+ (1..=9).collect::<Vec<_>>(),3076+ "coalescing must not pause or reuse decision IDs"3077+ );3078+ assert_eq!(3079+ states,3080+ [3081+ AdaptiveBitrateStateV1::Hold,3082+ AdaptiveBitrateStateV1::Hold,3083+ AdaptiveBitrateStateV1::Decrease,3084+ AdaptiveBitrateStateV1::Hold,3085+ AdaptiveBitrateStateV1::Decrease,3086+ AdaptiveBitrateStateV1::Hold,3087+ AdaptiveBitrateStateV1::Decrease,3088+ AdaptiveBitrateStateV1::Hold,3089+ AdaptiveBitrateStateV1::Decrease,3090+ ],3091+ "moderate-pressure hysteresis must advance once per feedback window"3092+ );3093+ for (index, tick) in evaluation_ticks.into_iter().enumerate() {3094+ assert_eq!(3095+ tick.duration_since(virtual_started_at),3096+ Duration::from_secs(index as u64),3097+ "feedback evaluation cadence must remain uniform during commit timeouts"3098+ );3099+ }3100+ assert!(3101+ completed.iter().any(|acknowledgement| {3102+ acknowledgement.plan.decision.decision_id == 33103+ && !acknowledgement.plan.decision.applied3104+ && acknowledgement.resolution_applied == Some(false)3105+ }),3106+ "the first bitrate and resolution timeout must retain exact negative acknowledgement"3107+ );3108+ assert!(coordinator.has_pending());3109+ assert_eq!(coordinator.acknowledgements.len(), 1);3110+ assert!(coordinator.queued.is_some());3111+3112+ coordinator.abort_and_drain().await;3113+ assert!(!coordinator.has_pending());3114+ assert!(coordinator.queued.is_none());3115+ assert!(coordinator.acknowledgements.is_empty());3116+3117+ let session_id = media.session_id;3118+ drop(media);3119+ assert!(!sessions.is_active(remote, session_id));3120+ }23093121 }
M crates/sigil-host/src/source/annexb.rs +179 -10
@@ -5,37 +5,83 @@ use super::MAX_ENCODE_BUFFER;
55 #[derive(Default)]66 pub(super) struct AnnexBAccessUnitParser {77 buffer: Vec<u8>,8+ next_aud_scan: usize,9+ has_access_unit_start: bool,10+ #[cfg(test)]11+ scanned_candidates: usize,812 }913 1014 impl AnnexBAccessUnitParser {1115 pub(super) fn push(&mut self, bytes: &[u8]) -> Result<Vec<Vec<u8>>> {12- self.buffer.extend_from_slice(bytes);16+ if bytes.is_empty() {17+ return Ok(Vec::new());18+ }19+ let encoded_len = self.buffer.len().checked_add(bytes.len());1320 ensure!(14- self.buffer.len() <= MAX_ENCODE_BUFFER,21+ encoded_len.is_some_and(|len| len <= MAX_ENCODE_BUFFER),1522 "encoded stream exceeded {MAX_ENCODE_BUFFER} bytes without a frame boundary"1623 );24+ self.buffer.extend_from_slice(bytes);1725 1826 let mut frames = Vec::new();19- while let Some(first) = find_h264_aud(&self.buffer, 0) {20- if first > 0 {21- self.buffer.drain(..first);27+ loop {28+ let scan = find_h264_aud(&self.buffer, self.next_aud_scan);29+ #[cfg(test)]30+ {31+ self.scanned_candidates += scan.examined;2232 }23- let Some(next) = find_h264_aud(&self.buffer, 4) else {33+ let Some(aud) = scan.found else {34+ self.next_aud_scan = scan.resume_at;2435 break;2536 };26- let frame: Vec<u8> = self.buffer.drain(..next).collect();37+38+ if !self.has_access_unit_start {39+ if aud > 0 {40+ self.buffer.drain(..aud);41+ }42+ self.has_access_unit_start = true;43+ // Preserve the original parser's search semantics: once the44+ // first AUD is at offset zero, look for its successor at45+ // offset four. That is after a three-byte AUD's NAL header and46+ // at a four-byte AUD's NAL header.47+ self.next_aud_scan = 4;48+ continue;49+ }50+51+ let frame: Vec<u8> = self.buffer.drain(..aud).collect();2752 if !frame.is_empty() {2853 frames.push(frame);2954 }55+ // The AUD that ended the emitted access unit is now at offset56+ // zero and begins the next one.57+ self.next_aud_scan = 4;3058 }3159 Ok(frames)3260 }3361 }3462 35-fn find_h264_aud(data: &[u8], start: usize) -> Option<usize> {63+struct AudScan {64+ found: Option<usize>,65+ resume_at: usize,66+ #[cfg(test)]67+ examined: usize,68+}69+70+fn find_h264_aud(data: &[u8], start: usize) -> AudScan {3671 let mut index = start;72+ #[cfg(test)]73+ let mut examined = 0;3774 while index + 4 <= data.len() {75+ #[cfg(test)]76+ {77+ examined += 1;78+ }3879 let nal_offset = if data.get(index..index + 4) == Some(&[0, 0, 0, 1]) {80+ // The start code is complete, but its NAL header may arrive in the81+ // next chunk. Revisit this one candidate rather than skipping it.82+ if index + 4 == data.len() {83+ break;84+ }3985 Some(index + 4)4086 } else if data.get(index..index + 3) == Some(&[0, 0, 1]) {4187 Some(index + 3)
@@ -45,11 +91,21 @@ fn find_h264_aud(data: &[u8], start: usize) -> Option<usize> {
4591 if let Some(nal_offset) = nal_offset4692 && data.get(nal_offset).is_some_and(|byte| byte & 0x1f == 9)4793 {48- return Some(index);94+ return AudScan {95+ found: Some(index),96+ resume_at: index,97+ #[cfg(test)]98+ examined,99+ };49100 }50101 index += 1;51102 }52- None103+ AudScan {104+ found: None,105+ resume_at: index,106+ #[cfg(test)]107+ examined,108+ }53109 }54110 55111 #[cfg(test)]
@@ -71,4 +127,117 @@ mod tests {
71127 72128 assert_eq!(frames, vec![first.to_vec(), second.to_vec()]);73129 }130+131+ #[test]132+ fn recognizes_three_and_four_byte_start_codes_split_at_every_boundary() {133+ let first = [0, 0, 0, 1, 9, 0x10, 0, 0, 1, 5, 1, 2, 3];134+ let second = [0, 0, 1, 9, 0x10, 0, 0, 1, 1, 4, 5];135+ let third = [0, 0, 0, 1, 9, 0x10, 0, 0, 1, 1, 6, 7];136+ let stream = [first.as_slice(), second.as_slice(), third.as_slice()].concat();137+138+ for split in 1..stream.len() {139+ let mut parser = AnnexBAccessUnitParser::default();140+ let mut frames = parser.push(&stream[..split]).unwrap();141+ frames.extend(parser.push(&stream[split..]).unwrap());142+143+ assert_eq!(144+ frames,145+ vec![first.to_vec(), second.to_vec()],146+ "split at byte {split}"147+ );148+ }149+150+ let mut bytewise = AnnexBAccessUnitParser::default();151+ let frames: Vec<Vec<u8>> = stream152+ .chunks(1)153+ .flat_map(|chunk| bytewise.push(chunk).unwrap())154+ .collect();155+ assert_eq!(frames, vec![first.to_vec(), second.to_vec()]);156+ }157+158+ #[test]159+ fn scans_large_chunked_access_unit_in_linear_work() {160+ const PAYLOAD_LEN: usize = 1024 * 1024;161+ const CHUNK_LEN: usize = 257;162+163+ let mut first = vec![0, 0, 0, 1, 9, 0x10, 0, 0, 1, 5];164+ first.resize(first.len() + PAYLOAD_LEN, 0x55);165+ let second = [0, 0, 1, 9, 0x10, 0, 0, 1, 1, 4, 5];166+ let third = [0, 0, 0, 1, 9, 0x10];167+ let stream = [first.as_slice(), second.as_slice(), third.as_slice()].concat();168+ let mut parser = AnnexBAccessUnitParser::default();169+ let mut frames = Vec::new();170+171+ for chunk in stream.chunks(CHUNK_LEN) {172+ let before = parser.scanned_candidates;173+ frames.extend(parser.push(chunk).unwrap());174+ let work = parser.scanned_candidates - before;175+ assert!(176+ work <= chunk.len() + 8,177+ "one {chunk_len}-byte append examined {work} candidates",178+ chunk_len = chunk.len()179+ );180+ }181+182+ assert_eq!(frames, vec![first, second.to_vec()]);183+ assert!(184+ parser.scanned_candidates <= stream.len() + 4 * stream.len().div_ceil(CHUNK_LEN),185+ "{} bytes caused {} candidate scans",186+ stream.len(),187+ parser.scanned_candidates188+ );189+ }190+191+ #[test]192+ fn resets_scan_offset_after_emitting_each_access_unit() {193+ let leading_junk = [0x44; 19];194+ let first = [0, 0, 1, 9, 0x10, 0, 0, 1, 5, 1];195+ let second = [0, 0, 0, 1, 9, 0x10, 0, 0, 1, 1, 2];196+ let third = [0, 0, 1, 9, 0x10, 0, 0, 1, 1, 3];197+ let fourth = [0, 0, 0, 1, 9, 0x10];198+ let stream = [199+ leading_junk.as_slice(),200+ first.as_slice(),201+ second.as_slice(),202+ third.as_slice(),203+ fourth.as_slice(),204+ ]205+ .concat();206+ let mut parser = AnnexBAccessUnitParser::default();207+ let frames: Vec<Vec<u8>> = stream208+ .chunks(7)209+ .flat_map(|chunk| parser.push(chunk).unwrap())210+ .collect();211+212+ assert_eq!(213+ frames,214+ vec![first.to_vec(), second.to_vec(), third.to_vec()]215+ );216+ assert!(parser.has_access_unit_start);217+ assert!(parser.next_aud_scan >= 4);218+ }219+220+ #[test]221+ fn oversized_append_is_rejected_without_allocating_or_poisoning_state() {222+ let first = [0, 0, 0, 1, 9, 0x10, 0, 0, 1, 5, 1, 2, 3];223+ let second = [0, 0, 1, 9, 0x10, 0, 0, 1, 1, 4, 5];224+ let mut parser = AnnexBAccessUnitParser::default();225+ assert!(parser.push(&first).unwrap().is_empty());226+ let buffer_len = parser.buffer.len();227+ let scan_offset = parser.next_aud_scan;228+ let scan_work = parser.scanned_candidates;229+230+ let oversized = vec![0x55; MAX_ENCODE_BUFFER];231+ let error = parser.push(&oversized).unwrap_err();232+ assert!(233+ error234+ .to_string()235+ .contains("encoded stream exceeded 16777216 bytes")236+ );237+ assert_eq!(parser.buffer.len(), buffer_len);238+ assert_eq!(parser.next_aud_scan, scan_offset);239+ assert_eq!(parser.scanned_candidates, scan_work);240+241+ assert_eq!(parser.push(&second).unwrap(), vec![first.to_vec()]);242+ }74243 }
M crates/sigil-host/src/source/in_process.rs +14 -0
M crates/sigil-protocol/src/input.rs +36 -2
@@ -4,7 +4,7 @@ use tokio::io::{AsyncRead, AsyncWrite};
44 use crate::framing::{read_json, write_json};55 use crate::{MAX_INPUT_MESSAGE_LEN, ProtocolError, Result};66 7-const MAX_KEY_LEN: usize = 64;7+const MAX_KEY_IDENTIFIER_UTF8_BYTES: usize = 64;88 const MAX_TEXT_LEN: usize = 1_024;99 1010 /// Symmetric per-message relative pointer range. Large accumulated movement
@@ -102,6 +102,8 @@ pub enum InputEvent {
102102 MouseUp { b: u8 },103103 #[serde(rename = "ms")]104104 MouseScroll { dx: i32, dy: i32 },105+ /// Bounded physical key identifier. New clients send the supported106+ /// `KeyboardEvent.code` subset; legacy logical key names remain decodable.105107 #[serde(rename = "kd")]106108 KeyDown { k: String },107109 #[serde(rename = "ku")]
@@ -204,7 +206,7 @@ impl InputEvent {
204206 })205207 }206208 Self::KeyDown { k } | Self::KeyUp { k } | Self::KeyClick { k }207- if k.is_empty() || k.len() > MAX_KEY_LEN =>209+ if k.is_empty() || k.len() > MAX_KEY_IDENTIFIER_UTF8_BYTES =>208210 {209211 Err(ProtocolError::InvalidMessage {210212 message_type: "input event",
@@ -321,6 +323,22 @@ mod tests {
321323 assert_eq!(read_input_event(&mut receiver).await.unwrap(), None);322324 }323325 326+ #[tokio::test]327+ async fn physical_keyboard_down_up_and_click_tokens_round_trip() {328+ for event in [329+ InputEvent::KeyDown { k: "F1".into() },330+ InputEvent::KeyUp {331+ k: "PrintScreen".into(),332+ },333+ InputEvent::KeyClick { k: "KeyQ".into() },334+ ] {335+ let (mut sender, mut receiver) = duplex(256);336+ write_input_event(&mut sender, &event).await.unwrap();337+ sender.shutdown().await.unwrap();338+ assert_eq!(read_input_event(&mut receiver).await.unwrap(), Some(event));339+ }340+ }341+324342 #[tokio::test]325343 async fn full_gamepad_snapshot_round_trips() {326344 let event = InputEvent::Gamepad {
@@ -477,6 +495,22 @@ mod tests {
477495 );478496 assert!(InputEvent::MouseDown { b: 9 }.validate().is_err());479497 assert!(InputEvent::KeyUp { k: String::new() }.validate().is_err());498+ // This exercises the UTF-8 byte bound only; protocol validation does499+ // not imply that arbitrary Unicode is an operational keyboard input.500+ assert!(501+ InputEvent::KeyDown {502+ k: "é".repeat(MAX_KEY_IDENTIFIER_UTF8_BYTES / 2)503+ }504+ .validate()505+ .is_ok()506+ );507+ assert!(508+ InputEvent::KeyUp {509+ k: "é".repeat((MAX_KEY_IDENTIFIER_UTF8_BYTES / 2) + 1)510+ }511+ .validate()512+ .is_err()513+ );480514 assert!(481515 InputEvent::Text {482516 s: "x".repeat(MAX_TEXT_LEN + 1)
M docs/fresh-bazzite-host.md +45 -26
@@ -611,12 +611,12 @@ cd /Users/dami/Developer/sigil-spark
611611 source ~/.cargo/env612612 scripts/package-bazzite-release.sh \613613 --release-tag v0.1.0 \614- --output /tmp/sigil-v0.1.0-bazzite-x86_64.tar.gz614+ --output /tmp/sigil-v0.1.0-linux-glibc2.17-x86_64.tar.gz615615 616616 # Verify the candidate before it crosses the offline signing boundary.617617 scripts/verify-sigil-release.sh \618618 --tag v0.1.0 \619- --archive /tmp/sigil-v0.1.0-bazzite-x86_64.tar.gz \619+ --archive /tmp/sigil-v0.1.0-linux-glibc2.17-x86_64.tar.gz \620620 --source-commit "$(git rev-parse HEAD)" \621621 --candidate622622
@@ -624,19 +624,19 @@ scripts/verify-sigil-release.sh \
624624 # reviewed public key before extraction. See docs/public-release-delivery.md.625625 scripts/verify-sigil-release.sh \626626 --tag v0.1.0 \627- --archive /tmp/sigil-v0.1.0-bazzite-x86_64.tar.gz \627+ --archive /tmp/sigil-v0.1.0-linux-glibc2.17-x86_64.tar.gz \628628 --source-commit "$(git rev-parse HEAD)" \629629 --public-key-file /absolute/path/to/sigil-minisign.pub630-shasum -a 256 -c /tmp/sigil-v0.1.0-bazzite-x86_64.tar.gz.sha256631-scp /tmp/sigil-v0.1.0-bazzite-x86_64.tar.gz tank@umpc:/tmp/630+shasum -a 256 -c /tmp/sigil-v0.1.0-linux-glibc2.17-x86_64.tar.gz.sha256631+scp /tmp/sigil-v0.1.0-linux-glibc2.17-x86_64.tar.gz tank@umpc:/tmp/632632 633633 ssh tank@umpc '634634 set -eu635635 incoming="$HOME/.local/share/sigil-spark/incoming"636636 install -d -m 0700 "$incoming"637637 run_dir="$(mktemp -d "$incoming/package.XXXXXX")"638- tar -tzf /tmp/sigil-v0.1.0-bazzite-x86_64.tar.gz639- tar -xzf /tmp/sigil-v0.1.0-bazzite-x86_64.tar.gz -C "$run_dir"638+ tar -tzf /tmp/sigil-v0.1.0-linux-glibc2.17-x86_64.tar.gz639+ tar -xzf /tmp/sigil-v0.1.0-linux-glibc2.17-x86_64.tar.gz -C "$run_dir"640640 cd "$run_dir/payload"641641 sha256sum -c PACKAGE-SHA256SUMS642642 bash -n stage-this-release.sh
@@ -1205,21 +1205,36 @@ acknowledged only after exact property readback. CQP remains on the external
12051205 compatibility backend until its in-process force-keyframe path is implemented12061206 and proven separately.12071207 1208-The configured `:0` is a bootstrap connection, not a fixed input target.1209-Gamescope may move mouse focus between its `:0` and `:1` Xwayland servers.1210-Sigil reads `GAMESCOPE_MOUSE_FOCUS_DISPLAY` from the bootstrap root, reconnects1211-to the active local display, and samples `QueryPointer` at no more than 60 Hz.1212-It also reads `GAMESCOPE_CURSOR_VISIBLE_FEEDBACK` from that active root so the1213-client overlay disappears when Gamescope hides its cursor. Missing, malformed,1214-or unreachable Xwayland state during startup disables the separately1215-negotiated pointer feedback capability. After successful startup, losing1216-Gamescope publishes pointer feedback as unavailable, then reconnects the1208+The configured `:0` is the bootstrap connection. Gamescope builds that publish1209+`GAMESCOPE_MOUSE_FOCUS_DISPLAY` may move mouse focus between their `:0` and1210+`:1` Xwayland servers; Sigil follows that property and reconnects to the active1211+local display. Upstream SteamOS and older Gamescope builds need not ship this1212+extension. Sigil detects that absence without creating the atom, logs the1213+degraded discovery mode, and safely samples only the explicitly configured1214+display instead of disabling pointer feedback.1215+1216+Sigil samples `QueryPointer` at no more than 60 Hz. If the active Xwayland root1217+has a different aspect ratio than the native captured surface, Sigil maps it1218+into a centered aspect-fit region (letterbox or pillarbox) so coordinates stay1219+in the compositor's native pointer space without independent-axis stretching.1220+Zero-sized, unrepresentable, off-root, or cross-screen geometry still fails1221+closed.1222+1223+Gamescope builds that publish `GAMESCOPE_CURSOR_VISIBLE_FEEDBACK` provide the1224+compositor's visibility sample. Authoritative pointer motion deliberately keeps1225+the client cursor visible for a bounded three seconds even if that sample is1226+temporarily false, recovering Gamescope's virtual cursor after remote input.1227+When the optional extension is absent or malformed, Sigil logs the fallback and1228+uses that same bounded motion window without inventing longer-lived visibility.1229+Missing or unreachable Xwayland state during startup still disables the1230+separately negotiated pointer feedback capability. After successful startup,1231+losing Gamescope publishes pointer feedback as unavailable, then reconnects the12171232 bootstrap display with a bounded 100 ms to 2 second backoff. A complete1218-reconnect re-interns both Gamescope atoms, discovers the replacement native1219-surface, and validates one active-display sample before publishing coordinates1220-again. Relative uinput remains available without guessed cursor coordinates.1221-The service `DISPLAY=:0` line is retained as an explicit fallback for1222-configurations created before `xwayland_display` was added.1233+reconnect re-detects both optional Gamescope properties, validates one1234+active-display sample, and only then publishes coordinates again. Relative1235+uinput remains available without guessed cursor coordinates. The service1236+`DISPLAY=:0` line is retained as an explicit fallback for configurations1237+created before `xwayland_display` was added.12231238 12241239 Audio is optional and must resolve one exact PipeWire sink, never a microphone.12251240 The appliance owns a persistent 48 kHz stereo null sink so capture does not
@@ -1318,11 +1333,15 @@ separate `gamepad` input capability and rejects gamepad snapshots unless that
13181333 capability was negotiated.13191334 13201335 It releases every held keyboard/mouse transition and sends a fully neutral1321-gamepad snapshot when an input session ends. The client currently sends a Text1322-event in addition to physical printable-key events; the host explicitly treats1323-Text as a content-free no-op, acknowledges it when ACKs were negotiated, and1324-does not advertise Text support. No key, text, or gamepad payload is logged.1325-The Linux button/axis assignments follow the kernel's1336+gamepad snapshot when an input session ends. Portal sends a bounded subset of1337+W3C `KeyboardEvent.code` physical-position tokens for keyboard input. This1338+keeps game and Steam bindings independent of the client's active keyboard1339+layout. The current uinput backend advertises keyboard input but does not1340+advertise Text; Unicode text and IME composition are therefore unsupported.1341+For protocol compatibility, any legacy Text event received by Sigil remains a1342+content-free no-op and is acknowledged when ACKs were negotiated. No key,1343+text, or gamepad payload is logged. The Linux button/axis assignments follow1344+the kernel's13261345 [gamepad protocol](https://docs.kernel.org/input/gamepad.html) and Xbox `xpad`13271346 layout.13281347
A docs/portal-platform-support.md +65 -0
@@ -0,0 +1,65 @@
1+# Portal platform and relative-pointer support2+3+Portal's first supported download is macOS arm64. Its relative-pointer path4+uses CoreGraphics cursor disassociation and has live Mac-to-Gamescope hardware5+evidence. Linux, Windows, and other Portal builds are not release targets yet.6+7+## Build policy8+9+| Client build | Relative pointer | Other negotiated input |10+| --- | --- | --- |11+| macOS | Enabled through the native CoreGraphics path | Absolute pointer, keyboard/text, and gamepad remain available when granted |12+| Non-macOS, default features | Not offered; any unexpected relative-pointer or pointer-feedback response is masked locally | Absolute pointer, keyboard/text, gamepad, and input acknowledgements remain available when granted |13+| Non-macOS with `experimental-non-macos-pointer-capture` | Enabled through Tao cursor grab plus browser Pointer Lock | Same independently granted input capabilities |14+15+The experimental flag makes the non-macOS code available for testing; it does16+not make that platform supported and must not appear in a published build.17+Compile both policies with:18+19+```bash20+cargo check --locked -p portal --all-targets21+cargo check --locked -p portal --all-targets \22+ --features experimental-non-macos-pointer-capture23+```24+25+The complete repository gate runs the default test suite and separately26+compiles the experimental branch. On Linux CI this proves both non-macOS27+configurations compile. Portal's macOS release job continues to build without28+feature flags.29+30+## Non-macOS acceptance checklist31+32+Complete this checklist independently for each OS/webview combination before33+proposing default or release support. Preserve the exact Portal commit,34+operating-system version, webview version, and window-system/session type with35+the evidence.36+37+1. Build and launch Portal without the experimental feature. Connect to a host38+ offering every input capability and confirm diagnostics do not report39+ relative pointer or pointer-position feedback. Confirm absolute motion and40+ buttons, keyboard/text, and a physical gamepad still work when their grants41+ are present.42+2. Build the exact same commit with43+ `experimental-non-macos-pointer-capture`. Confirm the embedded webview44+ implements `requestPointerLock`, reports ownership changes, and supplies45+ bounded `movementX`/`movementY` deltas.46+3. Against the UMPC Gamescope session, enter and exit control at least ten47+ times. Exercise motion, left/right click, both scroll axes, keyboard, and a48+ physical controller in an actual game. Confirm the remote pointer maps to49+ the native host surface rather than the encoded size.50+4. While control is active, test window focus loss/regain, Pointer Lock loss,51+ disconnect, reconnect, and application exit. Portal must visibly leave52+ control when ownership is lost, restore the local cursor, bound release53+ retries, and leave no held host input.54+5. Repeat under the platform's materially different window systems or55+ webviews. Linux requires separate Wayland/WebKitGTK and X11/WebKitGTK56+ evidence when both are claimed; Windows requires the shipped WebView257+ runtime. Record unavailable APIs as an explicit unsupported result rather58+ than bypassing the gate.59+6. Run `./scripts/verify-demo-build.sh` and retain the exact Portal-to-UMPC60+ session evidence required by `AGENTS.md`. A config check, unit test, or61+ compile-only result is not hardware acceptance.62+63+Removing the feature gate requires reviewed evidence for every platform being64+enabled and an explicit release-policy change. Do not infer support from Tao65+or webview API availability alone.
M docs/portal-release.md +7 -0
M docs/public-alpha-uat.md +2 -2
M docs/public-release-delivery.md +18 -7
@@ -9,12 +9,18 @@ Neither path may fall back to an unsigned development build.
99 The first public release is a draft or prerelease until capability-ticket1010 onboarding and the release-candidate hardware pass are complete.1111 12-Sigil publishes exactly these x86_64 Bazzite assets for a tag such as12+Sigil publishes exactly these Linux glibc 2.17 x86_64 assets for a tag such as1313 `v0.3.0-alpha.1`:1414 15-- `sigil-v0.3.0-alpha.1-bazzite-x86_64.tar.gz`16-- `sigil-v0.3.0-alpha.1-bazzite-x86_64.tar.gz.sha256`17-- `sigil-v0.3.0-alpha.1-bazzite-x86_64.tar.gz.minisig`15+- `sigil-v0.3.0-alpha.1-linux-glibc2.17-x86_64.tar.gz`16+- `sigil-v0.3.0-alpha.1-linux-glibc2.17-x86_64.tar.gz.sha256`17+- `sigil-v0.3.0-alpha.1-linux-glibc2.17-x86_64.tar.gz.minisig`18+19+The Sigil asset suffix names the binary target contract, not a support claim.20+The public bootstrap and package-owned activation path remain restricted to the21+Bazzite appliance until SteamOS or another host environment passes its own22+packaging, capability, cold-boot, and hardware acceptance gates. The canonical23+suffix is pinned in `release/sigil-target-contract.txt`.1824 1925 Portal preserves the package builder's versioned macOS contract:2026
@@ -77,8 +83,8 @@ environment builds clean tagged source, runs the complete repository gate,
7783 creates exactly these unsigned draft assets, and retains the same bytes as a7884 short-lived Actions artifact:7985 80-- `sigil-$tag-bazzite-x86_64.tar.gz`81-- `sigil-$tag-bazzite-x86_64.tar.gz.sha256`86+- `sigil-$tag-linux-glibc2.17-x86_64.tar.gz`87+- `sigil-$tag-linux-glibc2.17-x86_64.tar.gz.sha256`8288 8389 Move those bytes to the offline publisher machine. After independently8490 confirming the tag commit and reviewed public key, sign without exporting the
@@ -87,7 +93,7 @@ secret key or its passphrase:
8793 ```bash8894 scripts/sign-bazzite-release.sh \8995 --tag "$tag" \90- --archive "/absolute/offline/path/sigil-$tag-bazzite-x86_64.tar.gz" \96+ --archive "/absolute/offline/path/sigil-$tag-linux-glibc2.17-x86_64.tar.gz" \9197 --source-commit "$tag_commit" \9298 --minisign-key /absolute/offline/path/sigil-release.key \9399 --public-key-file /absolute/offline/path/sigil-minisign.pub
@@ -118,6 +124,11 @@ development flags are an inseparable escape hatch and cannot claim a release
118124 tag. `scripts/verify-sigil-release.sh` is the common candidate, offline, and119125 promotion verifier.120126 127+No public goq release exists under the former distro-named suffix. Unpublished128+drafts or locally staged candidates using `bazzite-x86_64` must be discarded129+and rebuilt; release verification intentionally provides no compatibility130+alias that could make two names identify the same public bytes.131+121132 ## Public Bazzite acceptance record122133 123134 Repository tests exercise a complete package fixture through fresh install,
M portal/control-runtime.test.mjs +23 -0
M portal/enrollment-ui.test.mjs +7 -0
M portal/index.html +1 -0
M portal/input-runtime.test.mjs +3 -1
M portal/input-state.test.mjs +15 -0
M portal/keyboard-map.mjs +112 -12
@@ -1,14 +1,114 @@
1+// This bounded subset of W3C KeyboardEvent.code values is the Portal/Sigil2+// physical-key contract. Codes identify key position rather than the active3+// client layout, which is what games, Steam, and Linux evdev consume.4+const PHYSICAL_KEY_CODES = new Set([5+ ...Array.from({ length: 26 }, (_, index) => `Key${String.fromCharCode(65 + index)}`),6+ ...Array.from({ length: 10 }, (_, index) => `Digit${index}`),7+ 'Minus',8+ 'Equal',9+ 'BracketLeft',10+ 'BracketRight',11+ 'Backslash',12+ 'IntlBackslash',13+ 'IntlRo',14+ 'IntlYen',15+ 'Semicolon',16+ 'Quote',17+ 'Backquote',18+ 'Comma',19+ 'Period',20+ 'Slash',21+ 'Enter',22+ 'Tab',23+ 'Space',24+ 'Backspace',25+ 'Escape',26+ 'ShiftLeft',27+ 'ShiftRight',28+ 'ControlLeft',29+ 'ControlRight',30+ 'AltLeft',31+ 'AltRight',32+ 'MetaLeft',33+ 'MetaRight',34+ 'ArrowUp',35+ 'ArrowDown',36+ 'ArrowLeft',37+ 'ArrowRight',38+ 'Home',39+ 'End',40+ 'PageUp',41+ 'PageDown',42+ 'Insert',43+ 'Delete',44+ 'PrintScreen',45+ ...Array.from({ length: 12 }, (_, index) => `F${index + 1}`),46+]);47+48+const LEGACY_NAMED_KEYS = Object.freeze({49+ ArrowUp: 'Up',50+ ArrowDown: 'Down',51+ ArrowLeft: 'Left',52+ ArrowRight: 'Right',53+ ' ': 'Space',54+ Delete: 'Delete',55+ Backspace: 'Backspace',56+ Enter: 'Enter',57+ Tab: 'Tab',58+ Escape: 'Escape',59+ Shift: 'Shift',60+ Control: 'Control',61+ Alt: 'Alt',62+ Meta: 'Meta',63+ Home: 'Home',64+ End: 'End',65+ PageUp: 'PageUp',66+ PageDown: 'PageDown',67+ Insert: 'Insert',68+ PrintScreen: 'PrintScreen',69+});70+71+function isAsciiPrintable(value) {72+ return value.length === 173+ && value.codePointAt(0) >= 0x2074+ && value.codePointAt(0) <= 0x7e;75+}76+77+function textValue(event) {78+ if (event.ctrlKey || event.altKey || event.metaKey || event.isComposing) return null;79+ if (typeof event.key !== 'string') return null;80+ const codePoints = Array.from(event.key);81+ if (codePoints.length !== 1) return null;82+ const codePoint = codePoints[0].codePointAt(0);83+ if (codePoint <= 0x1f84+ || (codePoint >= 0x7f && codePoint <= 0x9f)85+ || (codePoint >= 0xd800 && codePoint <= 0xdfff)) return null;86+ return event.key;87+}88+189 export function mapKey(event) {2- const key = event.key;3- const map = {4- 'ArrowUp': 'Up', 'ArrowDown': 'Down', 'ArrowLeft': 'Left', 'ArrowRight': 'Right',5- ' ': 'Space', 'Delete': 'Delete', 'Backspace': 'Backspace',6- 'Enter': 'Enter', 'Tab': 'Tab', 'Escape': 'Escape',7- 'Shift': 'Shift', 'Control': 'Control', 'Alt': 'Alt', 'Meta': 'Meta',8- 'Home': 'Home', 'End': 'End', 'PageUp': 'PageUp', 'PageDown': 'PageDown',9- };10- const asciiPrintable = key.length === 111- && key.codePointAt(0) >= 0x2012- && key.codePointAt(0) <= 0x7e;13- return map[key] || (asciiPrintable ? key : null);90+ if (!event || typeof event !== 'object') return null;91+ if (typeof event.code === 'string' && event.code.length > 0) {92+ return PHYSICAL_KEY_CODES.has(event.code) ? event.code : null;93+ }94+ if (typeof event.key !== 'string') return null;95+ if (/^F(?:[1-9]|1[0-2])$/.test(event.key)) return event.key;96+ return LEGACY_NAMED_KEYS[event.key]97+ ?? (isAsciiPrintable(event.key) ? event.key : null);98+}99+100+// Resolve each keydown to exactly one protocol class. Physical keyboard input101+// wins whenever it is available. Text is considered only if the host separately102+// advertises an operational Text capability, and is never emitted alongside a103+// key transition.104+export function keyboardInputForEvent(event, capabilities = {}) {105+ if (capabilities.keyboard === true) {106+ const key = mapKey(event);107+ if (key !== null) return { type: 'key', key };108+ }109+ if (capabilities.text === true) {110+ const text = textValue(event);111+ if (text !== null) return { type: 'text', text };112+ }113+ return null;14114 }
M portal/keyboard-map.test.mjs +105 -22
@@ -1,38 +1,121 @@
11 import assert from 'node:assert/strict';22 import test from 'node:test';33 4-import { mapKey } from './keyboard-map.mjs';4+import {5+ keyboardInputForEvent,6+ mapKey,7+} from './keyboard-map.mjs';58 6-test('maps the current named keyboard controls exactly', () => {9+test('forwards the bounded physical code contract including extended keys', () => {10+ for (const code of [11+ 'KeyA',12+ 'Digit0',13+ 'Minus',14+ 'BracketLeft',15+ 'IntlBackslash',16+ 'IntlRo',17+ 'IntlYen',18+ 'ShiftLeft',19+ 'ShiftRight',20+ 'ControlLeft',21+ 'ControlRight',22+ 'AltLeft',23+ 'AltRight',24+ 'MetaLeft',25+ 'MetaRight',26+ 'ArrowUp',27+ 'Insert',28+ 'PrintScreen',29+ ...Array.from({ length: 12 }, (_, index) => `F${index + 1}`),30+ ]) {31+ assert.equal(mapKey({ code, key: 'layout-dependent' }), code, code);32+ }33+});34+35+test('physical codes preserve client key positions across keyboard layouts', () => {36+ assert.equal(mapKey({ code: 'KeyE', key: 'é' }), 'KeyE');37+ assert.equal(mapKey({ code: 'Digit2', key: 'é' }), 'Digit2');38+ assert.equal(mapKey({ code: 'KeyQ', key: 'a' }), 'KeyQ');39+ assert.equal(mapKey({ code: 'AltRight', key: 'AltGraph' }), 'AltRight');40+});41+42+test('legacy missing-code events retain the compatible named and ASCII map', () => {743 for (const [key, expected] of [844 ['ArrowUp', 'Up'],9- ['ArrowDown', 'Down'],10- ['ArrowLeft', 'Left'],11- ['ArrowRight', 'Right'],1245 [' ', 'Space'],13- ['Delete', 'Delete'],14- ['Backspace', 'Backspace'],15- ['Enter', 'Enter'],16- ['Tab', 'Tab'],17- ['Escape', 'Escape'],18- ['Shift', 'Shift'],1946 ['Control', 'Control'],20- ['Alt', 'Alt'],21- ['Meta', 'Meta'],22- ['Home', 'Home'],23- ['End', 'End'],24- ['PageUp', 'PageUp'],2547 ['PageDown', 'PageDown'],48+ ['Insert', 'Insert'],49+ ['PrintScreen', 'PrintScreen'],50+ ['F1', 'F1'],51+ ['F12', 'F12'],52+ ['!', '!'],53+ ['a', 'a'],2654 ]) {27- assert.equal(mapKey({ key }), expected, key);55+ assert.equal(mapKey({ code: '', key }), expected, key);2856 }2957 });3058 31-test('preserves only single ASCII-printable keys', () => {32- for (const key of ['!', 'a', 'A', '0', '~']) {33- assert.equal(mapKey({ key }), key, key);59+test('rejects unsupported physical and logical keys without guessing', () => {60+ for (const event of [61+ { code: 'F13', key: 'F13' },62+ { code: 'CapsLock', key: 'CapsLock' },63+ { code: 'Numpad1', key: '1' },64+ { code: 'AudioVolumeUp', key: 'AudioVolumeUp' },65+ { code: '', key: 'é' },66+ { code: '', key: '😀' },67+ { code: '', key: '' },68+ null,69+ ]) {70+ assert.equal(mapKey(event), null);3471 }35- for (const key of ['F1', 'F12', 'é', '😀', 'CapsLock', 'Insert', '']) {36- assert.equal(mapKey({ key }), null, key);72+});73+74+test('production keyboard capability forwards a physical code without Text', () => {75+ assert.deepEqual(76+ keyboardInputForEvent(77+ { code: 'KeyE', key: 'é' },78+ { keyboard: true, text: false },79+ ),80+ { type: 'key', key: 'KeyE' },81+ );82+});83+84+test('hypothetical operational Text capability remains exclusive with keyboard', () => {85+ const both = { keyboard: true, text: true };86+ assert.deepEqual(87+ keyboardInputForEvent({ code: 'F5', key: 'F5' }, both),88+ { type: 'key', key: 'F5' },89+ );90+ assert.deepEqual(91+ keyboardInputForEvent({ code: 'Unknown', key: 'é' }, both),92+ { type: 'text', text: 'é' },93+ );94+});95+96+test('hypothetical Text-only capability accepts one scalar and rejects unsafe input', () => {97+ const textOnly = { keyboard: false, text: true };98+ assert.deepEqual(99+ keyboardInputForEvent({ code: '', key: 'é' }, textOnly),100+ { type: 'text', text: 'é' },101+ );102+ assert.deepEqual(103+ keyboardInputForEvent({ code: '', key: '😀' }, textOnly),104+ { type: 'text', text: '😀' },105+ );106+ for (const event of [107+ { code: '', key: 'a', ctrlKey: true },108+ { code: '', key: 'a', altKey: true },109+ { code: '', key: 'a', metaKey: true },110+ { code: '', key: 'a', isComposing: true },111+ { code: '', key: 'Enter' },112+ { code: '', key: '\u007f' },113+ { code: '', key: '\ud800' },114+ ]) {115+ assert.equal(keyboardInputForEvent(event, textOnly), null);37116 }117+ assert.equal(118+ keyboardInputForEvent({ code: 'KeyA', key: 'a' }, {}),119+ null,120+ );38121 });
M portal/main.js +19 -17
@@ -40,7 +40,7 @@ import {
4040 normalizeInvitationSummary,4141 shortPeerFingerprint,4242 } from './enrollment.mjs';43-import { mapKey } from './keyboard-map.mjs';43+import { keyboardInputForEvent } from './keyboard-map.mjs';4444 4545 let enrollmentReady = false;4646 let pendingInvitationSummary = null;
@@ -261,6 +261,12 @@ async function cancelInvitation() {
261261 async function checkDevelopmentConnectionMode() {262262 const mode = await invoke('development_connection_mode');263263 connectionState.setDevelopmentMode(mode.enabled);264+ if (mode.force_jpeg) {265+ const jpegBadge = document.getElementById('dev-jpeg-badge');266+ jpegBadge.classList.remove('hidden');267+ jpegBadge.title = mode.jpeg_warning;268+ console.warn('[development JPEG compatibility mode]', mode.jpeg_warning);269+ }264270 if (!mode.enabled) return;265271 266272 const badge = document.getElementById('dev-connect-badge');
@@ -977,25 +983,21 @@ window.addEventListener('keydown', (e) => {
977983 controlRuntime.exit();978984 return;979985 }980- const printableText = e.key.length === 1 && !e.ctrlKey && !e.altKey && !e.metaKey;981- if (printableText && connectionState.inputCapabilities.text) {982- e.preventDefault();983- inputRuntime.send({ t: 'tx', s: e.key });986+ const input = keyboardInputForEvent(e, connectionState.inputCapabilities);987+ if (input === null) return;988+ e.preventDefault();989+ if (input.type === 'text') {990+ inputRuntime.send({ t: 'tx', s: input.text });984991 return;985992 }986- if (!connectionState.inputCapabilities.keyboard) return;987- const k = mapKey(e);988- if (k) {989- e.preventDefault();990- const keyId = e.code || e.key;991- const tracking = inputRuntime.trackKey(keyId, k);992- if (tracking === 'repeat') return;993- if (tracking === 'full') {994- console.error('held key limit reached; refusing additional key transition');995- return;996- }997- if (!inputRuntime.send({ t: 'kd', k })) inputRuntime.takeKeyRelease(keyId);993+ const keyId = e.code || e.key;994+ const tracking = inputRuntime.trackKey(keyId, input.key);995+ if (tracking === 'repeat') return;996+ if (tracking === 'full') {997+ console.error('held key limit reached; refusing additional key transition');998+ return;998999 }1000+ if (!inputRuntime.send({ t: 'kd', k: input.key })) inputRuntime.takeKeyRelease(keyId);9991001 });10001002 10011003 window.addEventListener('keyup', (e) => {
M portal/stream-runtime.test.mjs +27 -0
M portal/video-capability.mjs +8 -2
M portal/video-capability.test.mjs +33 -1
M portal/video-pipeline.mjs +118 -16
@@ -23,6 +23,7 @@ import {
2323 export const MAX_DECODE_QUEUE_SIZE = 2;2424 const MAX_DECODE_TIMINGS = 8;2525 const PRESENTER_QUEUE_CAPACITY = 2;26+const JPEG_PRESENTER_QUEUE_CAPACITY = 2;2627 2728 export function createVideoPipelineSession({2829 hasWebCodecs,
@@ -40,6 +41,7 @@ export function createVideoPipelineSession({
4041 createObjectUrl = (blob) => URL.createObjectURL(blob),4142 revokeObjectUrl = (url) => URL.revokeObjectURL(url),4243 createImage = () => new Image(),44+ abortImage = (image) => image.removeAttribute('src'),4345 requestFrame = (callback) => requestAnimationFrame(callback),4446 cancelFrame = (handle) => cancelAnimationFrame(handle),4547 setTimer = (callback, delayMs) => setTimeout(callback, delayMs),
@@ -58,6 +60,9 @@ export function createVideoPipelineSession({
5860 let presentedFrames = 0;5961 let presentationDroppedFrames = 0;6062 let lastVideoDrawCompletedAtMs = null;63+ let jpegFrameSequence = 0;64+ let activeJpegDecode = null;65+ let queuedJpegFrame = null;6166 6267 const frontendDeliveryRate = new RollingRateWindow();6368 const decoderInputRate = new RollingRateWindow();
@@ -113,8 +118,107 @@ export function createVideoPipelineSession({
113118 resetAudioSync(...args);114119 }115120 121+ function releaseActiveJpegDecode(frame, { abort = false } = {}) {122+ if (!frame || frame.released) return;123+ frame.released = true;124+ frame.image.onload = null;125+ frame.image.onerror = null;126+ if (abort) {127+ try {128+ abortImage(frame.image);129+ } catch (error) {130+ console.warn('could not abort stale JPEG decode:', error);131+ }132+ }133+ revokeObjectUrl(frame.url);134+ }135+136+ function startQueuedJpegDecode() {137+ if (activeJpegDecode || !queuedJpegFrame) return;138+ const pending = queuedJpegFrame;139+ queuedJpegFrame = null;140+141+ let url = null;142+ let image;143+ try {144+ url = createObjectUrl(pending.blob);145+ image = createImage();146+ } catch (error) {147+ if (url !== null) revokeObjectUrl(url);148+ droppedFrames++;149+ console.warn('could not start JPEG decode:', error);150+ startQueuedJpegDecode();151+ return;152+ }153+154+ const frame = {155+ ...pending,156+ image,157+ url,158+ released: false,159+ superseded: false,160+ };161+ activeJpegDecode = frame;162+ image.onload = () => {163+ if (activeJpegDecode !== frame) return;164+ activeJpegDecode = null;165+ try {166+ if (!frame.superseded && queuedJpegFrame === null) {167+ const drawStartedAt = now();168+ context.drawImage(image, 0, 0, canvas.width, canvas.height);169+ const presentedAt = now();170+ presentedFrames++;171+ presentationRate.record(presentedAt);172+ presentationCadence.record(presentedAt);173+ drawLatency.record(presentedAt - drawStartedAt, presentedAt);174+ clientPresentationLatency.record(175+ presentedAt - frame.receivedAt,176+ presentedAt,177+ );178+ }179+ } finally {180+ releaseActiveJpegDecode(frame);181+ startQueuedJpegDecode();182+ }183+ };184+ image.onerror = () => {185+ if (activeJpegDecode !== frame) return;186+ activeJpegDecode = null;187+ if (!frame.superseded && queuedJpegFrame === null) droppedFrames++;188+ releaseActiveJpegDecode(frame);189+ startQueuedJpegDecode();190+ };191+ try {192+ image.src = url;193+ } catch (error) {194+ if (activeJpegDecode === frame) activeJpegDecode = null;195+ droppedFrames++;196+ releaseActiveJpegDecode(frame, { abort: true });197+ console.warn('could not assign JPEG decode source:', error);198+ startQueuedJpegDecode();199+ }200+ }201+202+ function enqueueJpegFrame(frame) {203+ if (!activeJpegDecode) {204+ queuedJpegFrame = frame;205+ startQueuedJpegDecode();206+ return;207+ }208+ if (!activeJpegDecode.superseded) {209+ activeJpegDecode.superseded = true;210+ presentationDroppedFrames++;211+ }212+ if (queuedJpegFrame) presentationDroppedFrames++;213+ queuedJpegFrame = frame;214+ }215+116216 function teardown() {117217 resetAvSync();218+ queuedJpegFrame = null;219+ const active = activeJpegDecode;220+ activeJpegDecode = null;221+ releaseActiveJpegDecode(active, { abort: true });118222 framePresenter.clear();119223 decodeTimings.clear();120224 if (videoDecoder) {
@@ -384,22 +488,16 @@ export function createVideoPipelineSession({
384488 epoch: activeVideoFormatEpoch,385489 });386490 }491+ // Keep exactly one browser decode active and one coalesced latest payload.492+ // Receipt order is authoritative: once a newer payload is queued, the493+ // active decode may finish to release its resources but can never draw.387494 const bytes = decodeBase64(data);388495 const blob = createBlob([bytes], { type: 'image/jpeg' });389- const url = createObjectUrl(blob);390- const image = createImage();391- image.onload = () => {392- const drawStartedAt = now();393- context.drawImage(image, 0, 0, canvas.width, canvas.height);394- revokeObjectUrl(url);395- const presentedAt = now();396- presentedFrames++;397- presentationRate.record(presentedAt);398- presentationCadence.record(presentedAt);399- drawLatency.record(presentedAt - drawStartedAt, presentedAt);400- clientPresentationLatency.record(presentedAt - receivedAt, presentedAt);401- };402- image.src = url;496+ enqueueJpegFrame({497+ sequence: ++jpegFrameSequence,498+ blob,499+ receivedAt,500+ });403501 }404502 }405503
@@ -429,8 +527,12 @@ export function createVideoPipelineSession({
429527 avSkewPercentiles: avSkew.summary(at),430528 decoderQueueDepth: videoDecoder?.decodeQueueSize ?? 0,431529 decoderQueueCapacity: MAX_DECODE_QUEUE_SIZE,432- presenterQueueDepth: framePresenter.depth,433- presenterQueueCapacity: PRESENTER_QUEUE_CAPACITY,530+ presenterQueueDepth: hasWebCodecs531+ ? framePresenter.depth532+ : Number(activeJpegDecode !== null) + Number(queuedJpegFrame !== null),533+ presenterQueueCapacity: hasWebCodecs534+ ? PRESENTER_QUEUE_CAPACITY535+ : JPEG_PRESENTER_QUEUE_CAPACITY,434536 recovering: decoderRecovery.recovering,435537 };436538 }
M portal/video-pipeline.test.mjs +191 -4
@@ -28,6 +28,9 @@ function harness({ hasWebCodecs = true } = {}) {
2828 const draws = [];2929 const revokedUrls = [];3030 const images = [];31+ const imageStarts = [];32+ const abortedImages = [];33+ let nextObjectUrl = 0;3134 const canvas = { width: 0, height: 0 };3235 const context = {3336 drawImage: (...args) => draws.push(args),
@@ -65,13 +68,26 @@ function harness({ hasWebCodecs = true } = {}) {
6568 },6669 decodeBase64: () => new Uint8Array([1, 2, 3]),6770 createBlob: (parts, options) => ({ parts, options }),68- createObjectUrl: () => 'blob:test-frame',71+ createObjectUrl: () => `blob:test-frame-${++nextObjectUrl}`,6972 revokeObjectUrl: (url) => revokedUrls.push(url),7073 createImage: () => {71- const image = { onload: null, src: null };74+ let src = null;75+ const image = {76+ onload: null,77+ onerror: null,78+ get src() { return src; },79+ set src(value) {80+ src = value;81+ imageStarts.push({ image: this, value });82+ },83+ };7284 images.push(image);7385 return image;7486 },87+ abortImage: (image) => {88+ abortedImages.push(image);89+ image.src = null;90+ },7591 requestFrame: () => 1,7692 cancelFrame: () => {},7793 setTimer: () => 2,
@@ -89,6 +105,8 @@ function harness({ hasWebCodecs = true } = {}) {
89105 draws,90106 revokedUrls,91107 images,108+ imageStarts,109+ abortedImages,92110 advance(milliseconds = 1) { clock += milliseconds; },93111 };94112 }
@@ -135,6 +153,8 @@ test('configured H.264 keyframes commit format only after decode enqueue', () =>
135153 assert.equal(stats.droppedFrames, 0);136154 assert.equal(stats.recovering, false);137155 assert.equal(stats.decoderQueueCapacity, MAX_DECODE_QUEUE_SIZE);156+ assert.equal(stats.presenterQueueDepth, 0);157+ assert.equal(stats.presenterQueueCapacity, 2);138158 });139159 140160 test('unsupported codecs are dropped before decoder construction', () => {
@@ -218,15 +238,182 @@ test('JPEG fallback keeps its asynchronous base64 draw behavior', () => {
218238 assert.deepEqual(subject.formats, [subject.pipeline.format]);219239 assert.equal(subject.canvas.width, 640);220240 assert.equal(subject.canvas.height, 400);221- assert.equal(subject.images[0].src, 'blob:test-frame');241+ assert.equal(subject.images[0].src, 'blob:test-frame-1');222242 assert.deepEqual(subject.draws, []);223243 assert.deepEqual(subject.revokedUrls, []);244+ assert.equal(subject.pipeline.snapshot().presenterQueueDepth, 1);245+ assert.equal(subject.pipeline.snapshot().presenterQueueCapacity, 2);224246 225247 subject.advance();226248 subject.images[0].onload();227249 assert.equal(subject.draws.length, 1);228- assert.deepEqual(subject.revokedUrls, ['blob:test-frame']);250+ assert.deepEqual(subject.revokedUrls, ['blob:test-frame-1']);229251 assert.equal(subject.pipeline.snapshot().presentedFrames, 1);252+ assert.equal(subject.pipeline.snapshot().presenterQueueDepth, 0);253+ assert.equal(subject.pipeline.snapshot().presenterQueueCapacity, 2);254+});255+256+test('JPEG fallback suppresses an active stale decode before starting the newest frame', () => {257+ const subject = harness({ hasWebCodecs: false });258+ const payload = {259+ width: 640,260+ height: 400,261+ data: 'AQID',262+ keyframe: true,263+ };264+ subject.pipeline.processFramePayload(payload);265+ const staleLoad = subject.images[0].onload;266+ const staleError = subject.images[0].onerror;267+268+ subject.advance(10);269+ subject.pipeline.processFramePayload(payload);270+ assert.equal(subject.images.length, 1);271+ assert.deepEqual(subject.revokedUrls, []);272+ const afterOverwrite = subject.pipeline.snapshot();273+ assert.equal(afterOverwrite.presentationDroppedFrames, 1);274+ assert.equal(afterOverwrite.presenterQueueDepth, 2);275+ assert.equal(afterOverwrite.presenterQueueCapacity, 2);276+277+ subject.advance(5);278+ staleLoad();279+ assert.deepEqual(subject.draws, []);280+ assert.deepEqual(subject.revokedUrls, ['blob:test-frame-1']);281+ assert.equal(subject.images.length, 2);282+ assert.equal(subject.images[1].src, 'blob:test-frame-2');283+ const newestLoad = subject.images[1].onload;284+ assert.equal(subject.pipeline.snapshot().presenterQueueDepth, 1);285+286+ subject.advance(5);287+ newestLoad();288+ staleError(new Error('stale JPEG failure'));289+ const afterNewest = subject.pipeline.snapshot();290+ assert.deepEqual(subject.draws.map(([image]) => image), [subject.images[1]]);291+ assert.equal(afterNewest.presentedFrames, 1);292+ assert.equal(afterNewest.presentPercentiles.count, 1);293+ assert.equal(afterNewest.presentPercentiles.p50, 10);294+ assert.equal(afterNewest.presentationDroppedFrames, 1);295+ assert.equal(afterNewest.droppedFrames, 0);296+ assert.equal(afterNewest.presenterQueueDepth, 0);297+ assert.equal(afterNewest.presenterQueueCapacity, 2);298+ assert.deepEqual(subject.revokedUrls, [299+ 'blob:test-frame-1',300+ 'blob:test-frame-2',301+ ]);302+});303+304+test('JPEG fallback error callbacks affect only the current pending frame', () => {305+ const subject = harness({ hasWebCodecs: false });306+ const payload = {307+ width: 640,308+ height: 400,309+ data: 'AQID',310+ keyframe: true,311+ };312+ subject.pipeline.processFramePayload(payload);313+ const staleError = subject.images[0].onerror;314+315+ subject.pipeline.processFramePayload(payload);316+ staleError(new Error('stale JPEG failure'));317+ assert.equal(subject.pipeline.snapshot().droppedFrames, 0);318+ assert.equal(subject.images.length, 2);319+ const failedLoad = subject.images[1].onload;320+ const currentError = subject.images[1].onerror;321+322+ currentError(new Error('current JPEG failure'));323+ failedLoad();324+ const afterFailure = subject.pipeline.snapshot();325+ assert.equal(afterFailure.droppedFrames, 1);326+ assert.equal(afterFailure.presentedFrames, 0);327+ assert.equal(afterFailure.presentationDroppedFrames, 1);328+ assert.equal(afterFailure.presenterQueueDepth, 0);329+ assert.equal(afterFailure.presenterQueueCapacity, 2);330+ assert.deepEqual(subject.draws, []);331+ assert.deepEqual(subject.revokedUrls, [332+ 'blob:test-frame-1',333+ 'blob:test-frame-2',334+ ]);335+});336+337+test('JPEG fallback callbacks cannot survive a session reset', () => {338+ const subject = harness({ hasWebCodecs: false });339+ subject.pipeline.processFramePayload({340+ width: 640,341+ height: 400,342+ data: 'AQID',343+ keyframe: true,344+ });345+ const staleLoad = subject.images[0].onload;346+ const staleError = subject.images[0].onerror;347+348+ subject.pipeline.reset();349+ subject.advance();350+ staleLoad();351+ staleError(new Error('reset JPEG failure'));352+353+ const stats = subject.pipeline.snapshot();354+ assert.equal(stats.receivedFrames, 0);355+ assert.equal(stats.presentedFrames, 0);356+ assert.equal(stats.droppedFrames, 0);357+ assert.equal(stats.presentPercentiles.count, 0);358+ assert.equal(stats.presenterQueueDepth, 0);359+ assert.equal(stats.presenterQueueCapacity, 2);360+ assert.deepEqual(subject.draws, []);361+ assert.deepEqual(subject.revokedUrls, ['blob:test-frame-1']);362+ assert.deepEqual(subject.abortedImages, [subject.images[0]]);363+});364+365+test('JPEG fallback bounds delayed decoding to one active and one coalesced latest payload', () => {366+ const subject = harness({ hasWebCodecs: false });367+ const payload = {368+ width: 640,369+ height: 400,370+ data: 'AQID',371+ keyframe: true,372+ };373+ subject.pipeline.processFramePayload(payload);374+ const oldestLoad = subject.images[0].onload;375+376+ for (let frame = 2; frame <= 10; frame++) {377+ subject.advance();378+ subject.pipeline.processFramePayload(payload);379+ }380+381+ assert.equal(subject.images.length, 1);382+ assert.deepEqual(subject.imageStarts, [{383+ image: subject.images[0],384+ value: 'blob:test-frame-1',385+ }]);386+ assert.deepEqual(subject.revokedUrls, []);387+ let stats = subject.pipeline.snapshot();388+ assert.equal(stats.presentationDroppedFrames, 9);389+ assert.equal(stats.presenterQueueDepth, 2);390+ assert.equal(stats.presenterQueueCapacity, 2);391+392+ subject.advance();393+ oldestLoad();394+ assert.equal(subject.images.length, 2);395+ assert.deepEqual(subject.imageStarts, [396+ { image: subject.images[0], value: 'blob:test-frame-1' },397+ { image: subject.images[1], value: 'blob:test-frame-2' },398+ ]);399+ assert.deepEqual(subject.revokedUrls, ['blob:test-frame-1']);400+ stats = subject.pipeline.snapshot();401+ assert.equal(stats.presenterQueueDepth, 1);402+ assert.equal(stats.presenterQueueCapacity, 2);403+404+ subject.advance();405+ subject.images[1].onload();406+ stats = subject.pipeline.snapshot();407+ assert.deepEqual(subject.draws.map(([image]) => image), [subject.images[1]]);408+ assert.equal(stats.presentedFrames, 1);409+ assert.equal(stats.presentPercentiles.p50, 2);410+ assert.equal(stats.presentationDroppedFrames, 9);411+ assert.equal(stats.presenterQueueDepth, 0);412+ assert.equal(stats.presenterQueueCapacity, 2);413+ assert.deepEqual(subject.revokedUrls, [414+ 'blob:test-frame-1',415+ 'blob:test-frame-2',416+ ]);230417 });231418 232419 test('reset closes decoding state but preserves the last dimensions and codec quirk', () => {
A release/sigil-target-contract.txt +1 -0
M scripts/install-bazzite-package.sh +4 -2
@@ -26,6 +26,7 @@ die() {
2626 2727 script_dir="$(CDPATH='' cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)"2828 payload_dir="$script_dir"29+readonly asset_target_contract="linux-glibc2.17-x86_64"2930 3031 while [[ $# -gt 0 ]]; do3132 case "$1" in
@@ -250,13 +251,14 @@ validate_release_tree() {
250251 cd "$path"251252 sha256sum -c SHA256SUMS252253 )253- python3 - "$path/release-manifest.json" <<'PY'254+ python3 - "$path/release-manifest.json" "$asset_target_contract" <<'PY'254255 import json255256 import pathlib256257 import re257258 import sys258259 259260 path = pathlib.Path(sys.argv[1])261+asset_target_contract = sys.argv[2]260262 try:261263 manifest = json.loads(path.read_text())262264 except (OSError, UnicodeDecodeError, json.JSONDecodeError) as error:
@@ -289,7 +291,7 @@ if not isinstance(commit, str) or re.fullmatch(r"[0-9a-f]{40}", commit) is None:
289291 if not isinstance(asset_name, str) or re.fullmatch(r"[A-Za-z0-9._-]+[.]tar[.]gz", asset_name) is None:290292 raise SystemExit("package install failed: release manifest asset name is invalid")291293 if release_kind == "product-candidate":292- if release_tag != f"v{version}" or asset_name != f"sigil-{release_tag}-bazzite-x86_64.tar.gz":294+ if release_tag != f"v{version}" or asset_name != f"sigil-{release_tag}-{asset_target_contract}.tar.gz":293295 raise SystemExit("package install failed: product release identity is inconsistent")294296 if manifest.get("git_dirty") is not False:295297 raise SystemExit("package install failed: product release claims dirty source")
M scripts/package-bazzite-release.sh +13 -2
@@ -12,6 +12,8 @@ allow_unsigned=false
1212 release_tag=""1313 linux_target="x86_64-unknown-linux-gnu.2.17"1414 linux_output_target="x86_64-unknown-linux-gnu"15+asset_target_contract_file="$repo_dir/release/sigil-target-contract.txt"16+asset_target_contract=""1517 1618 usage() {1719 cat <<'EOF'
@@ -19,7 +21,9 @@ Usage: scripts/package-bazzite-release.sh --output /absolute/path/package.tar.gz
1921 2022 Create an allowlisted, deterministic Bazzite host runtime package. Product mode2123 exports the clean source HEAD and builds both generic Linux release binaries in22-an isolated Cargo target directory. The package includes atomic install,24+an isolated Cargo target directory. Its public asset name describes that25+Linux/glibc/architecture contract rather than the validated host distribution.26+The package includes atomic install,2327 upgrade/rollback support, the systemd user unit, PipeWire sink, staged udev2428 rule, complete checksums, and build provenance. It never includes the source2529 tree, host identity, hardware configuration, environment files, or evidence.
@@ -95,6 +99,13 @@ while [[ $# -gt 0 ]]; do
9599 done96100 97101 [[ -n "$output_path" ]] || die "--output is required"102+[[ -f "$asset_target_contract_file" && ! -L "$asset_target_contract_file" ]] \103+ || die "Sigil asset target contract is missing or unsafe"104+[[ "$(wc -l <"$asset_target_contract_file" | tr -d ' ')" == 1 ]] \105+ || die "Sigil asset target contract must contain exactly one line"106+asset_target_contract="$(<"$asset_target_contract_file")"107+[[ "$asset_target_contract" == linux-glibc2.17-x86_64 ]] \108+ || die "Sigil asset target contract does not match the approved Linux build target"98109 case "$output_path" in99110 /*.tar.gz) ;;100111 *) die "--output must be an absolute .tar.gz path" ;;
@@ -123,7 +134,7 @@ if $allow_dirty || $allow_unsigned || $caller_supplied_binaries; then
123134 else124135 [[ "$release_tag" =~ ^v[0-9]+\.[0-9]+\.[0-9]+([.-][0-9A-Za-z][0-9A-Za-z.-]*)?$ ]] \125136 || die "product mode requires --release-tag vVERSION"126- expected_asset="sigil-$release_tag-bazzite-x86_64.tar.gz"137+ expected_asset="sigil-$release_tag-$asset_target_contract.tar.gz"127138 [[ "$(basename -- "$output_path")" == "$expected_asset" ]] \128139 || die "product output must be named $expected_asset"129140 fi
M scripts/package-macos-client.sh +4 -2
M scripts/public-alpha-uat.sh +5 -1
@@ -8,6 +8,7 @@ MAX_SOURCE_BYTES=$((1024 * 1024))
88 DEFAULT_MAX_AGE_SECONDS=$((7 * 24 * 60 * 60))99 FUTURE_SKEW_SECONDS=3001010 GITHUB_REPOSITORY='FelineStateMachine/goq'11+SIGIL_ASSET_TARGET_CONTRACT='linux-glibc2.17-x86_64'1112 required_kinds=(cold-boot controller mouse soak network-direct network-relay reconnect second-client)1213 all_kinds=("${required_kinds[@]}" loopback-preflight)1314 script_dir="$(CDPATH='' cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)"
@@ -647,6 +648,7 @@ reject_sensitive_source() {
647648 validate_manifest() {648649 local directory="$1"649650 local manifest="$directory/manifest.tsv"651+ local sigil_archive_name650652 local key651653 local record_kind652654 local record_field
@@ -671,7 +673,9 @@ validate_manifest() {
671673 require_equal "$(manifest_get "$manifest" format)" "$FORMAT" format672674 require_commit "$(manifest_get "$manifest" git_commit)" manifest673675 require_release_tag "$(manifest_get "$manifest" release_tag)"674- [[ "$(manifest_get "$manifest" sigil_archive_name)" =~ ^sigil-v[0-9A-Za-z.-]+-bazzite-x86_64\.tar\.gz$ ]] \676+ sigil_archive_name="$(manifest_get "$manifest" sigil_archive_name)"677+ [[ "$sigil_archive_name" =~ ^sigil-v[0-9A-Za-z.-]+-[0-9A-Za-z._-]+[.]tar[.]gz$ \678+ && "$sigil_archive_name" == sigil-v*-"$SIGIL_ASSET_TARGET_CONTRACT".tar.gz ]] \675679 || die "invalid manifest Sigil archive name"676680 [[ "$(manifest_get "$manifest" portal_dmg_name)" =~ ^Portal-[0-9A-Za-z.-]+-arm64\.dmg$ ]] \677681 || die "invalid manifest Portal DMG name"
A scripts/run-linux-cross-build-gate.sh +58 -0
@@ -0,0 +1,58 @@
1+#!/usr/bin/env bash2+set -euo pipefail3+4+case "${GOQ_VERIFY_IN_PROCESS_GSTREAMER:-0}" in5+ 0|1) ;;6+ *)7+ printf 'GOQ_VERIFY_IN_PROCESS_GSTREAMER must be 0 or 1\n' >&28+ exit 19+ ;;10+esac11+12+case "${GOQ_REQUIRE_LINUX_CROSS_BUILD:-0}" in13+ 0|1) ;;14+ *)15+ printf 'GOQ_REQUIRE_LINUX_CROSS_BUILD must be 0 or 1\n' >&216+ exit 117+ ;;18+esac19+20+require_cross_commands() {21+ local cross_command22+23+ for cross_command in cargo-zigbuild zig; do24+ if ! command -v "$cross_command" >/dev/null 2>&1; then25+ printf 'required Linux cross-build command is missing at build time: %s\n' \26+ "$cross_command" >&227+ exit 128+ fi29+ done30+}31+32+run_cross_build() {33+ # Re-check at the point of use. The complete repository gate may spend34+ # several minutes on native tests after its initial tool preflight.35+ require_cross_commands36+37+ if [[ "${GOQ_VERIFY_IN_PROCESS_GSTREAMER:-0}" == 1 ]]; then38+ # Zig intentionally omits the host's default system-library paths for an39+ # explicit target. Preserve pkg-config's system -L entries so the dynamic40+ # GStreamer/GLib development libraries remain linkable without an rpath.41+ PKG_CONFIG_ALLOW_CROSS=1 PKG_CONFIG_ALLOW_SYSTEM_LIBS=1 \42+ cargo zigbuild --locked -p sigil-host --bins \43+ --target x86_64-unknown-linux-gnu.2.17 --features in-process-gstreamer44+ else45+ cargo zigbuild --locked -p sigil-host --bins \46+ --target x86_64-unknown-linux-gnu.2.1747+ fi48+ echo 'linux_cross_build=ok'49+}50+51+if [[ "${GOQ_REQUIRE_LINUX_CROSS_BUILD:-0}" == 1 ]]; then52+ run_cross_build53+elif command -v cargo-zigbuild >/dev/null 2>&1 \54+ && command -v zig >/dev/null 2>&1; then55+ run_cross_build56+else57+ echo 'linux_cross_build=skipped (cargo-zigbuild and zig are both required)'58+fi
A scripts/run-shell-tests.sh +61 -0
@@ -0,0 +1,61 @@
1+#!/usr/bin/env bash2+3+set -euo pipefail4+5+LC_ALL=C6+export LC_ALL7+8+test_dir="${1:-}"9+if [[ -z "$test_dir" ]]; then10+ printf 'usage: %s TEST_DIRECTORY\n' "${0##*/}" >&211+ exit 212+fi13+if [[ -L "$test_dir" || ! -d "$test_dir" ]]; then14+ printf 'shell test directory must be a real directory: %s\n' "$test_dir" >&215+ exit 116+fi17+if [[ ! -r "$test_dir" || ! -x "$test_dir" ]]; then18+ printf 'shell test directory must be readable and searchable: %s\n' "$test_dir" >&219+ exit 120+fi21+22+# Bash expands this array in LC_ALL=C order. dotglob makes a test such as23+# `.release-contract.sh` part of the same inventory, while nullglob lets us24+# diagnose an empty inventory instead of trying to execute a literal glob.25+shopt -s dotglob nullglob26+shell_tests=("$test_dir"/*.sh)27+shopt -u dotglob nullglob28+29+if (( ${#shell_tests[@]} == 0 )); then30+ printf 'no shell tests were discovered in: %s\n' "$test_dir" >&231+ exit 132+fi33+34+# Validate the complete inventory before running its first entry. This keeps a35+# malformed or non-executable test late in the sort order from producing a36+# partially successful gate.37+for shell_test in "${shell_tests[@]}"; do38+ printf 'shell_test_discovered=%s\n' "$shell_test"39+ if [[ "$shell_test" == *$'\n'* || "$shell_test" == *$'\r'* ]]; then40+ printf 'shell test path contains an unsafe line break: %q\n' "$shell_test" >&241+ exit 142+ fi43+ if [[ -L "$shell_test" || ! -f "$shell_test" ]]; then44+ printf 'shell test entry must be a regular, non-symlink file: %s\n' \45+ "$shell_test" >&246+ exit 147+ fi48+ if [[ ! -r "$shell_test" ]]; then49+ printf 'shell test entry is not readable: %s\n' "$shell_test" >&250+ exit 151+ fi52+ if [[ ! -x "$shell_test" ]]; then53+ printf 'shell test entry is not executable: %s\n' "$shell_test" >&254+ exit 155+ fi56+done57+58+for shell_test in "${shell_tests[@]}"; do59+ printf 'shell_test_start=%s\n' "$shell_test"60+ bash "$shell_test"61+done
M scripts/sign-bazzite-release.sh +1 -1
M scripts/tests/bazzite-package-install-e2e.sh +1 -1
A scripts/tests/ci-cross-build-policy.sh +11 -0
A scripts/tests/linux-cross-build-gate.sh +95 -0
@@ -0,0 +1,95 @@
1+#!/usr/bin/env bash2+# shellcheck disable=SC20163+set -euo pipefail4+5+script_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)"6+repo_dir="$(cd -- "$script_dir/../.." && pwd -P)"7+gate="$repo_dir/scripts/run-linux-cross-build-gate.sh"8+temp_root="$(mktemp -d "${TMPDIR:-/tmp}/goq-cross-build-gate.XXXXXX")"9+10+cleanup() {11+ rm -r -- "$temp_root"12+}13+trap cleanup EXIT14+15+make_command() {16+ local bin_dir="$1"17+ local command_name="$2"18+ shift 219+20+ install -d "$bin_dir"21+ if [[ ! -e "$bin_dir/bash" ]]; then22+ ln -s "$(command -v bash)" "$bin_dir/bash"23+ fi24+ printf '%s\n' '#!/usr/bin/env bash' "$@" >"$bin_dir/$command_name"25+ chmod 0755 "$bin_dir/$command_name"26+}27+28+success_bin="$temp_root/success-bin"29+make_command "$success_bin" cargo \30+ 'printf "%s\n" "$*" >"$GOQ_CARGO_ARGS_LOG"' \31+ 'printf "%s %s\n" "${PKG_CONFIG_ALLOW_CROSS:-}" "${PKG_CONFIG_ALLOW_SYSTEM_LIBS:-}" >"$GOQ_CARGO_ENV_LOG"'32+make_command "$success_bin" cargo-zigbuild 'exit 0'33+make_command "$success_bin" zig 'exit 0'34+35+success_log="$temp_root/success.log"36+GOQ_CARGO_ARGS_LOG="$temp_root/cargo-args.log" \37+ GOQ_CARGO_ENV_LOG="$temp_root/cargo-env.log" \38+ GOQ_REQUIRE_LINUX_CROSS_BUILD=1 \39+ GOQ_VERIFY_IN_PROCESS_GSTREAMER=0 \40+ PATH="$success_bin" \41+ "$gate" >"$success_log"42+grep -Fxq 'linux_cross_build=ok' "$success_log"43+grep -Fxq \44+ 'zigbuild --locked -p sigil-host --bins --target x86_64-unknown-linux-gnu.2.17' \45+ "$temp_root/cargo-args.log"46+47+GOQ_CARGO_ARGS_LOG="$temp_root/cargo-args.log" \48+ GOQ_CARGO_ENV_LOG="$temp_root/cargo-env.log" \49+ GOQ_REQUIRE_LINUX_CROSS_BUILD=1 \50+ GOQ_VERIFY_IN_PROCESS_GSTREAMER=1 \51+ PATH="$success_bin" \52+ "$gate" >"$success_log"53+grep -Fxq \54+ 'zigbuild --locked -p sigil-host --bins --target x86_64-unknown-linux-gnu.2.17 --features in-process-gstreamer' \55+ "$temp_root/cargo-args.log"56+grep -Fxq '1 1' "$temp_root/cargo-env.log"57+58+missing_bin="$temp_root/missing-bin"59+make_command "$missing_bin" cargo \60+ 'echo "required mode invoked cargo without both tools" >&2' \61+ 'exit 91'62+make_command "$missing_bin" cargo-zigbuild 'exit 0'63+64+missing_log="$temp_root/missing.log"65+if GOQ_REQUIRE_LINUX_CROSS_BUILD=1 \66+ GOQ_VERIFY_IN_PROCESS_GSTREAMER=0 \67+ PATH="$missing_bin" \68+ "$gate" >"$temp_root/missing.stdout" 2>"$missing_log"; then69+ echo 'required cross-build gate accepted a missing point-of-use tool' >&270+ exit 171+fi72+grep -Fxq \73+ 'required Linux cross-build command is missing at build time: zig' \74+ "$missing_log"75+if grep -Fq 'required mode invoked cargo' "$missing_log"; then76+ echo 'required cross-build gate invoked cargo after tool validation failed' >&277+ exit 178+fi79+if grep -Fq 'linux_cross_build=skipped' "$temp_root/missing.stdout"; then80+ echo 'required cross-build gate entered the optional skip path' >&281+ exit 182+fi83+84+optional_bin="$temp_root/optional-bin"85+make_command "$optional_bin" cargo 'exit 92'86+optional_log="$temp_root/optional.log"87+GOQ_REQUIRE_LINUX_CROSS_BUILD=0 \88+ GOQ_VERIFY_IN_PROCESS_GSTREAMER=0 \89+ PATH="$optional_bin" \90+ "$gate" >"$optional_log"91+grep -Fxq \92+ 'linux_cross_build=skipped (cargo-zigbuild and zig are both required)' \93+ "$optional_log"94+95+echo 'linux_cross_build_gate=ok'
M scripts/tests/package-bazzite-product-contract.sh +3 -2
M scripts/tests/package-bazzite-provenance.sh +15 -1
M scripts/tests/public-alpha-uat.sh +3 -3
A scripts/tests/shell-test-discovery.sh +99 -0
@@ -0,0 +1,99 @@
1+#!/usr/bin/env bash2+3+set -euo pipefail4+5+script_dir="$(CDPATH='' cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)"6+repo_dir="$(CDPATH='' cd -- "$script_dir/../.." && pwd -P)"7+runner="$repo_dir/scripts/run-shell-tests.sh"8+temp_parent="${TMPDIR:-/tmp}"9+temp_parent="${temp_parent%/}"10+temp_root="$(mktemp -d "$temp_parent/goq-shell-test-discovery.XXXXXX")"11+12+cleanup() {13+ local status=$?14+ trap - EXIT INT TERM HUP15+ case "$temp_root" in16+ "$temp_parent"/goq-shell-test-discovery.??????) rm -rf -- "$temp_root" ;;17+ esac18+ exit "$status"19+}20+trap cleanup EXIT INT TERM HUP21+22+write_passing_test() {23+ local path="$1"24+ local marker="$2"25+ printf '%s\n' \26+ '#!/usr/bin/env bash' \27+ 'set -euo pipefail' \28+ "printf '%s\\n' '$marker' >>\"\$GOQ_TEST_MARKERS\"" \29+ >"$path"30+ chmod 0755 "$path"31+}32+33+assert_failed_with() {34+ local expected="$1"35+ local output_path="$2"36+ shift 237+38+ if "$@" >"$output_path" 2>&1; then39+ printf 'command unexpectedly succeeded: %s\n' "$*" >&240+ exit 141+ fi42+ if ! grep -Fq -- "$expected" "$output_path"; then43+ printf 'failure did not contain expected text: %s\n' "$expected" >&244+ sed -n '1,120p' "$output_path" >&245+ exit 146+ fi47+}48+49+ordered_dir="$temp_root/ordered"50+ordered_markers="$temp_root/ordered.markers"51+mkdir "$ordered_dir"52+write_passing_test "$ordered_dir/zeta.sh" zeta53+write_passing_test "$ordered_dir/.hidden.sh" hidden54+write_passing_test "$ordered_dir/alpha.sh" alpha55+GOQ_TEST_MARKERS="$ordered_markers" "$runner" "$ordered_dir" \56+ >"$temp_root/ordered.out"57+printf '%s\n' hidden alpha zeta >"$temp_root/ordered.expected"58+cmp "$temp_root/ordered.expected" "$ordered_markers"59+for expected_path in \60+ "$ordered_dir/.hidden.sh" \61+ "$ordered_dir/alpha.sh" \62+ "$ordered_dir/zeta.sh"63+do64+ grep -Fq "shell_test_discovered=$expected_path" "$temp_root/ordered.out"65+ grep -Fq "shell_test_start=$expected_path" "$temp_root/ordered.out"66+done67+68+nonexec_dir="$temp_root/nonexec"69+nonexec_markers="$temp_root/nonexec.markers"70+mkdir "$nonexec_dir"71+write_passing_test "$nonexec_dir/alpha.sh" should-not-run72+printf '%s\n' '#!/usr/bin/env bash' 'exit 0' >"$nonexec_dir/zeta.sh"73+chmod 0644 "$nonexec_dir/zeta.sh"74+assert_failed_with \75+ "shell test entry is not executable: $nonexec_dir/zeta.sh" \76+ "$temp_root/nonexec.out" \77+ env GOQ_TEST_MARKERS="$nonexec_markers" "$runner" "$nonexec_dir"78+grep -Fq "shell_test_discovered=$nonexec_dir/zeta.sh" "$temp_root/nonexec.out"79+[[ ! -e "$nonexec_markers" ]]80+81+symlink_dir="$temp_root/symlink"82+symlink_markers="$temp_root/symlink.markers"83+mkdir "$symlink_dir"84+write_passing_test "$symlink_dir/alpha.sh" should-not-run85+ln -s alpha.sh "$symlink_dir/zeta.sh"86+assert_failed_with \87+ "shell test entry must be a regular, non-symlink file: $symlink_dir/zeta.sh" \88+ "$temp_root/symlink.out" \89+ env GOQ_TEST_MARKERS="$symlink_markers" "$runner" "$symlink_dir"90+[[ ! -e "$symlink_markers" ]]91+92+empty_dir="$temp_root/empty"93+mkdir "$empty_dir"94+assert_failed_with \95+ "no shell tests were discovered in: $empty_dir" \96+ "$temp_root/empty.out" \97+ "$runner" "$empty_dir"98+99+printf 'shell_test_discovery_tests=ok\n'
A scripts/tests/sigil-release-target-contract.sh +72 -0
@@ -0,0 +1,72 @@
1+#!/usr/bin/env bash2+3+set -euo pipefail4+5+script_dir="$(CDPATH='' cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)"6+repo_dir="$(CDPATH='' cd -- "$script_dir/../.." && pwd -P)"7+contract_file="$repo_dir/release/sigil-target-contract.txt"8+expected_contract="linux-glibc2.17-x86_64"9+10+[[ -f "$contract_file" && ! -L "$contract_file" ]] \11+ || {12+ printf 'Sigil target contract file is missing or unsafe\n' >&213+ exit 114+ }15+[[ "$(wc -l <"$contract_file" | tr -d ' ')" == 1 ]] \16+ || {17+ printf 'Sigil target contract file must contain exactly one line\n' >&218+ exit 119+ }20+[[ "$(<"$contract_file")" == "$expected_contract" ]] \21+ || {22+ printf 'Sigil target contract does not match the approved build ABI\n' >&223+ exit 124+ }25+26+required_consumers=(27+ .github/workflows/portal-release.yml28+ .github/workflows/sigil-release.yml29+ AGENTS.md30+ README.md31+ docs/fresh-bazzite-host.md32+ docs/public-alpha-uat.md33+ docs/public-release-delivery.md34+ scripts/install-bazzite-package.sh35+ scripts/package-bazzite-release.sh36+ scripts/public-alpha-uat.sh37+ scripts/sign-bazzite-release.sh38+ scripts/verify-sigil-bootstrap.py39+ scripts/verify-sigil-release.sh40+ website/install-sigil41+)42+for relative in "${required_consumers[@]}"; do43+ grep -Fq "$expected_contract" "$repo_dir/$relative" || {44+ printf 'Sigil release target contract is missing from %s\n' "$relative" >&245+ exit 146+ }47+done48+49+# The old distro suffix may remain only in immutable Bazzite hardware-UAT50+# evidence and in the explicit migration note. It must never return as a51+# product release asset identity.52+while IFS=: read -r relative _ line; do53+ relative="${relative#./}"54+ case "$relative:$line" in55+ .github/workflows/sigil-hardware-uat.yml:*sigil-hardware-uat-*-bazzite-x86_64.tar.gz*) ;;56+ docs/hardware-uat/*:*sigil-hardware-uat-*-bazzite-x86_64.tar.gz*) ;;57+ scripts/run-bazzite-hardware-uat.sh:*sigil-hardware-uat-*-bazzite-x86_64.tar.gz*) ;;58+ docs/public-release-delivery.md:*candidates*using*bazzite-x86_64*) ;;59+ *)60+ printf 'legacy distro identity leaked outside its allowlist: %s: %s\n' \61+ "$relative" "$line" >&262+ exit 163+ ;;64+ esac65+done < <(66+ cd "$repo_dir"67+ rg -n --hidden --glob '!.git/**' --glob '!target/**' \68+ --glob '!scripts/tests/sigil-release-target-contract.sh' \69+ --fixed-strings 'bazzite-x86_64' .70+)71+72+printf 'sigil_release_target_contract_tests=ok\n'
M scripts/tests/sigil-release-verifier.sh +12 -4
@@ -38,7 +38,7 @@ root = pathlib.Path(sys.argv[1])
3838 tag = sys.argv[2]3939 commit = sys.argv[3]4040 variant = sys.argv[4]41-asset = f"sigil-{tag}-bazzite-x86_64.tar.gz"41+asset = f"sigil-{tag}-linux-glibc2.17-x86_64.tar.gz"4242 archive_path = root / asset4343 4444 manifest = {
@@ -154,10 +154,18 @@ assert_rejected() {
154154 155155 valid_dir="$temp_root/valid"156156 make_fixture "$valid_dir"157-archive="$valid_dir/sigil-$release_tag-bazzite-x86_64.tar.gz"157+archive="$valid_dir/sigil-$release_tag-linux-glibc2.17-x86_64.tar.gz"158158 "$verifier" --tag "$release_tag" --archive "$archive" \159159 --source-commit "$source_commit" --candidate | grep -Fq 'sigil_release_verification=ok'160160 161+legacy_contract="bazzite""-x86_64"162+legacy_archive="$valid_dir/sigil-$release_tag-$legacy_contract.tar.gz"163+cp "$archive" "$legacy_archive"164+cp "$archive.sha256" "$legacy_archive.sha256"165+assert_rejected legacy-distro-name \166+ "archive must be named sigil-$release_tag-linux-glibc2.17-x86_64.tar.gz" \167+ "$verifier" --tag "$release_tag" --archive "$legacy_archive" --candidate168+161169 printf 'not a signature\n' >"$archive.minisig"162170 key_file="$temp_root/test.pub"163171 printf '%s\n%s\n' 'untrusted comment: test key' \
@@ -176,7 +184,7 @@ assert_rejected wrong-commit 'commit does not match' \
176184 177185 bad_checksum_dir="$temp_root/bad-checksum"178186 make_fixture "$bad_checksum_dir"179-bad_archive="$bad_checksum_dir/sigil-$release_tag-bazzite-x86_64.tar.gz"187+bad_archive="$bad_checksum_dir/sigil-$release_tag-linux-glibc2.17-x86_64.tar.gz"180188 printf '%064d %s\n' 0 "$(basename -- "$bad_archive")" >"$bad_archive.sha256"181189 assert_rejected bad-checksum 'checksum declaration does not exactly match' \182190 "$verifier" --tag "$release_tag" --archive "$bad_archive" --candidate
@@ -184,7 +192,7 @@ assert_rejected bad-checksum 'checksum declaration does not exactly match' \
184192 for variant in symlink duplicate unexpected default-features demo-auth-bypass; do185193 fixture_dir="$temp_root/$variant"186194 make_fixture "$fixture_dir" "$variant"187- fixture_archive="$fixture_dir/sigil-$release_tag-bazzite-x86_64.tar.gz"195+ fixture_archive="$fixture_dir/sigil-$release_tag-linux-glibc2.17-x86_64.tar.gz"188196 case "$variant" in189197 symlink) expected='unsafe type' ;;190198 duplicate) expected='duplicate members' ;;
A scripts/tests/test_ci_cross_build_policy.py +398 -0
@@ -0,0 +1,398 @@
1+from __future__ import annotations2+3+import unittest4+from pathlib import Path5+6+from scripts.verify_ci_cross_build_policy import PolicyError, verify, verify_gate_script7+8+9+REPO_DIR = Path(__file__).resolve().parents[2]10+WORKFLOW = (REPO_DIR / ".github" / "workflows" / "ci.yml").read_text(11+ encoding="utf-8"12+)13+GATE_SCRIPT = (REPO_DIR / "scripts" / "verify-demo-build.sh").read_text(14+ encoding="utf-8"15+)16+17+18+def replace_once(source: str, old: str, new: str) -> str:19+ if source.count(old) != 1:20+ raise AssertionError(f"fixture source must contain exactly one {old!r}")21+ return source.replace(old, new, 1)22+23+24+def remove_named_step(source: str, name: str, next_name: str) -> tuple[str, str]:25+ marker = f" - name: {name}\n"26+ next_marker = f" - name: {next_name}\n"27+ start = source.index(marker)28+ end = source.index(next_marker, start)29+ return source[:start] + source[end:], source[start:end]30+31+32+def replace_once_in_demo_gate(source: str, old: str, new: str) -> str:33+ prefix, marker, demo_gate = source.partition(" demo-gate:\n")34+ if not marker:35+ raise AssertionError("fixture source must contain the demo-gate job")36+ return prefix + marker + replace_once(demo_gate, old, new)37+38+39+class CiCrossBuildPolicyTests(unittest.TestCase):40+ def test_repository_workflow_passes(self) -> None:41+ verify(WORKFLOW)42+ verify_gate_script(GATE_SCRIPT)43+44+ def test_rejects_cross_build_helper_inside_false_branch(self) -> None:45+ mutated = replace_once(46+ GATE_SCRIPT,47+ "./scripts/run-linux-cross-build-gate.sh",48+ "if false; then\n./scripts/run-linux-cross-build-gate.sh\nfi",49+ )50+ with self.assertRaisesRegex(51+ PolicyError, "executable prefix changed"52+ ):53+ verify_gate_script(mutated)54+55+ def test_rejects_successful_early_exit_before_cross_build_helper(self) -> None:56+ mutated = replace_once(57+ GATE_SCRIPT,58+ "./scripts/run-linux-cross-build-gate.sh",59+ "if true; then\n exit 0\nfi\n"60+ "./scripts/run-linux-cross-build-gate.sh",61+ )62+ with self.assertRaisesRegex(PolicyError, "executable prefix changed"):63+ verify_gate_script(mutated)64+65+ def test_rejects_failure_control_changes_before_cross_build_helper(self) -> None:66+ prefixes = [67+ "exit 0",68+ "return 0",69+ "trap 'exit 0' EXIT",70+ "exec true",71+ "set +e",72+ ]73+ for prefix in prefixes:74+ with self.subTest(prefix=prefix):75+ mutated = replace_once(76+ GATE_SCRIPT,77+ "./scripts/run-linux-cross-build-gate.sh",78+ f"{prefix}\n./scripts/run-linux-cross-build-gate.sh",79+ )80+ with self.assertRaisesRegex(81+ PolicyError, "executable prefix changed"82+ ):83+ verify_gate_script(mutated)84+85+ def test_rejects_failure_masked_cross_build_helper(self) -> None:86+ mutated = replace_once(87+ GATE_SCRIPT,88+ "./scripts/run-linux-cross-build-gate.sh",89+ "./scripts/run-linux-cross-build-gate.sh || true",90+ )91+ with self.assertRaisesRegex(PolicyError, "executable prefix changed"):92+ verify_gate_script(mutated)93+94+ def test_rejects_demo_gate_run_bypass_even_with_decoy_comment(self) -> None:95+ mutated = replace_once(96+ WORKFLOW,97+ " run: ./scripts/verify-demo-build.sh",98+ " run: echo bypassed",99+ )100+ mutated += "\n# run: ./scripts/verify-demo-build.sh\n"101+ with self.assertRaisesRegex(102+ PolicyError, "does not execute the repository gate"103+ ):104+ verify(mutated)105+106+ def test_rejects_conditionally_disabled_cross_install(self) -> None:107+ mutated = replace_once(108+ WORKFLOW,109+ " - name: Install pinned cross-build tools\n run: |",110+ " - name: Install pinned cross-build tools\n"111+ " if: false\n"112+ " run: |",113+ )114+ with self.assertRaisesRegex(PolicyError, "unexpected or missing fields"):115+ verify(mutated)116+117+ def test_rejects_checkout_ref_override(self) -> None:118+ mutated = replace_once_in_demo_gate(119+ WORKFLOW,120+ " uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1",121+ " uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1\n"122+ " with:\n"123+ " ref: main",124+ )125+ with self.assertRaisesRegex(126+ PolicyError, "checkout step has unexpected or missing fields"127+ ):128+ verify(mutated)129+130+ def test_rejects_checkout_action_change(self) -> None:131+ mutated = replace_once_in_demo_gate(132+ WORKFLOW,133+ "actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1",134+ "actions/checkout@main",135+ )136+ with self.assertRaisesRegex(PolicyError, "checkout action is not digest-pinned"):137+ verify(mutated)138+139+ def test_rejects_step_poisoning_cargo_environment(self) -> None:140+ mutated = replace_once(141+ WORKFLOW,142+ " - name: Install Linux build dependencies\n",143+ " - name: Poison Cargo environment\n"144+ " run: printf '%s\\n' 'export RUSTC_WRAPPER=true' >> ~/.cargo/env\n\n"145+ " - name: Install Linux build dependencies\n",146+ )147+ with self.assertRaisesRegex(148+ PolicyError, "step list or order changed from the CI policy contract"149+ ):150+ verify(mutated)151+152+ def test_rejects_step_replacing_cargo_zigbuild(self) -> None:153+ mutated = replace_once(154+ WORKFLOW,155+ " - name: Run complete demo gate\n",156+ " - name: Replace cargo-zigbuild\n"157+ " run: |\n"158+ " printf '%s\\n' '#!/usr/bin/env bash' 'exit 0' "159+ "> ~/.cargo/bin/cargo-zigbuild\n"160+ " chmod 0755 ~/.cargo/bin/cargo-zigbuild\n\n"161+ " - name: Run complete demo gate\n",162+ )163+ with self.assertRaisesRegex(164+ PolicyError, "step list or order changed from the CI policy contract"165+ ):166+ verify(mutated)167+168+ def test_rejects_extra_steps_at_every_boundary(self) -> None:169+ markers = [170+ " - name: Check out repository\n",171+ " - name: Install Linux build dependencies\n",172+ " - name: Restore pinned cargo-zigbuild\n",173+ " - name: Install pinned cross-build tools\n",174+ " - name: Run complete demo gate\n",175+ ]176+ for position, marker in enumerate(markers):177+ with self.subTest(position=position):178+ mutated = replace_once_in_demo_gate(179+ WORKFLOW,180+ marker,181+ f" - name: Extra step {position}\n"182+ " run: echo unexpected\n\n"183+ f"{marker}",184+ )185+ with self.assertRaisesRegex(186+ PolicyError, "step list or order changed from the CI policy contract"187+ ):188+ verify(mutated)189+190+ mutated = WORKFLOW + (191+ "\n"192+ " - name: Extra trailing step\n"193+ " run: echo unexpected\n"194+ )195+ with self.assertRaisesRegex(196+ PolicyError, "step list or order changed from the CI policy contract"197+ ):198+ verify(mutated)199+200+ def test_rejects_required_step_reordering(self) -> None:201+ cache_marker = " - name: Restore pinned cargo-zigbuild\n"202+ install_marker = " - name: Install pinned cross-build tools\n"203+ gate_marker = " - name: Run complete demo gate\n"204+ cache_start = WORKFLOW.index(cache_marker)205+ install_start = WORKFLOW.index(install_marker, cache_start)206+ gate_start = WORKFLOW.index(gate_marker, install_start)207+ cache_step = WORKFLOW[cache_start:install_start]208+ install_step = WORKFLOW[install_start:gate_start]209+ mutated = (210+ WORKFLOW[:cache_start]211+ + install_step212+ + cache_step213+ + WORKFLOW[gate_start:]214+ )215+ with self.assertRaisesRegex(216+ PolicyError, "step list or order changed from the CI policy contract"217+ ):218+ verify(mutated)219+220+ def test_rejects_partial_linux_dependency_body_mutations(self) -> None:221+ mutations = [222+ (223+ " ffmpeg \\\n",224+ "",225+ ),226+ (227+ " shellcheck\n",228+ " shellcheck \\\n"229+ " unzip\n",230+ ),231+ (232+ " sudo apt-get update\n",233+ " sudo apt-get update --quiet\n",234+ ),235+ ]236+ for old, new in mutations:237+ with self.subTest(replacement=new):238+ mutated = replace_once(WORKFLOW, old, new)239+ with self.assertRaisesRegex(240+ PolicyError, "Linux dependency step body changed"241+ ):242+ verify(mutated)243+244+ def test_rejects_linux_dependency_step_field_changes(self) -> None:245+ mutated = replace_once(246+ WORKFLOW,247+ " - name: Install Linux build dependencies\n run: |",248+ " - name: Install Linux build dependencies\n"249+ " env:\n"250+ " BASH_ENV: ~/.cargo/env\n"251+ " run: |",252+ )253+ with self.assertRaisesRegex(254+ PolicyError, "Linux dependency step has unexpected or missing fields"255+ ):256+ verify(mutated)257+258+ def test_rejects_conditionally_disabled_demo_job(self) -> None:259+ mutated = replace_once(260+ WORKFLOW,261+ " demo-gate:\n name: Complete demo gate",262+ " demo-gate:\n if: false\n name: Complete demo gate",263+ )264+ with self.assertRaisesRegex(PolicyError, "must not be conditionally disabled"):265+ verify(mutated)266+267+ def test_rejects_quoted_conditionally_disabled_demo_job(self) -> None:268+ mutated = replace_once(269+ WORKFLOW,270+ " demo-gate:\n name: Complete demo gate",271+ ' demo-gate:\n "if": false\n name: Complete demo gate',272+ )273+ with self.assertRaisesRegex(PolicyError, "must not be conditionally disabled"):274+ verify(mutated)275+276+ def test_rejects_demo_job_continue_on_error(self) -> None:277+ mutated = replace_once(278+ WORKFLOW,279+ " demo-gate:\n name: Complete demo gate",280+ " demo-gate:\n continue-on-error: true\n"281+ " name: Complete demo gate",282+ )283+ with self.assertRaisesRegex(PolicyError, "must not suppress failures"):284+ verify(mutated)285+286+ def test_rejects_missing_pull_request_trigger(self) -> None:287+ mutated = replace_once(WORKFLOW, " pull_request:\n", "")288+ with self.assertRaisesRegex(289+ PolicyError, "empty pull_request trigger"290+ ):291+ verify(mutated)292+293+ def test_rejects_filtered_pull_request_trigger(self) -> None:294+ filters = [295+ " branches: [main]",296+ " paths: ['crates/**']",297+ " types: [opened]",298+ ]299+ for filter_line in filters:300+ with self.subTest(filter_line=filter_line):301+ mutated = replace_once(302+ WORKFLOW,303+ " pull_request:\n",304+ f" pull_request:\n{filter_line}\n",305+ )306+ with self.assertRaisesRegex(307+ PolicyError, "exactly empty and unfiltered"308+ ):309+ verify(mutated)310+311+ def test_rejects_inline_pull_request_mapping(self) -> None:312+ mutated = replace_once(WORKFLOW, " pull_request:", " pull_request: {}")313+ with self.assertRaisesRegex(PolicyError, "must be exactly empty"):314+ verify(mutated)315+316+ def test_rejects_changed_demo_job_identity_and_execution_limits(self) -> None:317+ changes = [318+ (" name: Complete demo gate", " name: Optional demo gate", "required name"),319+ (320+ " demo-gate:\n name: Complete demo gate\n"321+ " runs-on: ubuntu-24.04",322+ " demo-gate:\n name: Complete demo gate\n"323+ " runs-on: ubuntu-latest",324+ "ubuntu-24.04",325+ ),326+ (" timeout-minutes: 45", " timeout-minutes: 5", "45 minutes"),327+ ]328+ for old, new, error in changes:329+ with self.subTest(field=old):330+ mutated = replace_once(WORKFLOW, old, new)331+ with self.assertRaisesRegex(PolicyError, error):332+ verify(mutated)333+334+ def test_rejects_changed_demo_job_environment(self) -> None:335+ changes = [336+ (' CARGO_TERM_COLOR: always', ' CARGO_TERM_COLOR: never'),337+ (' RUST_BACKTRACE: "1"\n', ""),338+ (339+ ' RUST_BACKTRACE: "1"',340+ ' RUST_BACKTRACE: "1"\n CI_FAILURES_OPTIONAL: "1"',341+ ),342+ ]343+ for old, new in changes:344+ with self.subTest(replacement=new):345+ mutated = replace_once(WORKFLOW, old, new)346+ with self.assertRaisesRegex(347+ PolicyError, "env changed from the CI policy contract"348+ ):349+ verify(mutated)350+351+ def test_rejects_failure_suppressing_workflow_shell_default(self) -> None:352+ mutated = replace_once(353+ WORKFLOW,354+ "name: CI\n\non:",355+ "name: CI\n\ndefaults:\n"356+ " run:\n"357+ " shell: 'bash {0} || true'\n\n"358+ "on:",359+ )360+ with self.assertRaisesRegex(PolicyError, "workflow-level defaults are forbidden"):361+ verify(mutated)362+363+ def test_ignores_tokens_in_comments_and_later_jobs(self) -> None:364+ mutated, removed = remove_named_step(365+ WORKFLOW,366+ "Install pinned cross-build tools",367+ "Run complete demo gate",368+ )369+ decoy = (370+ "\n# Install pinned cross-build tools\n"371+ "# GOQ_REQUIRE_LINUX_CROSS_BUILD: \"1\"\n"372+ " later-decoy:\n"373+ " runs-on: ubuntu-24.04\n"374+ " steps:\n"375+ f"{removed}"376+ )377+ with self.assertRaisesRegex(PolicyError, "must exist exactly once"):378+ verify(mutated + decoy)379+380+ def test_rejects_wrong_install_body_even_with_tokens_in_later_job(self) -> None:381+ mutated = replace_once(382+ WORKFLOW,383+ " cargo install cargo-zigbuild --locked --version 0.23.0",384+ " echo skipped cargo install",385+ )386+ mutated += (387+ "\n later-decoy:\n"388+ " runs-on: ubuntu-24.04\n"389+ " steps:\n"390+ " - name: Decoy\n"391+ " run: cargo install cargo-zigbuild --locked --version 0.23.0\n"392+ )393+ with self.assertRaisesRegex(PolicyError, "install step body changed"):394+ verify(mutated)395+396+397+if __name__ == "__main__":398+ unittest.main()
M scripts/tests/test_portal_release.py +15 -1
M scripts/tests/test_sigil_bootstrap.py +6 -1
M scripts/verify-demo-build.sh +40 -19
@@ -10,6 +10,8 @@ if [[ -f "$HOME/.cargo/env" ]]; then
1010 source "$HOME/.cargo/env"1111 fi1212 13+./scripts/run-linux-cross-build-gate.sh14+1315 for command_name in cargo rustc node git ffmpeg shellcheck; do1416 if ! command -v "$command_name" >/dev/null 2>&1; then1517 printf 'required command is missing: %s\n' "$command_name" >&2
@@ -25,6 +27,24 @@ case "${GOQ_VERIFY_IN_PROCESS_GSTREAMER:-0}" in
2527 ;;2628 esac2729 30+case "${GOQ_REQUIRE_LINUX_CROSS_BUILD:-0}" in31+ 0|1) ;;32+ *)33+ printf 'GOQ_REQUIRE_LINUX_CROSS_BUILD must be 0 or 1\n' >&234+ exit 135+ ;;36+esac37+38+if [[ "${GOQ_REQUIRE_LINUX_CROSS_BUILD:-0}" == 1 ]]; then39+ for cross_command in cargo-zigbuild zig; do40+ if ! command -v "$cross_command" >/dev/null 2>&1; then41+ printf 'required Linux cross-build command is missing: %s\n' \42+ "$cross_command" >&243+ exit 144+ fi45+ done46+fi47+2848 require_rust_test() {2949 local test_name="$1"3050 shift
@@ -61,6 +81,9 @@ require_rust_test 'resolves_pinned_upstream_gamescope_pipewire_contract' \
6181 cargo fmt --all -- --check6282 cargo test --locked --workspace --all-targets6383 cargo clippy --locked --workspace --all-targets -- -D warnings84+cargo check --locked -p portal --all-targets \85+ --features experimental-non-macos-pointer-capture86+echo 'experimental_non_macos_pointer_capture_compile=ok'6487 if [[ "${GOQ_VERIFY_IN_PROCESS_GSTREAMER:-0}" == 1 ]]; then6588 control_test='encoder_control_coalesces_latest_state_and_acknowledges_only_configured_idr'6689 gstreamer_test='in_process_gstreamer_x264_smoke'
@@ -72,22 +95,6 @@ if [[ "${GOQ_VERIFY_IN_PROCESS_GSTREAMER:-0}" == 1 ]]; then
7295 "$gstreamer_test" -- --ignored --nocapture7396 echo 'in_process_gstreamer_gate=ok'7497 fi75-if command -v cargo-zigbuild >/dev/null 2>&1 && command -v zig >/dev/null 2>&1; then76- if [[ "${GOQ_VERIFY_IN_PROCESS_GSTREAMER:-0}" == 1 ]]; then77- # Zig intentionally omits the host's default system-library paths for an78- # explicit target. Preserve pkg-config's system -L entries so the dynamic79- # GStreamer/GLib development libraries remain linkable without an rpath.80- PKG_CONFIG_ALLOW_CROSS=1 PKG_CONFIG_ALLOW_SYSTEM_LIBS=1 \81- cargo zigbuild --locked -p sigil-host --bins \82- --target x86_64-unknown-linux-gnu.2.17 --features in-process-gstreamer83- else84- cargo zigbuild --locked -p sigil-host --bins \85- --target x86_64-unknown-linux-gnu.2.1786- fi87- echo 'linux_cross_build=ok'88-else89- echo 'linux_cross_build=skipped (cargo-zigbuild and zig are both required)'90-fi9198 while IFS= read -r frontend_source; do9299 node --check "$frontend_source"93100 done < <(find portal -maxdepth 1 -type f \( -name '*.js' -o -name '*.mjs' \) -print | sort)
@@ -95,9 +102,7 @@ node --test portal/*.test.mjs
95102 96103 cargo build --locked -p sigil-host --bin sigil97104 find scripts -type f -name '*.sh' -exec shellcheck {} +98-while IFS= read -r script_test; do99- "$script_test"100-done < <(find scripts/tests -maxdepth 1 -type f -name '*.sh' -perm -u+x -print | sort)105+./scripts/run-shell-tests.sh scripts/tests101106 102107 host_dependencies="$(cargo tree --locked -p sigil-host --edges normal)"103108 if grep -Eiq '(^|[[:space:]├└│─])(tauri|wry|webkit)([[:space:]-]|$)' <<<"$host_dependencies"; then
@@ -126,6 +131,22 @@ cargo test --locked -p portal --release \
126131 commands::state::tests::ordinary_release_excludes_direct_node_bypass \127132 -- --exact128133 134+cargo test --locked -p portal --release \135+ commands::state::tests::rejects_force_jpeg_when_debug_mode_is_disabled \136+ -- --exact137+138+cargo test --locked -p portal --release \139+ commands::state::tests::ordinary_release_excludes_force_jpeg_override \140+ -- --exact141+142+cargo test --locked -p portal --release \143+ commands::state::tests::app_state_accepts_force_jpeg_only_in_debug_builds \144+ -- --exact145+146+cargo test --locked -p portal --release --features demo-direct-node \147+ commands::state::tests::app_state_accepts_force_jpeg_only_in_debug_builds \148+ -- --exact149+129150 cargo test --locked -p portal --release --features demo-direct-node \130151 commands::state::tests::app_state_accepts_direct_node_only_in_debug_builds \131152 -- --exact
M scripts/verify-portal-release.py +8 -5
M scripts/verify-sigil-bootstrap.py +6 -0
M scripts/verify-sigil-release.sh +16 -3
@@ -5,10 +5,12 @@ set -euo pipefail
55 usage() {66 cat <<'EOF'77 Usage: scripts/verify-sigil-release.sh \8- --tag vVERSION --archive /absolute/path/sigil-vVERSION-bazzite-x86_64.tar.gz \8+ --tag vVERSION --archive /absolute/path/sigil-vVERSION-linux-glibc2.17-x86_64.tar.gz \99 [--source-commit HEX] [--candidate | --public-key-file PATH]1010 11-Verify the exact Sigil Bazzite release asset contract without extracting it.11+Verify the exact Sigil Linux/glibc/x86_64 release asset contract without12+extracting it. This target identity does not broaden the separately validated13+host-distribution support boundary.1214 Candidate mode verifies a clean, unsigned product candidate before it crosses1315 the offline signing boundary. Published mode additionally requires and verifies1416 ARCHIVE.minisig with the reviewed Minisign public key file.
@@ -20,6 +22,17 @@ die() {
2022 exit 12123 }2224 25+script_dir="$(CDPATH='' cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)"26+repo_dir="$(CDPATH='' cd -- "$script_dir/.." && pwd -P)"27+asset_target_contract_file="$repo_dir/release/sigil-target-contract.txt"28+[[ -f "$asset_target_contract_file" && ! -L "$asset_target_contract_file" ]] \29+ || die "Sigil asset target contract is missing or unsafe"30+[[ "$(wc -l <"$asset_target_contract_file" | tr -d ' ')" == 1 ]] \31+ || die "Sigil asset target contract must contain exactly one line"32+asset_target_contract="$(<"$asset_target_contract_file")"33+[[ "$asset_target_contract" == linux-glibc2.17-x86_64 ]] \34+ || die "Sigil asset target contract does not match the approved Linux build target"35+2336 sha256_file() {2437 if command -v sha256sum >/dev/null 2>&1; then2538 sha256sum "$1" | awk '{print $1}'
@@ -75,7 +88,7 @@ done
7588 [[ "$archive_path" == /* ]] || die "--archive must be absolute"7689 [[ -f "$archive_path" && ! -L "$archive_path" ]] || die "archive is missing or unsafe"7790 78-asset_name="sigil-$release_tag-bazzite-x86_64.tar.gz"91+asset_name="sigil-$release_tag-$asset_target_contract.tar.gz"7992 [[ "$(basename -- "$archive_path")" == "$asset_name" ]] \8093 || die "archive must be named $asset_name"8194 checksum_path="$archive_path.sha256"
A scripts/verify_ci_cross_build_policy.py +509 -0
@@ -0,0 +1,509 @@
1+#!/usr/bin/env python32+"""Validate the executable semantics of CI's required glibc cross-build path."""3+4+from __future__ import annotations5+6+import argparse7+import re8+import sys9+from dataclasses import dataclass, field10+from pathlib import Path11+12+13+CHECKOUT_ACTION = "actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8"14+CACHE_ACTION = "actions/cache@0400d5f644dc74513175e3cd8d07132dd4860809"15+CACHE_PATH = "~/.cargo/bin/cargo-zigbuild"16+CACHE_KEY = "cargo-zigbuild-${{ runner.os }}-${{ runner.arch }}-0.23.0"17+LINUX_CROSS_BUILD_GATE = "./scripts/run-linux-cross-build-gate.sh"18+MAPPING_KEY = r"""(?:[A-Za-z0-9_-]+|"[A-Za-z0-9_-]+"|'[A-Za-z0-9_-]+')"""19+REQUIRED_STEP_NAMES = [20+ "Check out repository",21+ "Install Linux build dependencies",22+ "Restore pinned cargo-zigbuild",23+ "Install pinned cross-build tools",24+ "Run complete demo gate",25+]26+GATE_SCRIPT_PREFIX = """\27+#!/usr/bin/env bash28+set -euo pipefail29+30+script_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)"31+repo_dir="$(cd -- "$script_dir/.." && pwd -P)"32+cd "$repo_dir"33+34+if [[ -f "$HOME/.cargo/env" ]]; then35+ # shellcheck source=/dev/null36+ source "$HOME/.cargo/env"37+fi38+39+./scripts/run-linux-cross-build-gate.sh40+"""41+42+DEPENDENCY_RUN = """\43+sudo apt-get update44+sudo apt-get install --yes --no-install-recommends \\45+ build-essential \\46+ file \\47+ ffmpeg \\48+ gstreamer1.0-plugins-base \\49+ gstreamer1.0-plugins-bad \\50+ gstreamer1.0-plugins-good \\51+ gstreamer1.0-plugins-ugly \\52+ gstreamer1.0-tools \\53+ libayatana-appindicator3-dev \\54+ libgstreamer-plugins-base1.0-dev \\55+ libgstreamer1.0-dev \\56+ librsvg2-dev \\57+ libssl-dev \\58+ libudev-dev \\59+ libwebkit2gtk-4.1-dev \\60+ libxdo-dev \\61+ pkg-config \\62+ python3-venv \\63+ shellcheck"""64+65+INSTALL_RUN = """\66+python3 -m venv "$RUNNER_TEMP/zig-venv"67+printf '%s\\n' \\68+ 'ziglang==0.16.0 --hash=sha256:9fcda73f62b851dd72a54b710ad40a209896db14cfb13649e62191243556342b' \\69+ | "$RUNNER_TEMP/zig-venv/bin/pip" install \\70+ --disable-pip-version-check \\71+ --only-binary=:all: \\72+ --require-hashes \\73+ --requirement /dev/stdin74+install -d "$RUNNER_TEMP/ci-bin"75+# Expand runner paths when the generated wrapper executes.76+# shellcheck disable=SC201677+printf '%s\\n' \\78+ '#!/usr/bin/env bash' \\79+ 'exec "$RUNNER_TEMP/zig-venv/bin/python" -m ziglang "$@"' \\80+ >"$RUNNER_TEMP/ci-bin/zig"81+chmod 0755 "$RUNNER_TEMP/ci-bin/zig"82+echo "$RUNNER_TEMP/ci-bin" >>"$GITHUB_PATH"83+export PATH="$RUNNER_TEMP/ci-bin:$PATH"84+if ! cargo-zigbuild --version 2>/dev/null \\85+ | grep -Fxq 'cargo-zigbuild 0.23.0'; then86+ cargo install cargo-zigbuild --locked --version 0.23.087+fi88+test "$(zig version)" = 0.16.089+test "$(cargo-zigbuild --version)" = 'cargo-zigbuild 0.23.0'"""90+91+92+class PolicyError(ValueError):93+ """The workflow does not meet the mandatory cross-build policy."""94+95+96+@dataclass97+class Step:98+ name: str99+ scalars: dict[str, str] = field(default_factory=dict)100+ mappings: dict[str, dict[str, str]] = field(default_factory=dict)101+ blocks: dict[str, str] = field(default_factory=dict)102+103+ @property104+ def field_names(self) -> set[str]:105+ return {"name", *self.scalars, *self.mappings, *self.blocks}106+107+108+def _indent(line: str) -> int:109+ return len(line) - len(line.lstrip(" "))110+111+112+def _is_content(line: str) -> bool:113+ stripped = line.strip()114+ return bool(stripped) and not stripped.startswith("#")115+116+117+def _scalar(value: str) -> str:118+ value = value.split(" #", 1)[0].strip()119+ if len(value) >= 2 and value[0] == value[-1] and value[0] in "\"'":120+ return value[1:-1]121+ return value122+123+124+def _mapping_entry(line: str, indent: int) -> tuple[str, str] | None:125+ if _indent(line) != indent or not _is_content(line):126+ return None127+ match = re.fullmatch(128+ rf" {{{indent}}}({MAPPING_KEY}):(?:\s*(.*))?",129+ line,130+ )131+ if not match:132+ return None133+ return _scalar(match.group(1)), (match.group(2) or "").strip()134+135+136+def _mapping_key(line: str, indent: int) -> str | None:137+ entry = _mapping_entry(line, indent)138+ return entry[0] if entry else None139+140+141+def _block_end(lines: list[str], start: int, indent: int) -> int:142+ for index in range(start + 1, len(lines)):143+ if _mapping_entry(lines[index], indent) is not None:144+ return index145+ if _is_content(lines[index]) and _indent(lines[index]) < indent:146+ return index147+ return len(lines)148+149+150+def _validate_workflow_layout(workflow: str) -> None:151+ lines = workflow.splitlines()152+ for line in lines:153+ if _is_content(line) and _indent(line) == 0:154+ if _mapping_entry(line, 0) is None:155+ raise PolicyError(f"cannot parse top-level workflow field: {line}")156+ top_level = [157+ (index, entry)158+ for index, line in enumerate(lines)159+ if (entry := _mapping_entry(line, 0)) is not None160+ ]161+ top_level_names = [key for _, (key, _) in top_level]162+ if len(top_level_names) != len(set(top_level_names)):163+ raise PolicyError("workflow contains a duplicate top-level field")164+165+ if any(key == "defaults" for _, (key, _) in top_level):166+ raise PolicyError(167+ "workflow-level defaults are forbidden because they can suppress gate failures"168+ )169+ allowed_top_level = {"name", "on", "permissions", "concurrency", "jobs"}170+ if set(top_level_names) != allowed_top_level:171+ raise PolicyError("workflow top-level fields changed from the CI policy contract")172+173+ on_entries = [(index, value) for index, (key, value) in top_level if key == "on"]174+ if len(on_entries) != 1:175+ raise PolicyError("workflow must contain exactly one top-level on mapping")176+ on_start, on_value = on_entries[0]177+ if _scalar(on_value):178+ raise PolicyError("workflow on field must be a trigger mapping")179+ on_end = _block_end(lines, on_start, 0)180+ pull_request_indices = [181+ index182+ for index in range(on_start + 1, on_end)183+ if _mapping_key(lines[index], 2) == "pull_request"184+ ]185+ if len(pull_request_indices) != 1:186+ raise PolicyError(187+ "ordinary CI must contain exactly one empty pull_request trigger"188+ )189+ pull_request_index = pull_request_indices[0]190+ pull_request_entry = _mapping_entry(lines[pull_request_index], 2)191+ if pull_request_entry is None or _scalar(pull_request_entry[1]):192+ raise PolicyError("ordinary CI pull_request trigger must be exactly empty")193+194+ pull_request_end = on_end195+ for index in range(pull_request_index + 1, on_end):196+ if _is_content(lines[index]) and _indent(lines[index]) <= 2:197+ pull_request_end = index198+ break199+ if any(200+ _is_content(line)201+ for line in lines[pull_request_index + 1 : pull_request_end]202+ ):203+ raise PolicyError(204+ "ordinary CI pull_request trigger must be exactly empty and unfiltered"205+ )206+207+208+def _job_lines(workflow: str, job_name: str) -> list[str]:209+ lines = workflow.splitlines()210+ jobs_indices = [211+ index212+ for index, line in enumerate(lines)213+ if _mapping_key(line, 0) == "jobs"214+ ]215+ if len(jobs_indices) != 1:216+ raise PolicyError("workflow must contain exactly one top-level jobs mapping")217+ jobs_entry = _mapping_entry(lines[jobs_indices[0]], 0)218+ if jobs_entry is None or _scalar(jobs_entry[1]):219+ raise PolicyError("top-level jobs field must be a mapping")220+221+ jobs_start = jobs_indices[0] + 1222+ jobs_end = len(lines)223+ for index in range(jobs_start, len(lines)):224+ if _is_content(lines[index]) and _indent(lines[index]) == 0:225+ jobs_end = index226+ break227+228+ matches = [229+ index230+ for index in range(jobs_start, jobs_end)231+ if _mapping_key(lines[index], 2) == job_name232+ ]233+ if len(matches) != 1:234+ raise PolicyError(f"jobs.{job_name} must exist exactly once")235+236+ job_start = matches[0]237+ job_entry = _mapping_entry(lines[job_start], 2)238+ if job_entry is None or _scalar(job_entry[1]):239+ raise PolicyError(f"jobs.{job_name} must be a mapping")240+ job_end = jobs_end241+ for index in range(job_start + 1, jobs_end):242+ if _mapping_key(lines[index], 2) is not None:243+ job_end = index244+ break245+ return lines[job_start:job_end]246+247+248+def _parse_step(block: list[str]) -> Step:249+ match = re.fullmatch(r" - name:\s*(.+?)\s*", block[0])250+ if not match:251+ raise PolicyError("every demo-gate step must begin with an explicit name")252+ step = Step(name=_scalar(match.group(1)))253+254+ index = 1255+ while index < len(block):256+ line = block[index]257+ if not _is_content(line):258+ index += 1259+ continue260+ if _indent(line) != 8:261+ raise PolicyError(f"unexpected YAML structure in step {step.name!r}: {line}")262+ match = re.fullmatch(rf" ({MAPPING_KEY}):(?:\s*(.*))?", line)263+ if not match:264+ raise PolicyError(f"cannot parse field in step {step.name!r}: {line}")265+ key = _scalar(match.group(1))266+ value = (match.group(2) or "").strip()267+ if key in step.field_names:268+ raise PolicyError(f"duplicate field {key!r} in step {step.name!r}")269+270+ if value == "|":271+ index += 1272+ body: list[str] = []273+ while index < len(block):274+ child = block[index]275+ if _is_content(child) and _indent(child) <= 8:276+ break277+ if child.strip():278+ if _indent(child) < 10:279+ raise PolicyError(280+ f"invalid block indentation in step {step.name!r}"281+ )282+ body.append(child[10:])283+ else:284+ body.append("")285+ index += 1286+ step.blocks[key] = "\n".join(body).rstrip()287+ continue288+289+ if not value:290+ index += 1291+ entries: dict[str, str] = {}292+ while index < len(block):293+ child = block[index]294+ if not _is_content(child):295+ index += 1296+ continue297+ if _indent(child) <= 8:298+ break299+ child_match = re.fullmatch(300+ rf" ({MAPPING_KEY}):\s*(.+?)\s*", child301+ )302+ if not child_match:303+ raise PolicyError(304+ f"cannot parse {key!r} mapping in step {step.name!r}: {child}"305+ )306+ child_key = _scalar(child_match.group(1))307+ if child_key in entries:308+ raise PolicyError(309+ f"duplicate {key}.{child_key} in step {step.name!r}"310+ )311+ entries[child_key] = _scalar(child_match.group(2))312+ index += 1313+ step.mappings[key] = entries314+ continue315+316+ step.scalars[key] = _scalar(value)317+ index += 1318+319+ return step320+321+322+def _parse_steps(job: list[str]) -> list[Step]:323+ job_entries: list[tuple[str, str]] = []324+ for line in job:325+ if not _is_content(line) or _indent(line) != 4:326+ continue327+ entry = _mapping_entry(line, 4)328+ if entry is None:329+ raise PolicyError(f"cannot parse jobs.demo-gate field: {line}")330+ job_entries.append(entry)331+ job_field_list = [key for key, _ in job_entries]332+ if len(job_field_list) != len(set(job_field_list)):333+ raise PolicyError("jobs.demo-gate contains a duplicate field")334+ job_fields = set(job_field_list)335+ if "if" in job_fields:336+ raise PolicyError("jobs.demo-gate must not be conditionally disabled")337+ if "continue-on-error" in job_fields:338+ raise PolicyError("jobs.demo-gate must not suppress failures")339+ if "defaults" in job_fields:340+ raise PolicyError(341+ "jobs.demo-gate must not override run defaults or shell failure behavior"342+ )343+ if job_fields != {"name", "runs-on", "timeout-minutes", "env", "steps"}:344+ raise PolicyError("jobs.demo-gate fields changed from the CI policy contract")345+ job_values = {key: value for key, value in job_entries}346+ if _scalar(job_values["name"]) != "Complete demo gate":347+ raise PolicyError("jobs.demo-gate must retain its required name")348+ if _scalar(job_values["runs-on"]) != "ubuntu-24.04":349+ raise PolicyError("jobs.demo-gate must run on ubuntu-24.04")350+ if _scalar(job_values["timeout-minutes"]) != "45":351+ raise PolicyError("jobs.demo-gate timeout must remain 45 minutes")352+ if _scalar(job_values["env"]):353+ raise PolicyError("jobs.demo-gate env field must be an exact mapping")354+ if _scalar(job_values["steps"]):355+ raise PolicyError("jobs.demo-gate steps field must be a list")356+357+ env_indices = [358+ index359+ for index, line in enumerate(job)360+ if _mapping_key(line, 4) == "env"361+ ]362+ if len(env_indices) != 1:363+ raise PolicyError("jobs.demo-gate must contain exactly one env mapping")364+ env_start = env_indices[0]365+ env_end = len(job)366+ for index in range(env_start + 1, len(job)):367+ if _is_content(job[index]) and _indent(job[index]) <= 4:368+ env_end = index369+ break370+ env_entries: list[tuple[str, str]] = []371+ for line in job[env_start + 1 : env_end]:372+ if not _is_content(line):373+ continue374+ entry = _mapping_entry(line, 6)375+ if entry is None:376+ raise PolicyError(f"cannot parse jobs.demo-gate env field: {line}")377+ env_entries.append(entry)378+ env_names = [key for key, _ in env_entries]379+ if len(env_names) != len(set(env_names)):380+ raise PolicyError("jobs.demo-gate env contains a duplicate field")381+ env = {key: _scalar(value) for key, value in env_entries}382+ if env != {"CARGO_TERM_COLOR": "always", "RUST_BACKTRACE": "1"}:383+ raise PolicyError("jobs.demo-gate env changed from the CI policy contract")384+385+ steps_indices = [386+ index387+ for index, line in enumerate(job)388+ if line == " steps:"389+ ]390+ if len(steps_indices) != 1:391+ raise PolicyError("jobs.demo-gate must contain exactly one steps list")392+393+ start = steps_indices[0] + 1394+ starts = [395+ index396+ for index in range(start, len(job))397+ if re.fullmatch(r" - name:\s*.+", job[index])398+ ]399+ if not starts:400+ raise PolicyError("jobs.demo-gate contains no named steps")401+402+ steps: list[Step] = []403+ for position, step_start in enumerate(starts):404+ step_end = starts[position + 1] if position + 1 < len(starts) else len(job)405+ steps.append(_parse_step(job[step_start:step_end]))406+ return steps407+408+409+def _named_step(steps: list[Step], name: str) -> tuple[int, Step]:410+ matches = [411+ (index, step)412+ for index, step in enumerate(steps)413+ if step.name == name414+ ]415+ if len(matches) != 1:416+ raise PolicyError(f"demo-gate step {name!r} must exist exactly once")417+ return matches[0]418+419+420+def verify_gate_script(gate_script: str) -> None:421+ if not gate_script.startswith(GATE_SCRIPT_PREFIX):422+ raise PolicyError(423+ "complete repository gate executable prefix changed before the required Linux cross build"424+ )425+426+ if gate_script.count(LINUX_CROSS_BUILD_GATE) != 1:427+ raise PolicyError(428+ "complete repository gate must invoke the Linux cross-build gate exactly once"429+ )430+431+432+def verify(workflow: str) -> None:433+ _validate_workflow_layout(workflow)434+ steps = _parse_steps(_job_lines(workflow, "demo-gate"))435+436+ checkout_index, checkout = _named_step(steps, "Check out repository")437+ dependency_index, dependency = _named_step(438+ steps, "Install Linux build dependencies"439+ )440+ cache_index, cache = _named_step(steps, "Restore pinned cargo-zigbuild")441+ install_index, install = _named_step(steps, "Install pinned cross-build tools")442+ gate_index, gate = _named_step(steps, "Run complete demo gate")443+444+ if [step.name for step in steps] != REQUIRED_STEP_NAMES:445+ raise PolicyError(446+ "demo-gate step list or order changed from the CI policy contract"447+ )448+449+ if checkout.field_names != {"name", "uses"}:450+ raise PolicyError("checkout step has unexpected or missing fields")451+ if checkout.scalars.get("uses") != CHECKOUT_ACTION:452+ raise PolicyError("checkout action is not digest-pinned to the policy contract")453+454+ if dependency.field_names != {"name", "run"}:455+ raise PolicyError("Linux dependency step has unexpected or missing fields")456+ if dependency.blocks.get("run") != DEPENDENCY_RUN:457+ raise PolicyError("Linux dependency step body changed")458+459+ if cache.field_names != {"name", "uses", "with"}:460+ raise PolicyError("cargo-zigbuild cache step has unexpected or missing fields")461+ if cache.scalars.get("uses") != CACHE_ACTION:462+ raise PolicyError("cargo-zigbuild cache action is not digest-pinned")463+ if cache.mappings.get("with") != {"path": CACHE_PATH, "key": CACHE_KEY}:464+ raise PolicyError("cargo-zigbuild cache contract changed")465+466+ if install.field_names != {"name", "run"}:467+ raise PolicyError("cross-build install step has unexpected or missing fields")468+ if install.blocks.get("run") != INSTALL_RUN:469+ raise PolicyError("cross-build install step body changed")470+471+ if gate.field_names != {"name", "env", "run"}:472+ raise PolicyError("complete demo gate step has unexpected or missing fields")473+ if gate.mappings.get("env") != {474+ "GOQ_VERIFY_IN_PROCESS_GSTREAMER": "1",475+ "GOQ_REQUIRE_LINUX_CROSS_BUILD": "1",476+ }:477+ raise PolicyError("complete demo gate does not require the Linux cross build")478+ if gate.scalars.get("run") != "./scripts/verify-demo-build.sh":479+ raise PolicyError("complete demo gate does not execute the repository gate")480+481+ if [482+ checkout_index,483+ dependency_index,484+ cache_index,485+ install_index,486+ gate_index,487+ ] != list(range(len(REQUIRED_STEP_NAMES))):488+ raise PolicyError(489+ "demo-gate step list or order changed from the CI policy contract"490+ )491+492+493+def main() -> int:494+ parser = argparse.ArgumentParser()495+ parser.add_argument("workflow", type=Path)496+ parser.add_argument("gate_script", type=Path)497+ args = parser.parse_args()498+ try:499+ verify(args.workflow.read_text(encoding="utf-8"))500+ verify_gate_script(args.gate_script.read_text(encoding="utf-8"))501+ except (OSError, PolicyError) as error:502+ print(f"CI cross-build policy failed: {error}", file=sys.stderr)503+ return 1504+ print("ci_cross_build_policy=ok")505+ return 0506+507+508+if __name__ == "__main__":509+ raise SystemExit(main())
M src-tauri/Cargo.toml +3 -0
M src-tauri/src/commands/state.rs +135 -11
@@ -44,13 +44,31 @@ const fn should_apply_native_cursor_association(current: bool, requested: bool)
4444 requested || current != requested4545 }4646 47-#[cfg(not(target_os = "macos"))]47+#[cfg(all(48+ not(target_os = "macos"),49+ feature = "experimental-non-macos-pointer-capture"50+))]4851 fn set_native_cursor_grab(window: &tauri::WebviewWindow, grab: bool) -> Result<(), String> {4952 window5053 .set_cursor_grab(grab)5154 .map_err(|error| format!("Could not set native cursor grab to {grab}: {error}"))5255 }5356 57+#[cfg(all(58+ not(target_os = "macos"),59+ not(feature = "experimental-non-macos-pointer-capture")60+))]61+fn set_native_cursor_grab(_window: &tauri::WebviewWindow, grab: bool) -> Result<(), String> {62+ if grab {63+ return Err(64+ "Relative pointer capture is unavailable on this Portal build; rebuild with the \65+ experimental-non-macos-pointer-capture feature for platform UAT"66+ .to_string(),67+ );68+ }69+ Ok(())70+}71+5472 #[cfg(target_os = "macos")]5573 fn set_macos_cursor_hidden(hidden: bool) -> Result<(), String> {5674 let mut depth = MACOS_CURSOR_HIDE_DEPTH
@@ -262,12 +280,21 @@ pub const fn development_direct_node_available() -> bool {
262280 cfg!(any(debug_assertions, feature = "demo-direct-node"))263281 }264282 283+pub const fn development_force_jpeg_available() -> bool {284+ cfg!(debug_assertions)285+}286+265287 #[derive(Clone, Debug, Default, PartialEq, Eq)]266288 pub struct LaunchOptions {267289 pub dev_connect_node_id: Option<iroh::PublicKey>,290+ pub dev_force_jpeg: bool,268291 }269292 270-pub fn parse_launch_options<I, S>(args: I, allow_dev_connect: bool) -> Result<LaunchOptions, String>293+pub fn parse_launch_options<I, S>(294+ args: I,295+ allow_dev_connect: bool,296+ allow_force_jpeg: bool,297+) -> Result<LaunchOptions, String>271298 where272299 I: IntoIterator<Item = S>,273300 S: Into<OsString>,
@@ -289,6 +316,19 @@ where
289316 let arg = arg290317 .into_string()291318 .map_err(|_| "Portal command-line arguments must be valid UTF-8".to_string())?;319+ if arg == "--dev-force-jpeg" {320+ if !allow_force_jpeg {321+ return Err(322+ "--dev-force-jpeg requires a debug build; ordinary release builds use the normal WebCodecs capability probe"323+ .to_string(),324+ );325+ }326+ if options.dev_force_jpeg {327+ return Err("--dev-force-jpeg may be specified only once".to_string());328+ }329+ options.dev_force_jpeg = true;330+ continue;331+ }292332 let node_id = if arg == "--dev-connect" {293333 Some(294334 args.next()
@@ -342,6 +382,7 @@ pub struct AppState {
342382 pub client_connection_active: Arc<AtomicBool>,343383 pub webcodecs: AtomicBool,344384 pub dev_connect_node_id: Option<iroh::PublicKey>,385+ pub dev_force_jpeg: bool,345386 }346387 347388 pub type MediaControlRequestSender =
@@ -443,7 +484,12 @@ impl AppState {
443484 I: IntoIterator<Item = S>,444485 S: Into<OsString>,445486 {446- parse_launch_options(args, development_direct_node_available()).map(Self::new)487+ parse_launch_options(488+ args,489+ development_direct_node_available(),490+ development_force_jpeg_available(),491+ )492+ .map(Self::new)447493 }448494 449495 fn new(options: LaunchOptions) -> Self {
@@ -464,6 +510,7 @@ impl AppState {
464510 client_connection_active: Arc::new(AtomicBool::new(false)),465511 webcodecs: AtomicBool::new(false),466512 dev_connect_node_id: options.dev_connect_node_id,513+ dev_force_jpeg: options.dev_force_jpeg,467514 }468515 }469516 }
@@ -475,6 +522,8 @@ pub struct DevelopmentConnectionMode {
475522 pub enabled: bool,476523 pub host_node_id: Option<String>,477524 pub warning: &'static str,525+ pub force_jpeg: bool,526+ pub jpeg_warning: &'static str,478527 }479528 480529 #[tauri::command]
@@ -483,6 +532,8 @@ pub fn development_connection_mode(state: tauri::State<'_, AppState>) -> Develop
483532 enabled: state.dev_connect_node_id.is_some(),484533 host_node_id: state.dev_connect_node_id.map(|node_id| node_id.to_string()),485534 warning: "Development direct-node routing skips passkey identity lookup; it is not client authorization.",535+ force_jpeg: state.dev_force_jpeg,536+ jpeg_warning: "Development JPEG compatibility mode is forced; WebCodecs remains probed but is not selected.",486537 }487538 }488539
@@ -567,9 +618,15 @@ pub fn set_client_cursor_grab(window: tauri::WebviewWindow, grab: bool) -> Resul
567618 .map(|()| !cfg!(target_os = "macos"))568619 }569620 621+const fn effective_webcodecs_available(available: bool, force_jpeg: bool) -> bool {622+ available && !force_jpeg623+}624+570625 #[tauri::command]571-pub fn set_webcodecs_available(state: tauri::State<'_, AppState>, available: bool) {572- state.webcodecs.store(available, Ordering::SeqCst);626+pub fn set_webcodecs_available(state: tauri::State<'_, AppState>, available: bool) -> bool {627+ let effective = effective_webcodecs_available(available, state.dev_force_jpeg);628+ state.webcodecs.store(effective, Ordering::SeqCst);629+ effective573630 }574631 575632 #[tauri::command]
@@ -691,14 +748,41 @@ mod tests {
691748 #[test]692749 fn parses_debug_direct_node_flag() {693750 let node_id = test_node_id();694- let options = parse_launch_options(["portal", "--dev-connect", &node_id], true).unwrap();751+ let options =752+ parse_launch_options(["portal", "--dev-connect", &node_id], true, true).unwrap();695753 696754 assert_eq!(options.dev_connect_node_id.unwrap().to_string(), node_id);697755 }698756 757+ #[test]758+ fn parses_debug_force_jpeg_flag() {759+ let options = parse_launch_options(["portal", "--dev-force-jpeg"], true, true).unwrap();760+761+ assert!(options.dev_force_jpeg);762+ }763+764+ #[test]765+ fn parses_combined_debug_hardware_proof_flags() {766+ let node_id = test_node_id();767+ let options = parse_launch_options(768+ [769+ "portal",770+ "--dev-connect",771+ node_id.as_str(),772+ "--dev-force-jpeg",773+ ],774+ true,775+ true,776+ )777+ .unwrap();778+779+ assert_eq!(options.dev_connect_node_id.unwrap().to_string(), node_id);780+ assert!(options.dev_force_jpeg);781+ }782+699783 #[test]700784 fn rejects_legacy_daemon_mode() {701- let error = parse_launch_options(["portal", "--daemon"], true).unwrap_err();785+ let error = parse_launch_options(["portal", "--daemon"], true, true).unwrap_err();702786 assert!(error.contains("separate sigil executable"));703787 }704788
@@ -706,7 +790,7 @@ mod tests {
706790 fn parses_equals_form() {707791 let node_id = test_node_id();708792 let flag = format!("--dev-connect={node_id}");709- let options = parse_launch_options(["portal", &flag], true).unwrap();793+ let options = parse_launch_options(["portal", &flag], true, true).unwrap();710794 711795 assert_eq!(options.dev_connect_node_id.unwrap().to_string(), node_id);712796 }
@@ -714,11 +798,19 @@ mod tests {
714798 #[test]715799 fn rejects_direct_node_when_debug_mode_is_disabled() {716800 let node_id = test_node_id();717- let error = parse_launch_options(["portal", "--dev-connect", &node_id], false).unwrap_err();801+ let error =802+ parse_launch_options(["portal", "--dev-connect", &node_id], false, true).unwrap_err();718803 719804 assert!(error.contains("debug build or the explicit demo-direct-node feature"));720805 }721806 807+ #[test]808+ fn rejects_force_jpeg_when_debug_mode_is_disabled() {809+ let error = parse_launch_options(["portal", "--dev-force-jpeg"], true, false).unwrap_err();810+811+ assert!(error.contains("requires a debug build"));812+ }813+722814 #[test]723815 fn ordinary_release_excludes_direct_node_bypass() {724816 if cfg!(not(debug_assertions)) && cfg!(not(feature = "demo-direct-node")) {
@@ -734,6 +826,28 @@ mod tests {
734826 assert_eq!(result.is_ok(), development_direct_node_available());735827 }736828 829+ #[test]830+ fn ordinary_release_excludes_force_jpeg_override() {831+ if cfg!(not(debug_assertions)) {832+ assert!(!development_force_jpeg_available());833+ }834+ }835+836+ #[test]837+ fn app_state_accepts_force_jpeg_only_in_debug_builds() {838+ let result = AppState::from_args(["portal", "--dev-force-jpeg"]);839+840+ assert_eq!(result.is_ok(), development_force_jpeg_available());841+ }842+843+ #[test]844+ fn forced_jpeg_publication_keeps_native_and_frontend_mode_aligned() {845+ assert!(effective_webcodecs_available(true, false));846+ assert!(!effective_webcodecs_available(true, true));847+ assert!(!effective_webcodecs_available(false, false));848+ assert!(!effective_webcodecs_available(false, true));849+ }850+737851 #[test]738852 fn app_state_starts_without_owned_transport_connections() {739853 let state = AppState::default();
@@ -748,12 +862,12 @@ mod tests {
748862 #[test]749863 fn rejects_missing_invalid_and_duplicate_node_ids() {750864 assert!(751- parse_launch_options(["portal", "--dev-connect"], true)865+ parse_launch_options(["portal", "--dev-connect"], true, true)752866 .unwrap_err()753867 .contains("requires an iroh node ID")754868 );755869 assert!(756- parse_launch_options(["portal", "--dev-connect", "not-a-node-id"], true)870+ parse_launch_options(["portal", "--dev-connect", "not-a-node-id"], true, true)757871 .unwrap_err()758872 .contains("Invalid iroh node ID")759873 );
@@ -769,6 +883,16 @@ mod tests {
769883 &node_id,770884 ],771885 true,886+ true,887+ )888+ .unwrap_err()889+ .contains("only once")890+ );891+ assert!(892+ parse_launch_options(893+ ["portal", "--dev-force-jpeg", "--dev-force-jpeg"],894+ true,895+ true,772896 )773897 .unwrap_err()774898 .contains("only once")
M src-tauri/src/main.rs +1 -0
M src-tauri/src/media/input_delivery.rs +157 -34
@@ -12,6 +12,7 @@ use tauri::ipc::Channel;
1212 use crate::commands::state::AppState;1313 use crate::media::network_diagnostics::{NetworkSessionDiagnostics, lock_network_diagnostics};1414 use crate::media::transport::{NegotiatedV1Stream, negotiate_v1};15+use crate::platform_capabilities::relative_pointer_capture_enabled;1516 1617 const INPUT_ALPN: &[u8] = sigil_protocol::LEGACY_INPUT_ALPN_V0;1718 pub(crate) const CLIENT_INPUT_QUEUE_CAPACITY: usize = 256;
@@ -154,39 +155,48 @@ async fn open_negotiated_input_stream(
154155 Ok((connection, send, recv, negotiation))155156 }156157 157-fn input_capability_offers(grants: InvitationGrants) -> Vec<Vec<Capability>> {158- let base = [159- vec![160- Capability::RelativePointer,161- Capability::PointerPositionFeedback,162- Capability::PointerVisibilityFeedback,163- Capability::AbsolutePointer,164- Capability::Keyboard,165- Capability::Text,166- Capability::Gamepad,167- ],168- vec![169- Capability::RelativePointer,170- Capability::PointerPositionFeedback,171- Capability::AbsolutePointer,172- Capability::Keyboard,173- Capability::Text,174- Capability::Gamepad,175- ],176- vec![177- Capability::RelativePointer,178- Capability::AbsolutePointer,179- Capability::Keyboard,180- Capability::Text,181- Capability::Gamepad,182- ],183- vec![184- Capability::AbsolutePointer,185- Capability::Keyboard,186- Capability::Text,187- Capability::Gamepad,188- ],189- ];158+fn input_capability_offers_for(159+ grants: InvitationGrants,160+ relative_pointer_capture: bool,161+) -> Vec<Vec<Capability>> {162+ // Keep the relative compatibility ladder ordered from newest to oldest.163+ // When local relative capture is unavailable, deliberately collapse it to164+ // only the inherited absolute-pointer offer.165+ let mut base = Vec::with_capacity(if relative_pointer_capture { 4 } else { 1 });166+ if relative_pointer_capture {167+ base.extend([168+ vec![169+ Capability::RelativePointer,170+ Capability::PointerPositionFeedback,171+ Capability::PointerVisibilityFeedback,172+ Capability::AbsolutePointer,173+ Capability::Keyboard,174+ Capability::Text,175+ Capability::Gamepad,176+ ],177+ vec![178+ Capability::RelativePointer,179+ Capability::PointerPositionFeedback,180+ Capability::AbsolutePointer,181+ Capability::Keyboard,182+ Capability::Text,183+ Capability::Gamepad,184+ ],185+ vec![186+ Capability::RelativePointer,187+ Capability::AbsolutePointer,188+ Capability::Keyboard,189+ Capability::Text,190+ Capability::Gamepad,191+ ],192+ ]);193+ }194+ base.push(vec![195+ Capability::AbsolutePointer,196+ Capability::Keyboard,197+ Capability::Text,198+ Capability::Gamepad,199+ ]);190200 let has_input_grant = grants.contains(InvitationGrants::POINTER_KEYBOARD)191201 || grants.contains(InvitationGrants::GAMEPAD);192202 let mut offers = Vec::with_capacity(base.len() * 2);
@@ -216,6 +226,27 @@ fn input_capability_offers(grants: InvitationGrants) -> Vec<Vec<Capability>> {
216226 offers217227 }218228 229+fn input_capability_offers(grants: InvitationGrants) -> Vec<Vec<Capability>> {230+ input_capability_offers_for(grants, relative_pointer_capture_enabled())231+}232+233+fn mask_unavailable_relative_pointer_capabilities(234+ mut capabilities: Vec<Capability>,235+ relative_pointer_capture: bool,236+) -> Vec<Capability> {237+ if !relative_pointer_capture {238+ capabilities.retain(|capability| {239+ !matches!(240+ capability,241+ Capability::RelativePointer242+ | Capability::PointerPositionFeedback243+ | Capability::PointerVisibilityFeedback244+ )245+ });246+ }247+ capabilities248+}249+219250 fn input_event_allowed(capabilities: &[Capability], event: &InputEvent) -> bool {220251 match event {221252 InputEvent::Probe => capabilities.contains(&Capability::InputAck),
@@ -335,6 +366,12 @@ pub(crate) async fn open_input_session(
335366 ],336367 )337368 };369+ // A malformed or future host must not be able to re-enable a local input370+ // path that this Portal build deliberately withheld from its offers.371+ let capabilities = mask_unavailable_relative_pointer_capabilities(372+ capabilities,373+ relative_pointer_capture_enabled(),374+ );338375 let availability = InputAvailability::from_capabilities(&capabilities);339376 Ok(InputSession {340377 connection,
@@ -794,7 +831,7 @@ mod tests {
794831 795832 #[test]796833 fn input_capability_fallbacks_remove_only_one_protocol_extension_at_a_time() {797- let offers = input_capability_offers(InvitationGrants::ALL);834+ let offers = input_capability_offers_for(InvitationGrants::ALL, true);798835 assert_eq!(offers.len(), 8);799836 let visibility = &offers[0];800837 let position = &offers[1];
@@ -831,6 +868,92 @@ mod tests {
831868 );832869 }833870 871+ #[test]872+ fn disabled_relative_capture_preserves_only_non_relative_input_offers() {873+ let offers = input_capability_offers_for(InvitationGrants::ALL, false);874+ assert_eq!(875+ offers,876+ [877+ vec![878+ Capability::AbsolutePointer,879+ Capability::Keyboard,880+ Capability::Text,881+ Capability::Gamepad,882+ Capability::InputAck,883+ ],884+ vec![885+ Capability::AbsolutePointer,886+ Capability::Keyboard,887+ Capability::Text,888+ Capability::Gamepad,889+ ],890+ ]891+ );892+ assert!(offers.iter().flatten().all(|capability| !matches!(893+ capability,894+ Capability::RelativePointer895+ | Capability::PointerPositionFeedback896+ | Capability::PointerVisibilityFeedback897+ )));898+ }899+900+ #[test]901+ fn compiled_pointer_policy_controls_the_actual_negotiation_offers() {902+ let offers = input_capability_offers(InvitationGrants::ALL);903+ let offers_relative_pointer = offers904+ .iter()905+ .flatten()906+ .any(|capability| *capability == Capability::RelativePointer);907+ assert_eq!(offers_relative_pointer, relative_pointer_capture_enabled());908+ assert!(909+ offers910+ .iter()911+ .all(|offer| offer.contains(&Capability::AbsolutePointer))912+ );913+ }914+915+ #[test]916+ fn negotiated_capability_mask_fails_closed_without_losing_other_input() {917+ let accepted = vec![918+ Capability::RelativePointer,919+ Capability::PointerPositionFeedback,920+ Capability::PointerVisibilityFeedback,921+ Capability::AbsolutePointer,922+ Capability::Keyboard,923+ Capability::Text,924+ Capability::Gamepad,925+ Capability::InputAck,926+ ];927+ let masked = mask_unavailable_relative_pointer_capabilities(accepted.clone(), false);928+ assert_eq!(929+ masked,930+ [931+ Capability::AbsolutePointer,932+ Capability::Keyboard,933+ Capability::Text,934+ Capability::Gamepad,935+ Capability::InputAck,936+ ]937+ );938+ assert_eq!(939+ mask_unavailable_relative_pointer_capabilities(accepted.clone(), true),940+ accepted941+ );942+ assert_eq!(943+ InputAvailability::from_capabilities(&masked),944+ InputAvailability {945+ relative_pointer: false,946+ pointer_position_feedback: false,947+ absolute_pointer: true,948+ keyboard: true,949+ text: true,950+ gamepad: true,951+ input_ack: true,952+ control: true,953+ }954+ );955+ }956+834957 #[test]835958 fn local_invitation_grants_bound_input_offers_before_the_host_intersection() {836959 let view_only = input_capability_offers(InvitationGrants::VIEW);
A src-tauri/src/platform_capabilities.rs +37 -0

Not shown: this page already shows 6000 lines of diff.

M website/install-sigil +2 -1

Not shown: this page already shows 6000 lines of diff.