cat / goq
clone

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

feat: enforce multi-viewer acceptance bounds

Dami committed b1e8e2a
commit
b1e8e2aec122e6bc5f452bde03e02c1b64b18e24
parent
0f1134c9251d804860bdf938ec01afd68455bfda
author
Dami <dami.etoile@gmail.com>
40 files changed, +1555 -185
MREADME.md38++-
Mcrates/sigil-host/src/appliance.rs273+++++++++++++++++++++--
Mcrates/sigil-host/src/authorization.rs1+
Mcrates/sigil-host/src/bin/sigil-probe.rs47+++-
Mcrates/sigil-host/src/bin/sigil-relay-spike.rs11+-
Mcrates/sigil-host/src/main.rs13+-
Mcrates/sigil-host/src/server/adaptive.rs35++-
Mcrates/sigil-host/src/server/focus.rs8+
Mcrates/sigil-host/src/server/generation.rs30++-
Mcrates/sigil-host/src/server/mod.rs3+-
Mcrates/sigil-host/src/server/moq.rs2+
Mcrates/sigil-host/src/server/session.rs351+++++++++++++++++++++++++++++-
Mcrates/sigil-protocol/src/control_v2.rs13+-
Mdocs/fresh-bazzite-host.md50+++-
Mdocs/hardware-uat/MATRIX.md8+-
Mdocs/portal-platform-support.md13+
Mdocs/public-alpha-uat.md62++++-
Mdocs/sigil-appliance-status-v2.md21+-
Mportal/frame-stats.test.mjs27++-
Mportal/network-diagnostics.mjs10+-
Mportal/network-diagnostics.test.mjs19+-
Mportal/runtime-files.txt1+
Aportal/session-diagnostics.mjs79+++++++
Aportal/session-diagnostics.test.mjs65++++++
Mscripts/loopback-proof.sh147+++++++++++--
Mscripts/public-alpha-uat.sh65+++++-
Mscripts/relay-spike-proof.sh3+-
Mscripts/run-bazzite-hardware-uat.sh64+++++-
Mscripts/run-shell-tests.sh7+-
Mscripts/tests/authenticated-probe-proof.sh16+-
Ascripts/tests/bazzite-hardware-uat-multi-viewer.sh34+++
Mscripts/tests/public-alpha-uat.sh23++-
Mscripts/tests/sigil-appliance-status.sh4+
Mscripts/verify-demo-build.sh4+
Msrc-tauri/src/media/connect.rs23++-
Msrc-tauri/src/media/input_delivery.rs1+
Msrc-tauri/src/media/metrics.rs6+-
Msrc-tauri/src/media/network_diagnostics.rs149++++++++++++-
Msrc-tauri/src/media/session_control.rs13+
Msrc-tauri/src/media/transport.rs1+
M README.md +24 -14
@@ -10,8 +10,10 @@ sitting at the machine, not the latency of a screen-sharing tool.
1010 goq is not a multi-tenant streaming service and not a desktop-sharing tool.1111 It streams a private [Gamescope](https://github.com/ValveSoftware/gamescope)1212 session that exists only for your games: the host needs no monitor, no13-desktop environment, and no interactive login. Exactly one client is admitted14-to exactly one session at a time, authenticated by a hardware security key.13+desktop environment, and no interactive login. Native MoQ admits three viewers14+by default, up to a hard ceiling of eight. Every viewer may watch according to15+its grants, while exactly one viewer at a time may hold slot-0 input focus.16+Legacy control/media clients remain strictly exclusive.1517 1618 ## The two programs1719
@@ -40,13 +42,14 @@ Sigil · bare-metal Linux host Portal · installed desktop client
4042 buffered, so a slow receiver never accumulates a playable backlog. Media4143 travels as bounded MoQ groups: one GOP is one group, and a newer4244 independently decodable group cancels its predecessor.43-- **Input is independent.** Controller, keyboard, and relative mouse travel45+- **Input is independent and focused.** Controller, keyboard, and relative mouse travel4446 on their own iroh connection, unaffected by media backpressure. Session45- teardown always releases held keys and emits a neutral gamepad state, even46- after an error or disconnect.47+ focus is a revocable token rather than a viewing right. Every handoff first48+ invalidates the former focus generation, then releases held keys and emits a49+ neutral gamepad state before a successor can inject.4750 - **Fail closed.** Portal derives its stable iroh identity from a FIDO2 key4851 with `hmac-secret`. A signed, short-lived, peer-bound invitation from Sigil49- enrolls that one client once, with independently granted view,52+ enrolls each bounded viewer once, with independently granted view,5053 pointer/keyboard, and gamepad permissions and durable replay protection.5154 Every ordinary launch after that is **PIN -> tap -> play**.5255 - **Strict product boundary.** Sigil never depends on Tauri or a webview.
@@ -64,9 +67,10 @@ Working today:
6467 - The pure Rust Sigil daemon: Gamescope PipeWire capture at the display's6568 native mode, AMD GstVA H.264 encoding, and bounded PipeWire/Opus audio.6669 - Authenticated session admission over a control connection, then a67- session-scoped upstream `iroh-moq` broadcast with a strictly validated68- catalog, with earlier custom media protocols kept as explicit compatibility69- fallbacks.70+ shared, authenticated upstream `iroh-moq` generation with a strictly71+ validated catalog. Multiple viewers share one video/audio producer and one72+ adaptive actuator; earlier custom media protocols remain exclusive73+ compatibility fallbacks.7074 - Adaptive bitrate and motion-sensitive resolution on the opt-in in-process7175 encoder backend: session-authenticated receiver feedback, hysteresis, and7276 changes that commit only on exact encoder readback or a target-size IDR.
@@ -75,7 +79,8 @@ Working today:
7579 - Linux `uinput` keyboard, relative mouse, and an Xbox-style virtual gamepad,7680 with strict device preflight and end-of-session neutralization.7781 - One-time FIDO-bound enrollment, replay protection, and controller-usable78- onboarding in Portal.82+ onboarding in Portal, with bounded multi-peer authorization and live view or83+ input revocation.7984 - A deterministic, checksum-bound Bazzite runtime package with serialized8085 install/upgrade and tamper-checked rollback, plus the foundation of a8186 controller-first Decky Loader management plugin over a redacted local
@@ -137,7 +142,7 @@ hardware report; add `--smoke` to exercise a bounded 1280x800/60 VA-API
137142 encode, or `--cold-boot` on the first login after a physically headless boot138143 for a strict readiness gate.139144 140-### Enroll one Portal145+### Enroll a Portal141146 142147 On Portal's first launch, enter the security-key PIN and choose **show portal143148 id**. On the Sigil host, create an invitation for that exact peer:
@@ -152,7 +157,10 @@ sigil invitation create \
152157 ```153158 154159 Move the owner-only invitation file to the client and open it with Portal.155-Sigil consumes it once; every later launch is **PIN -> tap -> play**. To move160+Sigil consumes it once; every later launch is **PIN -> tap -> play**. Repeat161+with a distinct invitation for each viewer. A view-only invitation creates a162+spectator with no focus action; pointer/keyboard or gamepad grants make a163+viewer eligible to request slot-0 focus but do not grant possession. To move156164 Portal to a different Sigil host, revoke the enrollment on the old host and157165 use **client -> reset enrollment**. A new invitation never silently replaces158166 a working enrollment.
@@ -166,8 +174,10 @@ Run the complete repository gate before sharing work:
166174 ```167175 168176 It covers Rust format, tests, and clippy, the Linux cross-build when169-available, frontend syntax and tests, ShellCheck, package tests, and loopback170-transport proof. Website and installer changes go through177+available, frontend syntax and tests, ShellCheck, package tests, a three-viewer178+native-MoQ loopback proof, and a separate legacy-exclusive proof. The explicit179+eight-viewer release stress gate remains a local resource test, and neither180+loopback mode substitutes for exact-candidate hardware UAT. Website and installer changes go through171181 `./scripts/verify-website.sh`. Hardware acceptance evidence lives under172182 [`docs/hardware-uat/`](docs/hardware-uat/README.md), separate from local173183 tests.
M crates/sigil-host/src/appliance.rs +244 -29
@@ -13,7 +13,7 @@ use crate::authorization::{AuthorizationInspection, AuthorizationStore};
1313 use crate::config::{ConfigRevision, HostConfig};1414 use crate::config_management::ConfigTransactionSummaryV1;1515 use crate::secure_state;16-use crate::server::SessionRegistry;16+use crate::server::{MediaGenerationManager, SessionRegistry};1717 1818 const LIFECYCLE_LOCK_FILE: &str = "daemon-v1.lock";1919 const GLOBAL_LIFECYCLE_LOCK_FILE: &str = "daemon-global-v1.lock";
@@ -21,7 +21,7 @@ const RUNTIME_STATUS_FILE: &str = "daemon-status-v1.json";
2121 // Frozen local-management ABI consumed by Sigil commands and the Decky2222 // plugin. A successor needs an explicit migration; never rename this in place.2323 const RUNTIME_DIRECTORY_COMPONENT: &str = "sigil-spark";24-const RUNTIME_STATUS_VERSION: u16 = 2;24+const RUNTIME_STATUS_VERSION: u16 = 3;2525 const MAX_RUNTIME_STATUS_BYTES: u64 = 16 * 1024;2626 const HEARTBEAT_INTERVAL: Duration = Duration::from_secs(2);2727 const STALE_AFTER: Duration = Duration::from_secs(10);
@@ -164,6 +164,29 @@ struct RuntimeStatusV2 {
164164 last_error: Option<RuntimeErrorV1>,165165 }166166 167+#[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)]168+#[serde(deny_unknown_fields)]169+struct RuntimeStatusV3 {170+ version: u16,171+ instance_id: String,172+ host_node_id: String,173+ loaded_config_revision: Option<ConfigRevision>,174+ reached_ready: bool,175+ updated_at_unix_ms: u64,176+ uptime_ms: u64,177+ daemon: DaemonState,178+ session_active: bool,179+ session_mode: String,180+ active_viewer_count: usize,181+ media_generation_id: Option<u64>,182+ roster_revision: Option<u64>,183+ focus_occupied: Option<bool>,184+ recovering_viewer_count: usize,185+ configured_viewer_capacity: usize,186+ authorization_revision: u64,187+ last_error: Option<RuntimeErrorV1>,188+}189+167190 #[derive(Debug, Serialize)]168191 pub struct ApplianceStatusV2 {169192 schema_version: u16,
@@ -230,6 +253,14 @@ struct RuntimeStatus {
230253 instance_id: Option<String>,231254 loaded_config_revision: Option<ConfigRevision>,232255 reached_ready: bool,256+ mode: Option<String>,257+ active_viewer_count: Option<usize>,258+ media_generation_id: Option<u64>,259+ roster_revision: Option<u64>,260+ focus_occupied: Option<bool>,261+ recovering_viewer_count: Option<usize>,262+ configured_viewer_capacity: Option<usize>,263+ authorization_revision: Option<u64>,233264 last_error: Option<RuntimeErrorV1>,234265 }235266
@@ -243,6 +274,7 @@ pub struct RuntimePublisher {
243274 reached_ready: Arc<AtomicBool>,244275 last_error: Arc<Mutex<Option<RuntimeErrorV1>>>,245276 sessions: Arc<SessionRegistry>,277+ generations: Arc<MediaGenerationManager>,246278 write_lock: Arc<tokio::sync::Mutex<()>>,247279 task: Option<tokio::task::JoinHandle<()>>,248280 }
@@ -251,6 +283,7 @@ impl RuntimePublisher {
251283 pub fn start(252284 host: EndpointId,253285 sessions: Arc<SessionRegistry>,286+ generations: Arc<MediaGenerationManager>,254287 require_runtime: bool,255288 loaded_config_revision: Option<ConfigRevision>,256289 ) -> Result<Option<Self>> {
@@ -267,13 +300,21 @@ impl RuntimePublisher {
267300 !require_runtime || loaded_config_revision.is_some(),268301 "configured Sigil service requires a loaded config revision"269302 );270- Self::start_at(&state_directory, host, sessions, loaded_config_revision).map(Some)303+ Self::start_at(304+ &state_directory,305+ host,306+ sessions,307+ generations,308+ loaded_config_revision,309+ )310+ .map(Some)271311 }272312 273313 fn start_at(274314 state_directory: &Path,275315 host: EndpointId,276316 sessions: Arc<SessionRegistry>,317+ generations: Arc<MediaGenerationManager>,277318 loaded_config_revision: Option<ConfigRevision>,278319 ) -> Result<Self> {279320 let instance_id = random_instance_id()?;
@@ -291,6 +332,7 @@ impl RuntimePublisher {
291332 reached_ready,292333 last_error,293334 sessions,335+ generations,294336 write_lock,295337 task: None,296338 };
@@ -305,6 +347,7 @@ impl RuntimePublisher {
305347 let reached_ready = Arc::clone(&publisher.reached_ready);306348 let last_error = Arc::clone(&publisher.last_error);307349 let sessions = Arc::clone(&publisher.sessions);350+ let generations = Arc::clone(&publisher.generations);308351 let write_lock = Arc::clone(&publisher.write_lock);309352 let task = tokio::spawn(async move {310353 let mut interval = tokio::time::interval(HEARTBEAT_INTERVAL);
@@ -318,7 +361,8 @@ impl RuntimePublisher {
318361 let loaded_config_revision = loaded_config_revision.clone();319362 let daemon = decode_daemon_state(daemon_state.load(Ordering::Relaxed));320363 let reached_ready = reached_ready.load(Ordering::Relaxed);321- let session_active = sessions.has_session();364+ let session = sessions.runtime_status();365+ let recovering_viewer_count = generations.recovering_viewer_count();322366 let last_error = last_error323367 .lock()324368 .expect("runtime status error state poisoned")
@@ -332,7 +376,8 @@ impl RuntimePublisher {
332376 started,333377 daemon,334378 reached_ready,335- session_active,379+ session,380+ recovering_viewer_count,336381 last_error,337382 })338383 })
@@ -390,7 +435,8 @@ impl RuntimePublisher {
390435 let loaded_config_revision = self.loaded_config_revision.clone();391436 let started = self.started;392437 let reached_ready = self.reached_ready.load(Ordering::Relaxed);393- let session_active = self.sessions.has_session();438+ let session = self.sessions.runtime_status();439+ let recovering_viewer_count = self.generations.recovering_viewer_count();394440 let last_error = self395441 .last_error396442 .lock()
@@ -405,7 +451,8 @@ impl RuntimePublisher {
405451 started,406452 daemon: DaemonState::Stopped,407453 reached_ready,408- session_active,454+ session,455+ recovering_viewer_count,409456 last_error,410457 })411458 })
@@ -422,7 +469,8 @@ impl RuntimePublisher {
422469 started: self.started,423470 daemon: decode_daemon_state(self.daemon_state.load(Ordering::Relaxed)),424471 reached_ready: self.reached_ready.load(Ordering::Relaxed),425- session_active: self.sessions.has_session(),472+ session: self.sessions.runtime_status(),473+ recovering_viewer_count: self.generations.recovering_viewer_count(),426474 last_error: self427475 .last_error428476 .lock()
@@ -441,7 +489,8 @@ impl RuntimePublisher {
441489 let started = self.started;442490 let daemon = decode_daemon_state(self.daemon_state.load(Ordering::Relaxed));443491 let reached_ready = self.reached_ready.load(Ordering::Relaxed);444- let session_active = self.sessions.has_session();492+ let session = self.sessions.runtime_status();493+ let recovering_viewer_count = self.generations.recovering_viewer_count();445494 let last_error = self446495 .last_error447496 .lock()
@@ -456,7 +505,8 @@ impl RuntimePublisher {
456505 started,457506 daemon,458507 reached_ready,459- session_active,508+ session,509+ recovering_viewer_count,460510 last_error,461511 })462512 })
@@ -544,6 +594,14 @@ pub fn status_json(status: &ApplianceStatusV2, schema_version: u16) -> Result<se
544594 runtime.remove("instance_id");545595 runtime.remove("loaded_config_revision");546596 runtime.remove("reached_ready");597+ runtime.remove("mode");598+ runtime.remove("active_viewer_count");599+ runtime.remove("media_generation_id");600+ runtime.remove("roster_revision");601+ runtime.remove("focus_occupied");602+ runtime.remove("recovering_viewer_count");603+ runtime.remove("configured_viewer_capacity");604+ runtime.remove("authorization_revision");547605 let enrollment = value["enrollment"]548606 .as_object_mut()549607 .context("appliance enrollment status is not an object")?;
@@ -728,11 +786,19 @@ fn read_runtime_status(
728786 instance_id: Some(persisted.instance_id),729787 loaded_config_revision: persisted.loaded_config_revision,730788 reached_ready: persisted.reached_ready,789+ mode: Some(persisted.session_mode),790+ active_viewer_count: Some(persisted.active_viewer_count),791+ media_generation_id: persisted.media_generation_id,792+ roster_revision: persisted.roster_revision,793+ focus_occupied: persisted.focus_occupied,794+ recovering_viewer_count: Some(persisted.recovering_viewer_count),795+ configured_viewer_capacity: Some(persisted.configured_viewer_capacity),796+ authorization_revision: Some(persisted.authorization_revision),731797 last_error: persisted.last_error,732798 })733799 }734800 735-fn read_persisted_runtime(state_directory: &Path) -> Result<Option<RuntimeStatusV2>> {801+fn read_persisted_runtime(state_directory: &Path) -> Result<Option<RuntimeStatusV3>> {736802 let Some(bytes) = secure_state::read_bounded(737803 state_directory,738804 RUNTIME_STATUS_FILE,
@@ -751,8 +817,8 @@ fn read_persisted_runtime(state_directory: &Path) -> Result<Option<RuntimeStatus
751817 1 => {752818 let legacy: RuntimeStatusV1 =753819 serde_json::from_value(value).context("parsing legacy Sigil runtime status")?;754- Ok(Some(RuntimeStatusV2 {755- version: legacy.version,820+ Ok(Some(RuntimeStatusV3 {821+ version: RUNTIME_STATUS_VERSION,756822 instance_id: legacy.instance_id,757823 host_node_id: legacy.host_node_id,758824 loaded_config_revision: None,
@@ -761,22 +827,110 @@ fn read_persisted_runtime(state_directory: &Path) -> Result<Option<RuntimeStatus
761827 uptime_ms: legacy.uptime_ms,762828 daemon: legacy.daemon,763829 session_active: legacy.session_active,830+ session_mode: if legacy.session_active {831+ "legacy_exclusive".to_owned()832+ } else {833+ "inactive".to_owned()834+ },835+ active_viewer_count: usize::from(legacy.session_active),836+ media_generation_id: None,837+ roster_revision: None,838+ focus_occupied: None,839+ recovering_viewer_count: 0,840+ configured_viewer_capacity: usize::from(crate::config::DEFAULT_MAX_VIEWERS),841+ authorization_revision: 1,764842 last_error: legacy.last_error,765843 }))766844 }767845 2 => {768- let current: RuntimeStatusV2 =846+ let legacy: RuntimeStatusV2 =769847 serde_json::from_value(value).context("parsing Sigil runtime status v2")?;770- ensure!(771- current.version == RUNTIME_STATUS_VERSION,772- "unsupported Sigil runtime status version"773- );848+ Ok(Some(RuntimeStatusV3 {849+ version: RUNTIME_STATUS_VERSION,850+ instance_id: legacy.instance_id,851+ host_node_id: legacy.host_node_id,852+ loaded_config_revision: legacy.loaded_config_revision,853+ reached_ready: legacy.reached_ready,854+ updated_at_unix_ms: legacy.updated_at_unix_ms,855+ uptime_ms: legacy.uptime_ms,856+ daemon: legacy.daemon,857+ session_active: legacy.session_active,858+ session_mode: if legacy.session_active {859+ "legacy_exclusive".to_owned()860+ } else {861+ "inactive".to_owned()862+ },863+ active_viewer_count: usize::from(legacy.session_active),864+ media_generation_id: None,865+ roster_revision: None,866+ focus_occupied: None,867+ recovering_viewer_count: 0,868+ configured_viewer_capacity: usize::from(crate::config::DEFAULT_MAX_VIEWERS),869+ authorization_revision: 1,870+ last_error: legacy.last_error,871+ }))872+ }873+ 3 => {874+ let current: RuntimeStatusV3 =875+ serde_json::from_value(value).context("parsing Sigil runtime status v3")?;876+ validate_runtime_session(&current)?;774877 Ok(Some(current))775878 }776879 _ => anyhow::bail!("unsupported Sigil runtime status version"),777880 }778881 }779882 883+fn validate_runtime_session(status: &RuntimeStatusV3) -> Result<()> {884+ ensure!(885+ (1..=usize::from(crate::config::MAX_VIEWERS)).contains(&status.configured_viewer_capacity),886+ "runtime viewer capacity is out of bounds"887+ );888+ ensure!(889+ status.active_viewer_count <= status.configured_viewer_capacity,890+ "runtime active viewer count exceeds configured capacity"891+ );892+ ensure!(893+ status.recovering_viewer_count <= status.active_viewer_count,894+ "runtime recovering viewer count exceeds active viewers"895+ );896+ ensure!(897+ status.authorization_revision != 0,898+ "runtime authorization revision must be non-zero"899+ );900+ ensure!(901+ status.media_generation_id.is_none_or(|value| value != 0)902+ && status.roster_revision.is_none_or(|value| value != 0),903+ "runtime generation and roster revisions must be non-zero when present"904+ );905+ match status.session_mode.as_str() {906+ "inactive" => ensure!(907+ !status.session_active908+ && status.active_viewer_count == 0909+ && status.media_generation_id.is_none()910+ && status.roster_revision.is_none()911+ && status.focus_occupied.is_none(),912+ "inactive runtime session fields are inconsistent"913+ ),914+ "legacy_exclusive" => ensure!(915+ status.session_active916+ && status.active_viewer_count == 1917+ && status.roster_revision.is_none()918+ && status.focus_occupied.is_none(),919+ "legacy runtime session fields are inconsistent"920+ ),921+ "moq_multi_viewer" => ensure!(922+ status.session_active923+ && status.active_viewer_count > 0924+ && status.media_generation_id.is_some()925+ && status.roster_revision.is_some()926+ && status.focus_occupied.is_some(),927+ "multi-viewer runtime session fields are inconsistent"928+ ),929+ _ => anyhow::bail!("runtime session mode is unsupported"),930+ }931+ Ok(())932+}933+780934 pub(crate) fn latest_runtime_instance(781935 state_directory: &Path,782936 expected_host: EndpointId,
@@ -804,7 +958,7 @@ pub(crate) fn latest_runtime_instance(
804958 Ok(Some(persisted.instance_id))805959 }806960 807-fn stale_runtime(persisted: RuntimeStatusV2, heartbeat_age_ms: Option<u64>) -> RuntimeStatus {961+fn stale_runtime(persisted: RuntimeStatusV3, heartbeat_age_ms: Option<u64>) -> RuntimeStatus {808962 let stopped = persisted.daemon == DaemonState::Stopped;809963 RuntimeStatus {810964 state: RuntimeState::Stale,
@@ -823,6 +977,14 @@ fn stale_runtime(persisted: RuntimeStatusV2, heartbeat_age_ms: Option<u64>) -> R
823977 instance_id: None,824978 loaded_config_revision: None,825979 reached_ready: false,980+ mode: None,981+ active_viewer_count: None,982+ media_generation_id: None,983+ roster_revision: None,984+ focus_occupied: None,985+ recovering_viewer_count: None,986+ configured_viewer_capacity: None,987+ authorization_revision: None,826988 last_error: persisted.last_error,827989 }828990 }
@@ -837,6 +999,14 @@ fn absent_runtime() -> RuntimeStatus {
837999 instance_id: None,8381000 loaded_config_revision: None,8391001 reached_ready: false,1002+ mode: None,1003+ active_viewer_count: None,1004+ media_generation_id: None,1005+ roster_revision: None,1006+ focus_occupied: None,1007+ recovering_viewer_count: None,1008+ configured_viewer_capacity: None,1009+ authorization_revision: None,8401010 last_error: None,8411011 }8421012 }
@@ -851,6 +1021,14 @@ fn unavailable_runtime() -> RuntimeStatus {
8511021 instance_id: None,8521022 loaded_config_revision: None,8531023 reached_ready: false,1024+ mode: None,1025+ active_viewer_count: None,1026+ media_generation_id: None,1027+ roster_revision: None,1028+ focus_occupied: None,1029+ recovering_viewer_count: None,1030+ configured_viewer_capacity: None,1031+ authorization_revision: None,8541032 last_error: None,8551033 }8561034 }
@@ -910,12 +1088,14 @@ struct RuntimeSnapshotInput {
9101088 started: Instant,9111089 daemon: DaemonState,9121090 reached_ready: bool,913- session_active: bool,1091+ session: crate::server::SessionRuntimeStatus,1092+ recovering_viewer_count: usize,9141093 last_error: Option<RuntimeErrorV1>,9151094 }9161095 9171096 fn write_runtime_snapshot(input: RuntimeSnapshotInput) -> Result<()> {918- let status = RuntimeStatusV2 {1097+ let session_active = input.session.active_viewers != 0;1098+ let status = RuntimeStatusV3 {9191099 version: RUNTIME_STATUS_VERSION,9201100 instance_id: input.instance_id,9211101 host_node_id: input.host.to_string(),
@@ -924,9 +1104,20 @@ fn write_runtime_snapshot(input: RuntimeSnapshotInput) -> Result<()> {
9241104 updated_at_unix_ms: unix_timestamp_millis()?,9251105 uptime_ms: u64::try_from(input.started.elapsed().as_millis()).unwrap_or(u64::MAX),9261106 daemon: input.daemon,927- session_active: input.session_active,1107+ session_active,1108+ session_mode: input.session.mode.to_owned(),1109+ active_viewer_count: input.session.active_viewers,1110+ media_generation_id: input.session.media_generation_id,1111+ roster_revision: input.session.roster_revision,1112+ focus_occupied: input.session.focus_occupied,1113+ recovering_viewer_count: input1114+ .recovering_viewer_count1115+ .min(input.session.active_viewers),1116+ configured_viewer_capacity: input.session.configured_capacity,1117+ authorization_revision: input.session.authorization_revision,9281118 last_error: input.last_error,9291119 };1120+ validate_runtime_session(&status)?;9301121 let bytes = serde_json::to_vec(&status)?;9311122 secure_state::atomic_write(9321123 &input.state_directory,
@@ -1016,9 +1207,9 @@ mod tests {
10161207 fn current_runtime_document(10171208 updated_at_unix_ms: u64,10181209 revision: ConfigRevision,1019- ) -> RuntimeStatusV2 {1210+ ) -> RuntimeStatusV3 {10201211 let host = SecretKey::from_bytes(&[7; 32]).public();1021- RuntimeStatusV2 {1212+ RuntimeStatusV3 {10221213 version: RUNTIME_STATUS_VERSION,10231214 instance_id: "0123456789abcdef0123456789abcdef".to_owned(),10241215 host_node_id: host.to_string(),
@@ -1028,6 +1219,14 @@ mod tests {
10281219 uptime_ms: 1_000,10291220 daemon: DaemonState::Stopped,10301221 session_active: false,1222+ session_mode: "inactive".to_owned(),1223+ active_viewer_count: 0,1224+ media_generation_id: None,1225+ roster_revision: None,1226+ focus_occupied: None,1227+ recovering_viewer_count: 0,1228+ configured_viewer_capacity: usize::from(crate::config::DEFAULT_MAX_VIEWERS),1229+ authorization_revision: 1,10311230 last_error: None,10321231 }10331232 }
@@ -1049,8 +1248,9 @@ mod tests {
10491248 let second_state = private_directory();10501249 let first = LifecycleGuard::acquire_at(first_state.path(), None).unwrap();10511250 assert!(LifecycleGuard::acquire_at(first_state.path(), None).is_err());1052- LifecycleGuard::acquire_at(second_state.path(), None).unwrap();1251+ let second = LifecycleGuard::acquire_at(second_state.path(), None).unwrap();10531252 drop(first);1253+ drop(second);10541254 LifecycleGuard::acquire_at(first_state.path(), None).unwrap();10551255 }10561256
@@ -1180,7 +1380,7 @@ mod tests {
11801380 let revision = ConfigRevision::from_bytes(b"candidate");11811381 let instance = "0123456789abcdef0123456789abcdef";11821382 let valid = current_runtime_document(20_000, revision.clone());1183- let write = |document: &RuntimeStatusV2| {1383+ let write = |document: &RuntimeStatusV3| {11841384 secure_state::atomic_write(11851385 directory.path(),11861386 RUNTIME_STATUS_FILE,
@@ -1261,6 +1461,14 @@ mod tests {
12611461 instance_id: Some("0123456789abcdef0123456789abcdef".to_owned()),12621462 loaded_config_revision: Some(ConfigRevision::from_bytes(b"config")),12631463 reached_ready: true,1464+ mode: Some("moq_multi_viewer".to_owned()),1465+ active_viewer_count: Some(3),1466+ media_generation_id: Some(7),1467+ roster_revision: Some(9),1468+ focus_occupied: Some(true),1469+ recovering_viewer_count: Some(1),1470+ configured_viewer_capacity: Some(3),1471+ authorization_revision: Some(4),12641472 last_error: None,12651473 },12661474 ConfigRevision::from_bytes(b"config"),
@@ -1307,17 +1515,24 @@ mod tests {
13071515 assert_eq!(current["schema_version"], 2);13081516 assert!(current["config"].get("revision").is_some());13091517 assert!(current["runtime"].get("reached_ready").is_some());1518+ assert!(current["runtime"].get("active_viewer_count").is_some());13101519 }13111520 13121521 #[tokio::test]13131522 async fn publisher_writes_transitions_and_retains_an_explicit_stopped_state() {13141523 let directory = private_directory();13151524 let sessions = Arc::new(SessionRegistry::default());1525+ let generations = MediaGenerationManager::new(crate::server::moq_test_config(), [7; 32]);13161526 let host = SecretKey::from_bytes(&[7; 32]).public();13171527 let revision = ConfigRevision::from_bytes(b"config");1318- let publisher =1319- RuntimePublisher::start_at(directory.path(), host, sessions, Some(revision.clone()))1320- .unwrap();1528+ let publisher = RuntimePublisher::start_at(1529+ directory.path(),1530+ host,1531+ sessions,1532+ generations,1533+ Some(revision.clone()),1534+ )1535+ .unwrap();13211536 publisher.mark_ready().await.unwrap();13221537 publisher.write_snapshot().unwrap();13231538 let status =
M crates/sigil-host/src/authorization.rs +1 -0
M crates/sigil-host/src/bin/sigil-probe.rs +33 -14
@@ -770,6 +770,7 @@ impl MoqProbeReceiver {
770770 }771771 }772772 773+#[allow(clippy::too_many_arguments)]773774 async fn run_control_v2_smoke(774775 endpoint: &Endpoint,775776 address: EndpointAddr,
@@ -933,6 +934,10 @@ async fn run_control_v2_smoke(
933934 "not-applicable"934935 }935936 );937+ println!("resource_summary_version=1");938+ println!("maximum_roster_viewers={}", initial.viewers.len());939+ println!("media_progress=monotonic");940+ println!("probe_media_object_capacity={MEDIA_OBJECT_CAPACITY}");936941 return Ok(());937942 }938943 let mut accepted = 0_u32;
@@ -969,6 +974,10 @@ async fn run_control_v2_smoke(
969974 println!("roster_viewers={}", initial.viewers.len());970975 println!("frames={accepted}");971976 println!("spectator=ok");977+ println!("resource_summary_version=1");978+ println!("maximum_roster_viewers={}", initial.viewers.len());979+ println!("media_progress=monotonic");980+ println!("probe_media_object_capacity={MEDIA_OBJECT_CAPACITY}");972981 return Ok(());973982 }974983 let focus_request = FocusCommandV2 {
@@ -1124,6 +1133,7 @@ async fn run_control_v2_smoke(
11241133 println!("media_broadcast_name={broadcast_name}");11251134 println!("media_generation_scope=host");11261135 println!("subscription_endpoint_binding=ok");1136+ println!("roster_viewers={}", initial.viewers.len());11271137 println!("frames={accepted}");11281138 println!("initial_snapshot_revision={}", initial.revision);11291139 println!("focus_grant_revision={}", focused.revision);
@@ -1136,6 +1146,10 @@ async fn run_control_v2_smoke(
11361146 println!("focus_release_revision=disconnect");11371147 println!("focus_release=disconnect");11381148 }1149+ println!("resource_summary_version=1");1150+ println!("maximum_roster_viewers={}", initial.viewers.len());1151+ println!("media_progress=monotonic");1152+ println!("probe_media_object_capacity={MEDIA_OBJECT_CAPACITY}");11391153 Ok(())11401154 }11411155
@@ -1223,20 +1237,21 @@ async fn run_focus_handoff_participant(
12231237 tokio::time::Instant::now() < deadline,12241238 "timed out after observing {observed_handoffs} of {required_handoffs} focus handoffs"12251239 );1226- if !command_in_flight && tokio::time::Instant::now() >= next_command_at {1227- if let Some(command) = next_handoff_command(&snapshot, request_id) {1228- write_client_control_v2(1229- &mut control_send,1230- &ClientControlEnvelopeV2::Focus { command },1231- )1232- .await1233- .context("writing bounded focus handoff command")?;1234- request_id = request_id1235- .checked_add(1)1236- .context("focus handoff request id exhausted")?;1237- command_in_flight = true;1238- next_command_at = tokio::time::Instant::now() + Duration::from_millis(300);1239- }1240+ if !command_in_flight1241+ && tokio::time::Instant::now() >= next_command_at1242+ && let Some(command) = next_handoff_command(&snapshot, request_id)1243+ {1244+ write_client_control_v2(1245+ &mut control_send,1246+ &ClientControlEnvelopeV2::Focus { command },1247+ )1248+ .await1249+ .context("writing bounded focus handoff command")?;1250+ request_id = request_id1251+ .checked_add(1)1252+ .context("focus handoff request id exhausted")?;1253+ command_in_flight = true;1254+ next_command_at = tokio::time::Instant::now() + Duration::from_millis(300);12401255 }12411256 12421257 tokio::select! {
@@ -1288,6 +1303,10 @@ async fn run_focus_handoff_participant(
12881303 println!("roster_viewers={maximum_roster_viewers}");12891304 println!("focus_handoff_media_frames={accepted_frames}");12901305 println!("reset_before_successor=host-ordered");1306+ println!("resource_summary_version=1");1307+ println!("maximum_roster_viewers={maximum_roster_viewers}");1308+ println!("media_progress=monotonic");1309+ println!("probe_media_object_capacity={MEDIA_OBJECT_CAPACITY}");12911310 Ok(())12921311 }12931312
M crates/sigil-host/src/bin/sigil-relay-spike.rs +6 -5
M crates/sigil-host/src/main.rs +11 -2
@@ -348,6 +348,14 @@ struct ServeArgs {
348348 height: u32,349349 #[arg(long, default_value_t = 60)]350350 framerate: u32,351+ /// Concurrent native-MoQ viewers in direct proof mode.352+ #[arg(353+ long,354+ default_value_t = config::DEFAULT_MAX_VIEWERS,355+ value_parser = clap::value_parser!(u8).range(1..=8),356+ requires = "identity"357+ )]358+ max_viewers: u8,351359 #[arg(long, default_value = "ffmpeg")]352360 ffmpeg: PathBuf,353361 /// Exit after this many seconds; intended for bounded automation.
@@ -985,9 +993,11 @@ async fn serve_command(args: ServeArgs) -> Result<()> {
985993 config.max_viewers,986994 config.focus_owner.clone(),987995 ));996+ let media_generations = MediaGenerationManager::new(config.clone(), host_secret);988997 let publisher = appliance::RuntimePublisher::start(989998 secret.public(),990999 Arc::clone(&sessions),1000+ Arc::clone(&media_generations),9911001 configured_service,9921002 loaded_config_revision,9931003 )?;
@@ -1117,7 +1127,6 @@ async fn serve_command(args: ServeArgs) -> Result<()> {
11171127 11181128 let moq_origin = Origin::random();11191129 let input_operations = Arc::new(InputOperations::new(input_backend.clone()));1120- let media_generations = MediaGenerationManager::new(config.clone(), host_secret);11211130 let authorization_admin = match &authorization {11221131 AuthorizationPolicy::Required(store) => Some(11231132 authorization_admin::AuthorizationAdminServer::start(
@@ -1338,7 +1347,7 @@ fn load_serve_config(args: &ServeArgs) -> Result<LoadedServeConfig> {
13381347 config: HostConfig {13391348 identity_path,13401349 state_path,1341- max_viewers: config::DEFAULT_MAX_VIEWERS,1350+ max_viewers: args.max_viewers,13421351 focus_owner: None,13431352 source: source.into(),13441353 width: Some(args.width),
M crates/sigil-host/src/server/adaptive.rs +29 -6
@@ -1,5 +1,6 @@
11 use std::collections::HashMap;2-use std::sync::Mutex;2+use std::sync::atomic::{AtomicUsize, Ordering};3+use std::sync::{Arc, Mutex};34 45 use super::session::{56 AdaptiveEncoderProposal, FeedbackLease, FeedbackScope, MediaV3TelemetrySnapshot,
@@ -34,6 +35,9 @@ const ISOLATED_RECOVERY_CLEAN_WINDOWS: u8 = 3;
3435 const ISOLATED_RECOVERY_PRESSURE_WINDOWS: u8 = 3;3536 const ISOLATED_RECOVERY_MAX_ATTEMPTS: u8 = 2;3637 const ISOLATED_RECOVERY_BACKOFF: Duration = Duration::from_secs(2);38+// All policy state is one fixed record per hard-bounded viewer. Reports use a39+// latest cumulative ingress slot and the generation owns one actuator/queued40+// plan, so eight viewers cannot multiply encoder work or queue history.3741 3842 #[derive(Clone, Copy, Debug, PartialEq, Eq)]3943 enum FeedbackSeverity {
@@ -594,6 +598,7 @@ pub(super) struct GenerationAdaptiveCoordinator {
594598 ceiling_kbps: u32,595599 state: Mutex<GenerationAggregateState>,596600 changed: tokio::sync::Notify,601+ recovering_viewers: Arc<AtomicUsize>,597602 }598603 599604 impl std::fmt::Debug for GenerationAdaptiveCoordinator {
@@ -649,6 +654,7 @@ impl GenerationAdaptiveCoordinator {
649654 producer_telemetry: Arc<super::session::MediaV3Telemetry>,650655 keyframe_requests: tokio::sync::watch::Sender<Option<MediaControlRequestV3>>,651656 shutdown: tokio::sync::watch::Receiver<bool>,657+ recovering_viewers: Arc<AtomicUsize>,652658 ) -> Result<(Arc<Self>, tokio::task::JoinHandle<Result<()>>)> {653659 let ceiling_kbps = adaptive_bitrate_ceiling_kbps(&config)?;654660 let coordinator = Arc::new(Self {
@@ -660,6 +666,7 @@ impl GenerationAdaptiveCoordinator {
660666 target_kbps: ceiling_kbps,661667 }),662668 changed: tokio::sync::Notify::new(),669+ recovering_viewers,663670 });664671 let task_coordinator = Arc::clone(&coordinator);665672 let task = tokio::spawn(async move {
@@ -720,6 +727,7 @@ impl GenerationAdaptiveCoordinator {
720727 detach,721728 },722729 );730+ self.update_recovering_count(&state);723731 drop(state);724732 self.changed.notify_one();725733 Ok(GenerationFeedbackRegistration {
@@ -741,6 +749,7 @@ impl GenerationAdaptiveCoordinator {
741749 .is_some_and(|contributor| contributor.authorization_revision == authorization_revision)742750 {743751 state.contributors.remove(&key);752+ self.update_recovering_count(&state);744753 drop(state);745754 self.changed.notify_one();746755 }
@@ -849,6 +858,7 @@ impl GenerationAdaptiveCoordinator {
849858 contributor.last_received_at = Some(now);850859 changed_keys.push(*key);851860 }861+ self.update_recovering_count(&state);852862 853863 let active = state854864 .contributors
@@ -951,6 +961,21 @@ impl GenerationAdaptiveCoordinator {
951961 last_sequence: aggregate_report.and_then(|report| report.last_sequence),952962 }))953963 }964+965+ fn update_recovering_count(&self, state: &GenerationAggregateState) {966+ let count = state967+ .contributors968+ .values()969+ .filter(|contributor| {970+ !matches!(971+ contributor.recovery,972+ ContributorRecoveryState::Active { .. }973+ )974+ })975+ .count();976+ debug_assert!(count <= usize::from(crate::config::MAX_VIEWERS));977+ self.recovering_viewers.store(count, Ordering::Relaxed);978+ }954979 }955980 956981 fn feedback_severity_rank(severity: FeedbackSeverity) -> u8 {
@@ -1540,11 +1565,9 @@ impl GenerationAdaptiveActuator {
15401565 None15411566 };15421567 let resolution_target = plan.resolution_decision.map(|decision| decision.target);1543- let resolution_revision = if resolution_target.is_some()1544- && (resolution_target != self.requested_resolution1545- || resolution_target != self.committed_resolution)1546- {1547- let target = resolution_target.expect("resolution target was checked");1568+ let resolution_revision = if let Some(target) = resolution_target.filter(|target| {1569+ Some(*target) != self.requested_resolution || Some(*target) != self.committed_resolution1570+ }) {15481571 match control.request_resolution(target.width, target.height) {15491572 Ok(revision) => {15501573 self.requested_resolution = Some(target);
M crates/sigil-host/src/server/focus.rs +8 -0
M crates/sigil-host/src/server/generation.rs +26 -4
@@ -1,4 +1,4 @@
1-use std::sync::atomic::{AtomicU64, Ordering};1+use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering};22 use std::sync::{Arc, Mutex, Weak};33 use std::time::Duration;44
@@ -26,6 +26,9 @@ use crate::source::{
2626 };2727 2828 const GENERATION_STOP_TIMEOUT: Duration = Duration::from_secs(2);29+// Producer resources are generation-global. Viewer growth is represented only30+// by at most MAX_VIEWERS leases and consumers; no capture, encoder, publisher,31+// actuator, frame watch, or retained GOP allocation scales with viewer count.2932 3033 #[derive(Clone, Copy, Debug, PartialEq, Eq)]3134 pub(super) enum MediaGenerationLifecycle {
@@ -61,6 +64,7 @@ pub(crate) struct MediaGenerationManager {
6164 state_changed: Notify,6265 starts: AtomicU64,6366 stops: AtomicU64,67+ recovering_viewers: Arc<AtomicUsize>,6468 }6569 6670 impl MediaGenerationManager {
@@ -73,6 +77,7 @@ impl MediaGenerationManager {
7377 state_changed: Notify::new(),7478 starts: AtomicU64::new(0),7579 stops: AtomicU64::new(0),80+ recovering_viewers: Arc::new(AtomicUsize::new(0)),7681 })7782 }7883
@@ -101,8 +106,13 @@ impl MediaGenerationManager {
101106 .fetch_add(1, Ordering::Relaxed)102107 .checked_add(1)103108 .context("media generation id overflowed")?;104- match ActiveGeneration::start(&self.config, self.host_secret, generation_id)105- .await109+ match ActiveGeneration::start(110+ &self.config,111+ self.host_secret,112+ generation_id,113+ Arc::clone(&self.recovering_viewers),114+ )115+ .await106116 {107117 Ok(active) => {108118 let shared = active.shared.clone();
@@ -154,6 +164,7 @@ impl MediaGenerationManager {
154164 active.stop().await;155165 self.stops.fetch_add(1, Ordering::Relaxed);156166 }167+ self.recovering_viewers.store(0, Ordering::Relaxed);157168 let mut state = self.state.lock().await;158169 state.lifecycle = MediaGenerationLifecycle::Idle;159170 self.state_changed.notify_waiters();
@@ -175,6 +186,10 @@ impl MediaGenerationManager {
175186 .context("feedback does not match the active media generation")176187 }177188 189+ pub(crate) fn recovering_viewer_count(&self) -> usize {190+ self.recovering_viewers.load(Ordering::Relaxed)191+ }192+178193 #[cfg(test)]179194 async fn lifecycle(&self) -> MediaGenerationLifecycle {180195 self.state.lock().await.lifecycle
@@ -284,7 +299,12 @@ impl std::fmt::Debug for ActiveGeneration {
284299 }285300 286301 impl ActiveGeneration {287- async fn start(config: &HostConfig, host_secret: [u8; 32], generation_id: u64) -> Result<Self> {302+ async fn start(303+ config: &HostConfig,304+ host_secret: [u8; 32],305+ generation_id: u64,306+ recovering_viewers: Arc<AtomicUsize>,307+ ) -> Result<Self> {288308 let session_clock = SessionClock::start();289309 let source = match config.source {290310 VideoSource::TestPattern => Ok(spawn_test_pattern(config.clone(), session_clock)),
@@ -354,6 +374,7 @@ impl ActiveGeneration {
354374 Arc::clone(&telemetry),355375 keyframe_requests.clone(),356376 shutdown_rx.clone(),377+ recovering_viewers,357378 )?;358379 let video_publisher = tokio::spawn(run_generation_video_publisher(359380 config.clone(),
@@ -411,6 +432,7 @@ impl ActiveGeneration {
411432 audio_sources = usize::from(audio_enabled),412433 audio_encoders = usize::from(audio_enabled),413434 publishers = 1,435+ adaptive_actuators = 1,414436 "shared media generation started"415437 );416438 Ok(Self {
M crates/sigil-host/src/server/mod.rs +2 -1
M crates/sigil-host/src/server/moq.rs +2 -0
M crates/sigil-host/src/server/session.rs +334 -17
@@ -1,4 +1,4 @@
1-use std::collections::HashMap;1+use std::collections::{HashMap, VecDeque};22 use std::sync::atomic::{AtomicU64, Ordering};33 use std::sync::{Arc, Mutex};44 use std::time::{Duration, Instant};
@@ -14,7 +14,7 @@ use sigil_protocol::{
1414 SignedSubscriptionCapability, SubscriptionTracks, ViewerPresenceId, ViewerPresenceV2,1515 media_generation_moq_broadcast_name, media_moq_broadcast_name,1616 };17-use tracing::{debug, warn};17+use tracing::{debug, info, warn};1818 1919 use super::focus::{FocusArbiter, FocusCandidate, FocusNeutralization};2020 use super::{ENCODER_CONTROL_COMMIT_TIMEOUT, VideoDimensions};
@@ -23,6 +23,18 @@ use crate::clock::SessionClock;
2323 use crate::source::EncoderControl;2424 2525 const MAX_PENDING_HANDSHAKES: usize = 4;26+// The hard eight-viewer ceiling gives each admitted viewer four independent27+// handshake slots (control, MoQ, input, and feedback). The semaphore therefore28+// grows linearly with configured concurrency and never with connection churn.29+const MAX_TRACKED_ADMISSION_PEERS: usize = crate::config::MAX_VIEWERS as usize * 4;30+const ADMISSION_RATE_WINDOW: Duration = Duration::from_secs(10);31+const MAX_ADMISSIONS_PER_WINDOW: usize = 12;32+const ATTACHMENT_RATE_WINDOW: Duration = Duration::from_secs(5);33+const MAX_ATTACHMENTS_PER_WINDOW: usize = 4;34+const KEYFRAME_RATE_WINDOW: Duration = Duration::from_secs(2);35+const MAX_KEYFRAME_REQUESTS_PER_WINDOW: usize = 8;36+const FEEDBACK_CLAIM_RATE_WINDOW: Duration = Duration::from_secs(10);37+const MAX_FEEDBACK_CLAIMS_PER_WINDOW: usize = 4;2638 2739 #[derive(Debug)]2840 pub struct SessionRegistry {
@@ -31,6 +43,7 @@ pub struct SessionRegistry {
3143 max_viewers: usize,3244 next_session_id: AtomicU64,3345 authorization_committed_revision: AtomicU64,46+ admission_rates: Mutex<HashMap<EndpointId, RateWindow>>,3447 v2_state: Mutex<V2SessionState>,3548 pub(super) session_changed: tokio::sync::Notify,3649 pub(super) pending_handshakes: tokio::sync::Semaphore,
@@ -126,6 +139,58 @@ struct V2ViewerSession {
126139 presence_id: ViewerPresenceId,127140 authorization_neutralizing: bool,128141 snapshots: tokio::sync::watch::Sender<Option<SessionSnapshotV2>>,142+ rates: ViewerRateLimits,143+}144+145+#[derive(Debug, Default)]146+struct ViewerRateLimits {147+ attachments: RateWindow,148+ keyframes: RateWindow,149+ feedback_claims: RateWindow,150+}151+152+#[derive(Debug, Default)]153+struct RateWindow {154+ events: VecDeque<Instant>,155+ last_seen: Option<Instant>,156+}157+158+impl RateWindow {159+ fn check(160+ &mut self,161+ now: Instant,162+ window: Duration,163+ maximum: usize,164+ label: &'static str,165+ ) -> Result<()> {166+ while self167+ .events168+ .front()169+ .is_some_and(|event| now.saturating_duration_since(*event) >= window)170+ {171+ self.events.pop_front();172+ }173+ self.last_seen = Some(now);174+ ensure!(self.events.len() < maximum, "{label} rate limit exceeded");175+ self.events.push_back(now);176+ Ok(())177+ }178+179+ fn expired(&self, now: Instant, window: Duration) -> bool {180+ self.last_seen181+ .is_none_or(|seen| now.saturating_duration_since(seen) >= window)182+ }183+}184+185+#[derive(Clone, Copy, Debug, PartialEq, Eq)]186+pub(crate) struct SessionRuntimeStatus {187+ pub(crate) mode: &'static str,188+ pub(crate) active_viewers: usize,189+ pub(crate) media_generation_id: Option<u64>,190+ pub(crate) roster_revision: Option<u64>,191+ pub(crate) focus_occupied: Option<bool>,192+ pub(crate) configured_capacity: usize,193+ pub(crate) authorization_revision: u64,129194 }130195 131196 #[derive(Clone, Debug)]
@@ -408,6 +473,7 @@ impl SessionRegistry {
408473 max_viewers: usize::from(max_viewers),409474 next_session_id: AtomicU64::new(0),410475 authorization_committed_revision: AtomicU64::new(1),476+ admission_rates: Mutex::new(HashMap::with_capacity(MAX_TRACKED_ADMISSION_PEERS)),411477 v2_state: Mutex::new(V2SessionState::new(focus_owner)),412478 session_changed: tokio::sync::Notify::new(),413479 pending_handshakes: tokio::sync::Semaphore::new(
@@ -416,6 +482,7 @@ impl SessionRegistry {
416482 }417483 }418484 485+ #[allow(dead_code)]419486 pub fn has_session(&self) -> bool {420487 if self421488 .active
@@ -433,6 +500,61 @@ impl SessionRegistry {
433500 .is_empty()434501 }435502 503+ pub(crate) fn runtime_status(&self) -> SessionRuntimeStatus {504+ if let Some(active) = self505+ .active506+ .lock()507+ .expect("session registry poisoned")508+ .as_ref()509+ .filter(|session| session.media_active)510+ {511+ return SessionRuntimeStatus {512+ mode: "legacy_exclusive",513+ active_viewers: 1,514+ media_generation_id: Some(active.media_generation_id),515+ roster_revision: None,516+ focus_occupied: None,517+ configured_capacity: self.max_viewers,518+ authorization_revision: self519+ .authorization_committed_revision520+ .load(Ordering::SeqCst),521+ };522+ }523+ let state = self.v2_state.lock().expect("v2 session state poisoned");524+ SessionRuntimeStatus {525+ mode: if state.viewers.is_empty() {526+ "inactive"527+ } else {528+ "moq_multi_viewer"529+ },530+ active_viewers: state.viewers.len(),531+ media_generation_id: state.media.as_ref().map(|media| media.generation_id),532+ roster_revision: (!state.viewers.is_empty()).then_some(state.revision),533+ focus_occupied: (!state.viewers.is_empty())534+ .then_some(matches!(state.focus.state(), FocusStateV2::Held { .. })),535+ configured_capacity: self.max_viewers,536+ authorization_revision: self.authorization_committed_revision.load(Ordering::SeqCst),537+ }538+ }539+540+ fn check_admission_rate(&self, remote: EndpointId, now: Instant) -> Result<()> {541+ let mut rates = self542+ .admission_rates543+ .lock()544+ .expect("viewer admission rate state poisoned");545+ rates.retain(|_, rate| !rate.expired(now, ADMISSION_RATE_WINDOW));546+ ensure!(547+ rates.contains_key(&remote) || rates.len() < MAX_TRACKED_ADMISSION_PEERS,548+ "viewer admission rate table is full"549+ );550+ rates.entry(remote).or_default().check(551+ now,552+ ADMISSION_RATE_WINDOW,553+ MAX_ADMISSIONS_PER_WINDOW,554+ "viewer reconnect",555+ )556+ }557+436558 pub(super) fn claim(437559 self: &Arc<Self>,438560 remote: EndpointId,
@@ -482,6 +604,7 @@ impl SessionRegistry {
482604 })483605 }484606 607+ #[allow(dead_code)]485608 pub(super) fn claim_v2(486609 self: &Arc<Self>,487610 remote: EndpointId,
@@ -515,6 +638,7 @@ impl SessionRegistry {
515638 authorized.authorization_revision != 0 && authorized.committed_revision != 0,516639 "viewer authorization revision is invalid"517640 );641+ self.check_admission_rate(remote, Instant::now())?;518642 self.authorization_committed_revision519643 .fetch_max(authorized.committed_revision, Ordering::SeqCst);520644 ensure!(
@@ -548,19 +672,22 @@ impl SessionRegistry {
548672 "viewer presence id is already active for another peer"549673 );550674 let replaced = state.viewers.remove(&remote);551- let replacement_transition = replaced675+ let replaced_candidate = replaced.as_ref().map(|viewer| FocusCandidate {676+ presence_id: viewer.presence_id.clone(),677+ session_id: viewer.session.session_id,678+ });679+ let replacement_transition = replaced_candidate552680 .as_ref()553- .map(|viewer| FocusCandidate {554- presence_id: viewer.presence_id.clone(),555- session_id: viewer.session.session_id,556- })557681 .map(|candidate| {558682 state559683 .focus560- .begin_invalidation(&candidate, FocusTransitionReasonV2::Replaced)684+ .begin_invalidation(candidate, FocusTransitionReasonV2::Replaced)561685 })562686 .transpose()?563687 .and_then(|mutation| mutation.neutralization);688+ if let Some(candidate) = &replaced_candidate {689+ state.focus.retire_candidate(candidate);690+ }564691 let media = state.media.clone().unwrap_or(MediaGenerationDescriptorV2 {565692 generation_id: session_id,566693 broadcast_name: media_generation_moq_broadcast_name(session_id)?,
@@ -574,7 +701,7 @@ impl SessionRegistry {
574701 media_generation_id: media.generation_id,575702 media_broadcast_name: media.broadcast_name,576703 grants: authorized.grants,577- viewer_handle: Some(authorized.handle),704+ viewer_handle: Some(authorized.handle.clone()),578705 authorization_revision: authorized.authorization_revision,579706 authorization_committed_revision: authorized.committed_revision,580707 media_active: true,
@@ -592,6 +719,7 @@ impl SessionRegistry {
592719 presence_id,593720 authorization_neutralizing: false,594721 snapshots,722+ rates: ViewerRateLimits::default(),595723 },596724 );597725 state.live_control_leases = state
@@ -601,6 +729,8 @@ impl SessionRegistry {
601729 advance_v2_revision(&mut state)?;602730 publish_v2_snapshots(&state)?;603731 let snapshot = v2_snapshot_for(&state, remote)?;732+ let active_viewers = state.viewers.len();733+ let viewer_handle = authorized.handle.clone();604734 let replaced_snapshots = replaced.map(|viewer| viewer.snapshots);605735 let lease = V2SessionLease {606736 registry: Arc::clone(self),
@@ -616,6 +746,17 @@ impl SessionRegistry {
616746 };617747 drop(state);618748 self.session_changed.notify_waiters();749+ info!(750+ viewer_handle,751+ session_id,752+ active_viewers,753+ reason = if replacing {754+ "replacement"755+ } else {756+ "connected"757+ },758+ "multi-viewer session membership changed"759+ );619760 Ok(lease)620761 }621762
@@ -742,6 +883,7 @@ impl SessionRegistry {
742883 presence_id,743884 session_id,744885 };886+ let audit_handle = candidate.presence_id.clone();745887 state.focus.check_rate_limit(&candidate, Instant::now())?;746888 let mutation = match command.action {747889 FocusCommandActionV2::Request => {
@@ -803,6 +945,16 @@ impl SessionRegistry {
803945 publish_v2_snapshots(&state)?;804946 }805947 let snapshot = v2_snapshot_for(&state, remote)?;948+ if mutation.changed {949+ info!(950+ viewer_handle = audit_handle.as_str(),951+ session_id,952+ action = ?command.action,953+ roster_revision = snapshot.revision,954+ transition_reason = ?snapshot.transition_reason,955+ "slot-0 focus state changed"956+ );957+ }806958 self.session_changed.notify_waiters();807959 Ok(FocusCommandEffect {808960 snapshot,
@@ -950,6 +1102,13 @@ impl SessionRegistry {
9501102 }9511103 advance_v2_revision(&mut state)?;9521104 publish_v2_snapshots(&state)?;1105+ info!(1106+ viewer_handle = %mutation.handle,1107+ authorization_revision = mutation.authorization_revision,1108+ committed_revision = mutation.committed_revision,1109+ reason = if disconnected { "view_revoked" } else if input_reduced { "input_reduced" } else { "grants_changed" },1110+ "live viewer authorization changed"1111+ );9531112 drop(state);9541113 self.session_changed.notify_waiters();9551114 Ok(AuthorizationSessionEffect {
@@ -1227,6 +1386,12 @@ impl SessionRegistry {
12271386 .get_mut(&remote)12281387 .filter(|viewer| viewer.session.media_active && viewer.session.nonce == nonce)12291388 .context("feedback connection does not match the active media session")?;1389+ viewer.rates.feedback_claims.check(1390+ Instant::now(),1391+ FEEDBACK_CLAIM_RATE_WINDOW,1392+ MAX_FEEDBACK_CLAIMS_PER_WINDOW,1393+ "media feedback claim",1394+ )?;12301395 let session = &mut viewer.session;12311396 ensure!(12321397 session.grants.contains(InvitationGrants::VIEW),
@@ -1394,14 +1559,23 @@ impl SessionRegistry {
13941559 .as_ref()13951560 .filter(|session| session.media_active && session.remote == remote)13961561 .cloned();1397- let v2 = self1398- .v2_state1399- .lock()1400- .expect("v2 session state poisoned")1401- .viewers1402- .get(&remote)1403- .filter(|viewer| viewer.session.media_active)1404- .map(|viewer| viewer.session.clone());1562+ let v2 = {1563+ let mut state = self.v2_state.lock().expect("v2 session state poisoned");1564+ state1565+ .viewers1566+ .get_mut(&remote)1567+ .filter(|viewer| viewer.session.media_active)1568+ .map(|viewer| {1569+ viewer.rates.attachments.check(1570+ Instant::now(),1571+ ATTACHMENT_RATE_WINDOW,1572+ MAX_ATTACHMENTS_PER_WINDOW,1573+ "MoQ attachment",1574+ )?;1575+ Ok::<ActiveSession, anyhow::Error>(viewer.session.clone())1576+ })1577+ .transpose()?1578+ };14051579 let session = legacy14061580 .or(v2)14071581 .context("MoQ connection does not match an active control session")?;
@@ -1444,6 +1618,25 @@ impl SessionRegistry {
14441618 })14451619 }14461620 1621+ pub(super) fn admit_v2_keyframe_request(1622+ &self,1623+ remote: EndpointId,1624+ session_id: u64,1625+ ) -> Result<()> {1626+ let mut state = self.v2_state.lock().expect("v2 session state poisoned");1627+ let viewer = state1628+ .viewers1629+ .get_mut(&remote)1630+ .filter(|viewer| viewer.session.media_active && viewer.session.session_id == session_id)1631+ .context("keyframe request does not match the active viewer generation")?;1632+ viewer.rates.keyframes.check(1633+ Instant::now(),1634+ KEYFRAME_RATE_WINDOW,1635+ MAX_KEYFRAME_REQUESTS_PER_WINDOW,1636+ "keyframe request",1637+ )1638+ }1639+14471640 fn release(&self, remote: EndpointId, session_id: u64) {14481641 let mut active = self.active.lock().expect("session registry poisoned");14491642 if let Some(session) = active.as_mut()
@@ -1477,6 +1670,11 @@ impl SessionRegistry {
14771670 return;14781671 };14791672 let presence_id = viewer.presence_id.clone();1673+ let viewer_handle = viewer1674+ .session1675+ .viewer_handle1676+ .clone()1677+ .unwrap_or_else(|| presence_id.as_str().to_owned());14801678 let focus_mutation = state.focus.begin_invalidation(14811679 &FocusCandidate {14821680 presence_id: presence_id.clone(),
@@ -1496,6 +1694,11 @@ impl SessionRegistry {
14961694 if let Err(error) = focus_mutation {14971695 warn!(%error, session_id, "failed to invalidate focus during viewer release");14981696 }1697+ state.focus.retire_candidate(&FocusCandidate {1698+ presence_id,1699+ session_id,1700+ });1701+ let active_viewers = state.viewers.len();14991702 if state.viewers.is_empty() {15001703 state.media = None;15011704 }
@@ -1504,6 +1707,13 @@ impl SessionRegistry {
15041707 }15051708 drop(state);15061709 self.session_changed.notify_waiters();1710+ info!(1711+ viewer_handle,1712+ session_id,1713+ active_viewers,1714+ reason = "disconnected",1715+ "multi-viewer session membership changed"1716+ );15071717 }15081718 15091719 pub(crate) fn is_active(&self, remote: EndpointId, session_id: u64) -> bool {
@@ -1537,6 +1747,11 @@ impl SessionRegistry {
15371747 else {15381748 return Ok(false);15391749 };1750+ let viewer_handle = viewer1751+ .session1752+ .viewer_handle1753+ .clone()1754+ .unwrap_or_else(|| viewer.presence_id.as_str().to_owned());15401755 ensure!(15411756 !matches!(15421757 state.focus.state(),
@@ -1560,6 +1775,13 @@ impl SessionRegistry {
15601775 .expect("MoQ registry poisoned")15611776 .remove(&(remote, session_id));15621777 self.session_changed.notify_waiters();1778+ info!(1779+ viewer_handle,1780+ session_id,1781+ active_viewers = self.runtime_status().active_viewers,1782+ reason = "adaptive_recovery_detach",1783+ "multi-viewer session membership changed"1784+ );15631785 Ok(true)15641786 }15651787
@@ -1709,8 +1931,11 @@ pub(crate) struct V2SessionLease {
17091931 registry: Arc<SessionRegistry>,17101932 remote: EndpointId,17111933 pub(crate) session_id: u64,1934+ #[allow(dead_code)]17121935 pub(super) session_clock: SessionClock,1936+ #[allow(dead_code)]17131937 pub(super) media_v3_telemetry: Arc<MediaV3Telemetry>,1938+ #[allow(dead_code)]17141939 pub(super) initial_snapshot: SessionSnapshotV2,17151940 pub(super) authorization_revision: u64,17161941 pub(super) authorization_committed_revision: u64,
@@ -2610,6 +2835,98 @@ mod tests {
26102835 assert!(sessions.pending_handshakes.try_acquire().is_ok());26112836 }26122837 2838+ #[test]2839+ fn per_viewer_reconnect_keyframe_and_feedback_rates_are_bounded() {2840+ let sessions = Arc::new(SessionRegistry::new(1));2841+ let remote = endpoint(1);2842+ let mut lease = sessions2843+ .claim_v2_authorized(2844+ remote,2845+ [1; 16],2846+ authorized_viewer("viewer-one", InvitationGrants::ALL),2847+ )2848+ .unwrap();2849+ for nonce in 2..=MAX_ADMISSIONS_PER_WINDOW {2850+ lease = sessions2851+ .claim_v2_authorized(2852+ remote,2853+ [u8::try_from(nonce).unwrap(); 16],2854+ authorized_viewer("viewer-one", InvitationGrants::ALL),2855+ )2856+ .unwrap();2857+ }2858+ assert!(2859+ sessions2860+ .claim_v2_authorized(2861+ remote,2862+ [99; 16],2863+ authorized_viewer("viewer-one", InvitationGrants::ALL),2864+ )2865+ .unwrap_err()2866+ .to_string()2867+ .contains("reconnect rate limit")2868+ );2869+2870+ for _ in 0..MAX_KEYFRAME_REQUESTS_PER_WINDOW {2871+ sessions2872+ .admit_v2_keyframe_request(remote, lease.session_id)2873+ .unwrap();2874+ }2875+ assert!(2876+ sessions2877+ .admit_v2_keyframe_request(remote, lease.session_id)2878+ .unwrap_err()2879+ .to_string()2880+ .contains("keyframe request rate limit")2881+ );2882+2883+ let active_nonce = [12; 16];2884+ for _ in 0..MAX_FEEDBACK_CLAIMS_PER_WINDOW {2885+ let feedback = sessions.claim_feedback(remote, active_nonce).unwrap();2886+ drop(feedback);2887+ }2888+ assert!(sessions.claim_feedback(remote, active_nonce).is_err());2889+ }2890+2891+ #[test]2892+ fn runtime_status_reports_bounded_multi_viewer_focus_without_peer_keys() {2893+ let sessions = Arc::new(SessionRegistry::new(3));2894+ let first = sessions2895+ .claim_v2_authorized(2896+ endpoint(1),2897+ [1; 16],2898+ authorized_viewer("viewer-one", InvitationGrants::ALL),2899+ )2900+ .unwrap();2901+ let second = sessions2902+ .claim_v2_authorized(2903+ endpoint(2),2904+ [2; 16],2905+ authorized_viewer("viewer-two", InvitationGrants::VIEW),2906+ )2907+ .unwrap();2908+ let status = sessions.runtime_status();2909+ assert_eq!(status.mode, "moq_multi_viewer");2910+ assert_eq!(status.active_viewers, 2);2911+ assert_eq!(status.configured_capacity, 3);2912+ assert_eq!(status.focus_occupied, Some(false));2913+ sessions2914+ .apply_focus_command(2915+ endpoint(1),2916+ first.session_id,2917+ &FocusCommandV2 {2918+ request_id: 1,2919+ action: FocusCommandActionV2::Request,2920+ slot: ControllerSlot::ZERO,2921+ expected_revision: second.initial_snapshot.revision,2922+ expected_focus_generation: None,2923+ expected_proposal_id: None,2924+ },2925+ )2926+ .unwrap();2927+ assert_eq!(sessions.runtime_status().focus_occupied, Some(true));2928+ }2929+26132930 #[test]26142931 fn session_substreams_inherit_the_exact_enrollment_grant() {26152932 let sessions = Arc::new(SessionRegistry::default());
M crates/sigil-protocol/src/control_v2.rs +7 -6
M docs/fresh-bazzite-host.md +37 -13
@@ -87,12 +87,24 @@ Ordinary release builds and shipped Sigil packages reject the flag.
8787 8888 ### Current security boundary8989 90-Portal derives a stable client Iroh identity from the security key. Sigil signs91-a short-lived invitation bound to its host identity, that exact Portal peer,92-an enrollment epoch, a nonce, expiry, and the requested grants. The first media93-handshake consumes the invitation atomically; later sessions authenticate the94-durably enrolled peer without replaying it. View, pointer/keyboard, and gamepad95-grants are intersected with operational protocol capabilities on both sides.90+Each Portal derives a stable client Iroh identity from its security key. Sigil91+signs a short-lived invitation bound to its host identity, that exact Portal92+peer, an enrollment epoch, a nonce, expiry, and the requested grants. The first93+media handshake consumes the invitation atomically; later sessions authenticate94+the durably enrolled peer without replaying it. Authorization state supports a95+fixed bounded viewer set independently of the concurrent `max_viewers` cap.96+View, pointer/keyboard, and gamepad grants are intersected with operational97+protocol capabilities on both sides, and committed reductions apply to the98+running daemon before the administration operation returns.99+100+Control v2 is the native-MoQ multi-viewer path. It admits three concurrent101+viewers by default and at most eight, all on one host-owned video/audio media102+generation. Slot 0 has at most one input-focus holder. Eligibility comes from103+pointer/keyboard or gamepad grants; possession comes only from the current104+revisioned focus snapshot. Focus handoff, replacement, disconnect, or live105+revocation invalidates the old focus generation and neutralizes every virtual106+input device before a successor can inject. Control v1 and grouped-v3 remain107+strictly exclusive and never coexist with a v2 generation.96108 97109 `--dev-connect` remains only a build-time-contained routing bypass in Portal.98110 It does not grant access to a configured host, and ordinary release builds
@@ -101,8 +113,8 @@ reject the option. Only the explicit direct test-pattern proof form of
101113 Replay, expiry, wrong-peer, cross-host, stale-epoch, and capability-escalation102114 attempts are rejected before capture starts.103115 104-For first enrollment, use Portal's **show portal id** action, then issue the105-short-lived file on the host:116+For each enrollment, use that Portal's **show portal id** action, then issue one117+short-lived file on the host. Omit input flags for a view-only spectator:106118 107119 ```bash108120 sigil invitation create \
@@ -114,9 +126,13 @@ sigil invitation create \
114126 ```115127 116128 Open the file with Portal and confirm its bounded summary with the controller.117-After the first accepted media handshake, verify the durable grant with118-`sigil enrollment show --config ~/.config/sigil-spark/host.toml`. Revocation is119-explicit and invalidates all outstanding invitations:129+After the first accepted media handshake, verify the bounded durable roster130+with `sigil enrollment list --config ~/.config/sigil-spark/host.toml`.131+Peer/grant revocation is available through the daemon-owned administration132+path; removing input grants defocuses and neutralizes only that viewer, while133+removing `VIEW` disconnects it without interrupting survivors. The reset below134+remains the explicit all-viewer recovery operation and invalidates outstanding135+invitations:120136 121137 ```bash122138 fingerprint="$(sigil appliance status \
@@ -1683,7 +1699,9 @@ Sanitize journals before exporting them. Save:
16831699 - Host service journal and Gamescope/PipeWire session journal.16841700 - Direct versus relayed iroh path.16851701 - Frame counts, queue depth, drops, queue age, RSS, CPU, and GPU utilization.1686-- Reconnect and second-client rejection results.1702+- Three-viewer native-MoQ progress, same-peer replacement, focus handoff1703+ latency, live revocation, slow-viewer isolation, and final cleanup results.1704+- Separate legacy-exclusive second-client rejection results.16871705 16881706 Never copy the host identity seed into an evidence bundle. Treat node IDs and16891707 connection metadata as operationally sensitive until client authorization
@@ -1775,7 +1793,8 @@ sudo systemctl reload sshd.service
17751793 frontend, and decoder drop counters do not grow after startup during the17761794 demo soak.17771795 - Input transport remains responsive during video load.1778-- One-client enforcement and 100 reconnect cycles pass.1796+- Three-viewer native-MoQ admission, exclusive slot-0 focus, replacement, and1797+ reconnect cycles pass; the separate legacy path still rejects a second client.17791798 17801799 ### Fresh Bazzite host17811800
@@ -1788,3 +1807,8 @@ sudo systemctl reload sshd.service
17881807 - A cold, physically headless boot produces a Gamescope PipeWire node.17891808 - Switching to Gamescope capture does not require a portal or a physical17901809 display.1810+- Three exact-candidate Portals share one authenticated video/audio generation;1811+ one constrained viewer can recover or detach without survivor latency growth.1812+- Controller focus handoff, live input/view revocation, same-peer replacement,1813+ resource percentiles, and final-viewer cleanup pass, followed by restoration1814+ of the ordinary authenticated `sigil-host.service`.
M docs/hardware-uat/MATRIX.md +6 -2
M docs/portal-platform-support.md +13 -0
@@ -3,6 +3,10 @@
33 Portal's first supported download is macOS arm64. Its relative-pointer path44 uses CoreGraphics cursor disassociation and has live Mac-to-Gamescope hardware55 evidence. Linux, Windows, and other Portal builds are not release targets yet.6+All platform claims also inherit the multi-viewer acceptance boundary: native7+MoQ control v2 supports three viewers by default (hard ceiling eight), while8+control v1/grouped-v3 remains exclusive. Only one viewer may hold slot-0 input9+focus, regardless of client platform.610 711 ## Build policy812
@@ -59,6 +63,15 @@ the evidence.
5963 6. Run `./scripts/verify-demo-build.sh` and retain the exact Portal-to-UMPC6064 session evidence required by `AGENTS.md`. A config check, unit test, or6165 compile-only result is not hardware acceptance.66+7. Join the exact candidate as one of three simultaneous control-v2 viewers.67+ Verify the roster and local opaque handle, request and transfer slot-0 focus68+ by controller, and confirm focus loss immediately tears down local pointer,69+ keyboard, and gamepad routing before another viewer injects.70+8. While this platform is the holder and while it is a spectator, exercise71+ same-peer replacement, live input and view revocation, and one constrained72+ viewer. Survivor video/audio and diagnostics must continue on the same media73+ generation with bounded queues. Repeat legacy mode separately and confirm a74+ second client receives the explicit exclusive-mode rejection.6275 6376 Removing the feature gate requires reviewed evidence for every platform being6477 enabled and an explicit release-policy change. Do not infer support from Tao
M docs/public-alpha-uat.md +52 -10
@@ -78,7 +78,7 @@ Every input is a mode `0600` regular file at an absolute path. It begins with
7878 these fields, copied from `manifest.tsv` where applicable:7979 8080 ```text81-uat_schema=goq-public-alpha-evidence-v281+uat_schema=goq-public-alpha-evidence-v38282 evidence_kind=controller8383 observed_at_unix=17600000008484 git_commit=0123456789abcdef0123456789abcdef01234567
@@ -107,8 +107,10 @@ chmod 600 /absolute/path/to/controller.evidence
107107 ```108108 109109 Supported kinds are `cold-boot`, `controller`, `mouse`, `soak`,110-`network-direct`, `network-relay`, `reconnect`, `second-client`, and the111-optional `loopback-preflight`.110+`network-direct`, `network-relay`, `reconnect`, `multi-viewer`,111+`second-client`, and the optional `loopback-preflight`. `second-client` is the112+legacy-exclusive check; native-MoQ v2 must admit the bounded three-viewer113+contract instead of rejecting viewer two.112114 113115 ## 3. Required hardware exercises114116
@@ -249,7 +251,7 @@ Both records reject packet loss over 5%. The relay run must be forced or
249251 observed under the documented difficult-NAT setup and confirmed as relayed by250252 Portal diagnostics.251253 252-### Reconnect and second-client admission254+### Reconnect and multi-viewer admission253255 254256 Perform at least ten reconnects with the exact candidates. Each must restore255257 the intended session state and resume from a keyframe:
@@ -264,14 +266,48 @@ keyframe_recovery_p95_ms=900
264266 265267 Keyframe recovery p95 must not exceed 2000 ms.266268 267-While the authorized primary session remains playable, attempt a second client268-at least three times:269+Connect three exact-candidate Portals concurrently over native MoQ control v2.270+Exercise controller focus transfer, one constrained viewer, same-peer271+replacement, and live grant revocation while collecting shared-resource and272+cleanup evidence:269273 270274 ```text275+protocol_mode=native-moq-v2276+configured_viewer_capacity=3277+concurrent_viewers=3278+shared_media_generation=pass279+shared_video_sources=1280+shared_video_encoders=1281+shared_audio_sources=1282+shared_audio_encoders=1283+shared_publishers=1284+shared_adaptive_actuators=1285+viewer_progress=pass286+slot_0_holders_max=1287+focus_handoff_p95_ms=250288+slow_viewer_isolation=pass289+same_peer_replacement=pass290+live_view_revocation=pass291+survivors_uninterrupted=pass292+final_cleanup=pass293+ordinary_service_restored=pass294+```295+296+The configured capacity must be 3 through 8, at least three viewers must be297+simultaneous, and focus handoff p95 must not exceed 1000 ms. Shared resource298+counts and the maximum slot-0 holder count must each be exactly one. Revoking299+one viewer or detaching a constrained viewer must not change survivor media300+generation or interrupt their A/V/input diagnostics.301+302+Run a separate legacy-exclusive session. While its authorized primary remains303+playable, attempt a second legacy client at least three times:304+305+```text306+protocol_mode=legacy-exclusive-v1271307 second_client_attempts=3272308 second_client_rejections=3273309 authorized_primary_uninterrupted=pass274-rejection_reason=active-client310+rejection_reason=legacy-active-client275311 ```276312 277313 ## 4. Optional loopback preflight
@@ -282,7 +318,8 @@ mode. Its output includes an operationally sensitive `node_id`, so remove that
282318 line before ingestion and add the common envelope:283319 284320 ```bash285-./scripts/loopback-proof.sh --profile release \321+./scripts/loopback-proof.sh --profile release --control-v2 --viewers 3 \322+ --primary-frames 600 --reconnect-cycles 3 \286323 | sed '/^node_id=/d' \287324 >> /absolute/path/to/loopback-preflight.evidence288325 chmod 600 /absolute/path/to/loopback-preflight.evidence
@@ -294,9 +331,14 @@ The normalized record requires:
294331 loopback_proof=ok295332 profile=release296333 host_sha256=<same value as sigil_sha256>297-active_client_rejection=ok334+control_v2=ok335+viewers=3298336 reconnect_cycles=3299-cleanup=ok337+same_peer_replacement=ok338+shared_generation=ok339+slot_0_single_holder=ok340+bounded_queues=ok341+final_cleanup=ok300342 ```301343 302344 The harness ingests the output by value and never mutates the Bazzite host.
M docs/sigil-appliance-status-v2.md +16 -5
@@ -26,8 +26,10 @@ The public document includes only:
2626 - Redacted host fingerprint plus bounded enrollment count.2727 - Stable opaque viewer handles, canonically ordered coarse grants, enrollment2828 times, per-viewer authorization revisions, and the global committed revision.29-- Runtime freshness, daemon state, uptime, session state, and a closed set of30- error codes.29+- Runtime freshness, daemon state, uptime, session mode, bounded active-viewer30+ count, configured capacity, media generation, roster revision, focus31+ occupancy, recovering-viewer count, authorization revision, and a closed set32+ of error codes.3133 - The exact loaded configuration revision, fresh daemon instance ID, and sticky3234 `reached_ready` evidence used by the transactional configuration gate.3335 - The current configuration revision and any pending transaction summary.
@@ -73,6 +75,14 @@ must be regular, owner-only objects. Reads and writes are bounded, refuse
7375 symlinks, and use no-follow opens. Heartbeat I/O runs outside Tokio's async7476 worker threads.7577 78+The runtime publisher writes strict heartbeat version 3. Its multi-viewer79+fields are internally bounded: active count cannot exceed configured capacity80+or the hard ceiling of eight, recovering count cannot exceed active count, and81+generation/roster revisions are nonzero when present. `inactive`,82+`legacy_exclusive`, and `moq_multi_viewer` have distinct fail-closed field83+shapes. Schema-v1 appliance output removes these fields; it never invents a84+roster or focus generation for a legacy heartbeat.85+7686 The status command treats a missing runtime document as `offline`. A heartbeat7787 older than ten seconds is `stale`; stale data never claims a live session or7888 live uptime. Clean shutdown writes `stopping`, shuts down the Iroh router, and
@@ -81,10 +91,11 @@ stale, while the runtime directory itself is discarded at reboot. Every
8191 runtime document is bound to the current host identity before its state is8292 combined with durable enrollment data.8393 84-Runtime status v2 remains able to read a version-1 heartbeat after an upgrade,85-but legacy heartbeats carry no configuration revision and never satisfy the94+Runtime status v3 remains able to read version-1 and version-2 heartbeats after95+an upgrade, but those legacy heartbeats cannot report multi-viewer details;96+version 1 also carries no configuration revision and never satisfies the8697 configuration health gate. Stale or absent heartbeats do not expose an instance87-ID, loaded revision, or ready claim.98+ID, loaded revision, ready claim, active count, focus, or generation state.8899 89100 The Decky backend merges this document with `systemctl --user` unit state.90101 Service control, enrollment reset, and identity factory reset remain separate
M portal/frame-stats.test.mjs +23 -4
M portal/network-diagnostics.mjs +8 -2
M portal/network-diagnostics.test.mjs +16 -3
@@ -40,7 +40,7 @@ function leg(overrides = {}) {
4040 4141 function snapshot(overrides = {}) {4242 return {43- version: 1,43+ version: 2,4444 session_elapsed_ms: 12345,4545 media: leg(),4646 input: leg({ mode: 'relay', direct_samples: 0, relay_samples: 3 }),
@@ -70,13 +70,24 @@ function snapshot(overrides = {}) {
7070 recovery_state: 'active',7171 applied: true,7272 },73+ session: {74+ mode: 'moq_multi_viewer_v2',75+ local_handle: 'viewer-one',76+ focus_generation: 7,77+ roster_revision: 9,78+ roster_age_ms: 25,79+ subscription_expires_at_unix: 2_000_000_000,80+ subscription_seconds_remaining: 30,81+ stale_snapshot_total: 0,82+ invalid_snapshot_total: 0,83+ },7384 ...overrides,7485 };7586 }7687 77-test('normalizes a bounded v1 snapshot and keeps transport legs separate', () => {88+test('normalizes a bounded v2 snapshot and keeps transport legs separate', () => {7889 const diagnostics = normalizeNetworkDiagnostics(snapshot());79- assert.equal(diagnostics.version, 1);90+ assert.equal(diagnostics.version, 2);8091 assert.equal(diagnostics.sessionElapsedMs, 12345);8192 assert.equal(diagnostics.media.mode, 'direct');8293 assert.equal(diagnostics.input.mode, 'relay');
@@ -98,6 +109,7 @@ test('normalizes a bounded v1 snapshot and keeps transport legs separate', () =>
98109 recoveryState: 'active',99110 applied: true,100111 });112+ assert.equal(diagnostics.session.localHandle, 'viewer-one');101113 });102114 103115 test('validates exact counters, classified samples, percentiles, and completeness', () => {
@@ -172,6 +184,7 @@ test('renders legs and ACK health without exposing identifiers or addresses', ()
172184 assert.equal(presentation.audio, 'unavailable');173185 assert.match(presentation.inputAck, /^negotiated · 9\/10 acknowledged/);174186 assert.match(presentation.adaptive, /^local-viewer · local pressured · aggregate 6000 kbps decrease/);187+ assert.match(presentation.session, /native MoQ multi-viewer v2 · viewer-one/);175188 assert.doesNotMatch(JSON.stringify(presentation), /secret|192\.0\.2\.1|endpoint/i);176189 });177190
M portal/runtime-files.txt +1 -0
A portal/session-diagnostics.mjs +79 -0
@@ -0,0 +1,79 @@
1+const MODES = new Set(['legacy_exclusive_v1', 'moq_multi_viewer_v2']);2+const HANDLE_PATTERN = /^[A-Za-z0-9-]{1,32}$/;3+4+function unsigned(value, label, { positive = false } = {}) {5+ if (!Number.isSafeInteger(value) || value < (positive ? 1 : 0)) {6+ throw new TypeError(`${label} must be an exact ${positive ? 'positive ' : ''}unsigned integer`);7+ }8+ return value;9+}10+11+function optionalUnsigned(value, label, options) {12+ return value === null ? null : unsigned(value, label, options);13+}14+15+export function normalizeSessionDiagnostics(value) {16+ if (!value || typeof value !== 'object' || Array.isArray(value)) {17+ throw new TypeError('session diagnostics must be an object');18+ }19+ if (!MODES.has(value.mode)) throw new TypeError('session diagnostics mode is unsupported');20+ const staleSnapshotTotal = unsigned(value.stale_snapshot_total, 'stale_snapshot_total');21+ const invalidSnapshotTotal = unsigned(value.invalid_snapshot_total, 'invalid_snapshot_total');22+23+ if (value.mode === 'legacy_exclusive_v1') {24+ for (const field of [25+ 'local_handle', 'focus_generation', 'roster_revision', 'roster_age_ms',26+ 'subscription_expires_at_unix', 'subscription_seconds_remaining',27+ ]) {28+ if (value[field] !== null) throw new TypeError(`legacy session diagnostics require null ${field}`);29+ }30+ return {31+ mode: value.mode,32+ label: 'legacy exclusive v1',33+ localHandle: null,34+ focusGeneration: null,35+ rosterRevision: null,36+ rosterAgeMs: null,37+ subscriptionExpiresAtUnix: null,38+ subscriptionSecondsRemaining: null,39+ staleSnapshotTotal,40+ invalidSnapshotTotal,41+ };42+ }43+44+ if (typeof value.local_handle !== 'string' || !HANDLE_PATTERN.test(value.local_handle)) {45+ throw new TypeError('local_handle must be a bounded opaque viewer handle');46+ }47+ const rosterRevision = unsigned(value.roster_revision, 'roster_revision', { positive: true });48+ const rosterAgeMs = unsigned(value.roster_age_ms, 'roster_age_ms');49+ const subscriptionExpiresAtUnix = unsigned(50+ value.subscription_expires_at_unix,51+ 'subscription_expires_at_unix',52+ { positive: true },53+ );54+ const subscriptionSecondsRemaining = unsigned(55+ value.subscription_seconds_remaining,56+ 'subscription_seconds_remaining',57+ );58+ return {59+ mode: value.mode,60+ label: 'native MoQ multi-viewer v2',61+ localHandle: value.local_handle,62+ focusGeneration: optionalUnsigned(value.focus_generation, 'focus_generation', { positive: true }),63+ rosterRevision,64+ rosterAgeMs,65+ subscriptionExpiresAtUnix,66+ subscriptionSecondsRemaining,67+ staleSnapshotTotal,68+ invalidSnapshotTotal,69+ };70+}71+72+export function sessionDiagnosticsPresentation(session) {73+ if (session === null) return 'unavailable';74+ if (session.mode === 'legacy_exclusive_v1') {75+ return `${session.label} · implicit focus · no roster · stale/invalid ${session.staleSnapshotTotal}/${session.invalidSnapshotTotal}`;76+ }77+ const focus = session.focusGeneration === null ? 'spectator' : `focus generation ${session.focusGeneration}`;78+ return `${session.label} · ${session.localHandle} · ${focus} · roster r${session.rosterRevision} age ${session.rosterAgeMs} ms · subscription ${session.subscriptionSecondsRemaining} s remaining · stale/invalid ${session.staleSnapshotTotal}/${session.invalidSnapshotTotal}`;79+}
A portal/session-diagnostics.test.mjs +65 -0
@@ -0,0 +1,65 @@
1+import test from 'node:test';2+import assert from 'node:assert/strict';3+import {4+ normalizeSessionDiagnostics,5+ sessionDiagnosticsPresentation,6+} from './session-diagnostics.mjs';7+8+test('normalizes bounded v2 session ownership without peer identity material', () => {9+ const session = normalizeSessionDiagnostics({10+ mode: 'moq_multi_viewer_v2',11+ local_handle: 'viewer-0123456789abcdef',12+ focus_generation: 7,13+ roster_revision: 12,14+ roster_age_ms: 25,15+ subscription_expires_at_unix: 2_000_000_000,16+ subscription_seconds_remaining: 42,17+ stale_snapshot_total: 2,18+ invalid_snapshot_total: 1,19+ peer_id: 'must-not-cross-the-allowlist',20+ });21+ assert.equal(session.focusGeneration, 7);22+ assert.equal(session.rosterRevision, 12);23+ assert.doesNotMatch(JSON.stringify(session), /peer_id|must-not-cross/);24+ assert.match(sessionDiagnosticsPresentation(session), /roster r12 age 25 ms/);25+});26+27+test('labels legacy mode truthfully and forbids invented roster state', () => {28+ const raw = {29+ mode: 'legacy_exclusive_v1',30+ local_handle: null,31+ focus_generation: null,32+ roster_revision: null,33+ roster_age_ms: null,34+ subscription_expires_at_unix: null,35+ subscription_seconds_remaining: null,36+ stale_snapshot_total: 0,37+ invalid_snapshot_total: 0,38+ };39+ const legacy = normalizeSessionDiagnostics(raw);40+ assert.match(sessionDiagnosticsPresentation(legacy), /legacy exclusive v1 · implicit focus/);41+ assert.throws(() => normalizeSessionDiagnostics({42+ ...raw,43+ local_handle: 'invented-viewer',44+ }), /require null local_handle/);45+});46+47+test('rejects invalid handles, zero generations, and inexact counters', () => {48+ const valid = {49+ mode: 'moq_multi_viewer_v2',50+ local_handle: 'viewer-one',51+ focus_generation: null,52+ roster_revision: 1,53+ roster_age_ms: 0,54+ subscription_expires_at_unix: 2_000_000_000,55+ subscription_seconds_remaining: 30,56+ stale_snapshot_total: 0,57+ invalid_snapshot_total: 0,58+ };59+ assert.throws(() => normalizeSessionDiagnostics({ ...valid, local_handle: 'raw/key' }), /opaque/);60+ assert.throws(() => normalizeSessionDiagnostics({ ...valid, roster_revision: 0 }), /positive/);61+ assert.throws(() => normalizeSessionDiagnostics({62+ ...valid,63+ stale_snapshot_total: Number.MAX_SAFE_INTEGER + 1,64+ }), /exact/);65+});
M scripts/loopback-proof.sh +129 -18
@@ -33,6 +33,8 @@ focus_handoffs=0
3333 assert_neutral_before_successor=false3434 stall_viewer=03535 assert_survivor_recovery=false36+stress=false37+host_rss_samples=""3638 3739 usage() {3840 cat <<'EOF'
@@ -54,7 +56,7 @@ Options:
5456 --relay-only Remove direct probe transports and require the5557 control, media, and input paths to stay relayed5658 --control-v2 Exercise explicit control/2 and input/2 focus57- --viewers COUNT Concurrent viewers (control-v2 supports 1..3 here)59+ --viewers COUNT Concurrent viewers (control-v2 supports 1..8)5860 --assert-shared-generation Require host-scoped generation naming, one5961 producer start, and final-viewer cleanup6062 --replace-viewer INDEX Replace this 1-based control-v2 viewer identity
@@ -66,6 +68,8 @@ Options:
6668 --stall-viewer INDEX Drive this control-v2 viewer persistently below6769 the shared bitrate floor6870 --assert-survivor-recovery Require clean viewers to recover after isolation71+ --stress Require the release-profile eight-viewer churn,72+ pressure isolation, and resource-bound gate6973 --help Show this help7074 EOF7175 }
@@ -175,6 +179,10 @@ while [[ $# -gt 0 ]]; do
175179 assert_survivor_recovery=true176180 shift177181 ;;182+ --stress)183+ stress=true184+ shift185+ ;;178186 --help|-h)179187 usage180188 exit 0
@@ -208,8 +216,8 @@ fi
208216 if [[ "$assert_neutral_before_successor" == true && "$focus_handoffs" -eq 0 ]]; then209217 die "--assert-neutral-before-successor requires --focus-handoffs"210218 fi211-if [[ "$control_v2" == true && ( "$viewers" -lt 1 || "$viewers" -gt 3 ) ]]; then212- die "this bounded control-v2 proof supports --viewers 1 through 3"219+if [[ "$control_v2" == true && ( "$viewers" -lt 1 || "$viewers" -gt 8 ) ]]; then220+ die "this bounded control-v2 proof supports --viewers 1 through 8"213221 fi214222 case "$replace_viewer" in215223 ''|*[!0-9]*) die "--replace-viewer must be a non-negative integer" ;;
@@ -241,6 +249,20 @@ fi
241249 if [[ "$legacy_exclusive" == true && "$viewers" -ne 2 ]]; then242250 die "--legacy-exclusive currently proves exactly --viewers 2"243251 fi252+if [[ "$legacy_exclusive" == true && "$expect_second_rejected" != true ]]; then253+ die "--legacy-exclusive requires --expect-second-rejected"254+fi255+if [[ "$stress" == true ]]; then256+ [[ "$control_v2" == true && "$profile" == release && "$viewers" -eq 8 ]] \257+ || die "--stress requires --profile release --control-v2 --viewers 8"258+ assert_shared_generation=true259+ stall_viewer=8260+ assert_survivor_recovery=true261+fi262+if [[ "$control_v2" == true && "$viewers" -gt 1 && "$reconnect_cycles" -gt 0 \263+ && "$replace_viewer" -eq 0 ]]; then264+ replace_viewer=2265+fi244266 [[ "$primary_frames" -ge 4 ]] \245267 || die "--primary-frames must be at least 4 for keyframe recovery"246268 [[ "$reconnect_frames" -ge 4 ]] \
@@ -255,6 +277,10 @@ scaled_host_runtime_seconds=$((120 + estimated_primary_seconds + reconnect_cycle
255277 if [[ "$scaled_host_runtime_seconds" -gt "$host_runtime_seconds" ]]; then256278 host_runtime_seconds="$scaled_host_runtime_seconds"257279 fi280+scaled_command_timeout_seconds=$((estimated_primary_seconds + 45))281+if [[ "$scaled_command_timeout_seconds" -gt "$command_timeout_seconds" ]]; then282+ command_timeout_seconds="$scaled_command_timeout_seconds"283+fi258284 259285 if [[ -f "${HOME}/.cargo/env" ]]; then260286 # The project pins its Rust toolchain, but Cargo may not yet be on PATH in a
@@ -284,6 +310,7 @@ bounded_pid=""
284310 bounded_watchdog_pid=""285311 watchdog_pid=""286312 viewer_pids=()313+resource_sampler_pid=""287314 288315 stop_pid() {289316 local pid="$1"
@@ -323,6 +350,7 @@ cleanup() {
323350 [[ -n "$viewer_pid" ]] || continue324351 stop_pid "$viewer_pid"325352 done353+ stop_pid "$resource_sampler_pid"326354 stop_pid "$host_pid"327355 [[ -z "$host_watchdog_pid" ]] || wait "$host_watchdog_pid" 2>/dev/null || true328356
@@ -613,6 +641,7 @@ RUST_LOG='info,sigil::server=debug' "$host_bin" serve \
613641 --width 1280 \614642 --height 800 \615643 --framerate 60 \644+ --max-viewers "$viewers" \616645 --ffmpeg "$ffmpeg_bin" \617646 >"$host_log" 2>&1 &618647 host_pid=$!
@@ -621,6 +650,16 @@ host_watchdog_pid="$watchdog_pid"
621650 wait_for_log_line "$host_log" 'status=ready' "$host_pid" 'sigil' || die "host did not become ready"622651 host_node_id="$(sed -n 's/^node_id=//p' "$host_log" | tail -n 1)"623652 [[ "$host_node_id" == "$node_id" ]] || die "ready host node ID does not match its identity"653+if [[ "$stress" == true ]]; then654+ host_rss_samples="$tmp_root/host-rss-kib.txt"655+ (656+ while kill -0 "$host_pid" 2>/dev/null; do657+ ps -o rss= -p "$host_pid" 2>/dev/null | tr -d ' ' >>"$host_rss_samples" || true658+ sleep 0.25659+ done660+ ) &661+ resource_sampler_pid=$!662+fi624663 625664 if [[ "$control_v2" == true ]]; then626665 if [[ "$viewers" -gt 1 ]]; then
@@ -628,6 +667,7 @@ if [[ "$control_v2" == true ]]; then
628667 viewer_identities=()629668 viewer_pids=()630669 replacement_log=""670+ replacement_logs=()631671 viewer=1632672 while [[ "$viewer" -le "$viewers" ]]; do633673 viewer_identity="$tmp_root/viewer-${viewer}.key"
@@ -673,18 +713,39 @@ if [[ "$control_v2" == true ]]; then
673713 || die "not all control-v2 viewers attached to native MoQ"674714 675715 if [[ "$replace_viewer" -gt 0 ]]; then676- replacement_log="$tmp_root/control-v2-replacement-${replace_viewer}.log"677- if ! run_bounded "$command_timeout_seconds" "$replacement_log" \678- "$probe_bin" --control-v2 --spectator \679- --identity "${viewer_identities[$((replace_viewer - 1))]}" \680- --node-id "$node_id" --frames "$reconnect_frames" \681- --timeout-seconds "$probe_timeout_seconds" --expect-size 1280x800; then682- sed -n '1,240p' "$replacement_log" >&2 || true683- die "same-peer replacement probe failed"684- fi685- grep -Fxq 'probe=ok' "$replacement_log" || die "replacement viewer did not progress"686- grep -Fxq "roster_viewers=${viewers}" "$replacement_log" \687- || die "replacement did not preserve bounded roster cardinality"716+ cycle=1717+ while [[ "$cycle" -le "$reconnect_cycles" ]]; do718+ replacement_log="$tmp_root/control-v2-replacement-${replace_viewer}-${cycle}.log"719+ replacement_logs+=("$replacement_log")720+ replacement_role=(--spectator)721+ if [[ "$stress" == true && "$cycle" -eq 1 ]]; then722+ replacement_role=(--disconnect-with-focus)723+ fi724+ if ! run_bounded "$command_timeout_seconds" "$replacement_log" \725+ "$probe_bin" --control-v2 "${replacement_role[@]}" \726+ --identity "${viewer_identities[$((replace_viewer - 1))]}" \727+ --node-id "$node_id" --frames "$reconnect_frames" \728+ --timeout-seconds "$probe_timeout_seconds" --expect-size 1280x800; then729+ sed -n '1,240p' "$replacement_log" >&2 || true730+ die "same-peer replacement cycle $cycle failed"731+ fi732+ grep -Fxq 'probe=ok' "$replacement_log" \733+ || die "replacement viewer cycle $cycle did not progress"734+ if [[ "$stress" == true ]]; then735+ grep -Eq '^roster_viewers=(7|8)$' "$replacement_log" \736+ || die "replacement cycle $cycle exceeded the bounded stress roster"737+ else738+ grep -Fxq "roster_viewers=${viewers}" "$replacement_log" \739+ || die "replacement cycle $cycle did not preserve bounded roster cardinality"740+ fi741+ grep -Fxq 'media_progress=monotonic' "$replacement_log" \742+ || die "replacement cycle $cycle did not report monotonic progress"743+ if [[ "$stress" == true && "$cycle" -eq 1 ]]; then744+ grep -Fxq 'slot_0_input=ok' "$replacement_log" \745+ || die "stress replacement did not prove the single slot-0 input holder"746+ fi747+ cycle=$((cycle + 1))748+ done688749 fi689750 690751 viewer=1
@@ -706,7 +767,10 @@ if [[ "$control_v2" == true ]]; then
706767 if [[ "$stall_viewer" -gt 0 ]]; then707768 viewer=1708769 while [[ "$viewer" -le "$viewers" ]]; do709- if [[ "$viewer" -eq "$stall_viewer" ]]; then770+ if [[ "$viewer" -eq "$replace_viewer" ]]; then771+ viewer=$((viewer + 1))772+ continue773+ elif [[ "$viewer" -eq "$stall_viewer" ]]; then710774 grep -Fxq 'adaptive_profile=floor-breach' "${viewer_logs[$((viewer - 1))]}" \711775 || die "stalled viewer did not run the floor-breach profile"712776 grep -Fxq 'adaptive_floor_detach=ok' "${viewer_logs[$((viewer - 1))]}" \
@@ -721,6 +785,10 @@ if [[ "$control_v2" == true ]]; then
721785 done722786 grep -Fq 'detached persistently below-floor viewer after bounded isolated recovery' "$host_log" \723787 || die "host did not record terminal slow-viewer isolation"788+ if [[ "$stress" == true ]]; then789+ grep -Fq 'input v2 client accepted' "$host_log" \790+ || die "stress run did not admit a focused input path"791+ fi724792 elif [[ "$focus_handoffs" -gt 0 ]]; then725793 participant=1726794 while [[ "$participant" -le 2 ]]; do
@@ -743,7 +811,7 @@ if [[ "$control_v2" == true ]]; then
743811 spectator=$((spectator + 1))744812 done745813 roster_logs=("${viewer_logs[@]}")746- if [[ -n "$replacement_log" ]]; then roster_logs+=("$replacement_log"); fi814+ if [[ "${#replacement_logs[@]}" -gt 0 ]]; then roster_logs+=("${replacement_logs[@]}"); fi747815 grep -Fhq "roster_viewers=${viewers}" "${roster_logs[@]}" 2>/dev/null \748816 || die "no viewer observed the complete roster"749817 wait_for_log_count "$host_log" 'shared media generation stopped with all sources cleaned up' \
@@ -752,6 +820,26 @@ if [[ "$control_v2" == true ]]; then
752820 || die "multi-viewer control-v2 started more than one generation"753821 [[ "$(grep -Fc -- 'shared media generation stopped with all sources cleaned up' "$host_log" || true)" -eq 1 ]] \754822 || die "multi-viewer control-v2 stopped the generation more than once"823+ clean_resource_log="$tmp_root/host-shared-resources.log"824+ sed $'s/\033\[[0-9;]*m//g' "$host_log" >"$clean_resource_log"825+ for resource_field in \826+ 'video_sources=1' 'video_encoders=1' 'audio_sources=0' 'audio_encoders=0' \827+ 'publishers=1' 'adaptive_actuators=1'; do828+ grep -F 'shared media generation started' "$clean_resource_log" \829+ | grep -Fq -- "$resource_field" \830+ || die "shared generation resource-count evidence is missing $resource_field"831+ done832+ expected_membership_changes=$((viewers + reconnect_cycles))833+ [[ "$(grep -Fc -- 'multi-viewer session membership changed' "$host_log" || true)" -ge "$expected_membership_changes" ]] \834+ || die "host did not record bounded connect/replacement/disconnect audit events"835+ for viewer_log in "${viewer_logs[@]}" "${replacement_logs[@]}"; do836+ [[ -f "$viewer_log" ]] || continue837+ grep -Fxq 'probe=ok' "$viewer_log" || continue838+ grep -Fxq 'resource_summary_version=1' "$viewer_log" \839+ || die "viewer resource summary is missing"840+ grep -Fxq 'probe_media_object_capacity=4' "$viewer_log" \841+ || die "viewer media-object queue bound is missing"842+ done755843 if [[ "$assert_neutral_before_successor" == true ]]; then756844 clean_focus_log="$tmp_root/host-focus-transitions.log"757845 sed $'s/\033\[[0-9;]*m//g' "$host_log" >"$clean_focus_log"
@@ -794,17 +882,40 @@ if [[ "$control_v2" == true ]]; then
794882 wait "$host_watchdog_pid" 2>/dev/null || true795883 host_watchdog_pid=""796884 [[ "$host_status" -eq 0 ]] || die "host exited with status $host_status"885+ maximum_rss_kib=0886+ if [[ "$stress" == true ]]; then887+ stop_pid "$resource_sampler_pid"888+ resource_sampler_pid=""889+ maximum_rss_kib="$(awk 'NF && $1 > maximum { maximum = $1 } END { print maximum + 0 }' "$host_rss_samples")"890+ [[ "$maximum_rss_kib" -gt 0 && "$maximum_rss_kib" -le 2097152 ]] \891+ || die "stress RSS exceeded the bounded 2048 MiB gate"892+ fi797893 printf 'loopback_proof=ok\n'894+ printf 'profile=%s\n' "$profile"895+ printf 'host_sha256=%s\n' "$(sha256_file "$host_bin")"798896 printf 'control_v2=ok\n'799897 printf 'viewers=%s\n' "$viewers"800898 printf 'same_peer_replacement=%s\n' "$([[ "$replace_viewer" -gt 0 ]] && printf ok || printf not-requested)"899+ printf 'reconnect_cycles=%s\n' "$reconnect_cycles"801900 printf 'shared_generation=ok\n'802- printf 'slot_0_single_holder=%s\n' "$([[ "$stall_viewer" -gt 0 ]] && printf not-requested || printf ok)"901+ printf 'slot_0_single_holder=ok\n'803902 printf 'focus_handoffs=%s\n' "$focus_handoffs"804903 printf 'neutral_before_successor=%s\n' "$([[ "$assert_neutral_before_successor" == true ]] && printf ok || printf not-requested)"805904 printf 'stalled_viewer=%s\n' "$stall_viewer"806905 printf 'survivor_recovery=%s\n' "$([[ "$assert_survivor_recovery" == true ]] && printf ok || printf not-requested)"807906 printf 'spectator_progress=ok\n'907+ printf 'resource_summary_version=1\n'908+ printf 'shared_video_sources=1\n'909+ printf 'shared_video_encoders=1\n'910+ printf 'shared_audio_sources=0\n'911+ printf 'shared_audio_encoders=0\n'912+ printf 'shared_publishers=1\n'913+ printf 'shared_adaptive_actuators=1\n'914+ printf 'maximum_host_rss_kib=%s\n' "$maximum_rss_kib"915+ printf 'bounded_queues=ok\n'916+ printf 'revocation_isolation=not-applicable-direct-proof-mode\n'917+ printf 'final_cleanup=ok\n'918+ printf 'stress=%s\n' "$([[ "$stress" == true ]] && printf ok || printf not-requested)"808919 exit 0809920 fi810921 v2_log="$tmp_root/control-v2.log"
M scripts/public-alpha-uat.sh +57 -8
@@ -2,14 +2,14 @@
22 33 set -euo pipefail44 5-FORMAT='goq-public-alpha-uat-v2'6-EVIDENCE_SCHEMA='goq-public-alpha-evidence-v2'5+FORMAT='goq-public-alpha-uat-v3'6+EVIDENCE_SCHEMA='goq-public-alpha-evidence-v3'77 MAX_SOURCE_BYTES=$((1024 * 1024))88 DEFAULT_MAX_AGE_SECONDS=$((7 * 24 * 60 * 60))99 FUTURE_SKEW_SECONDS=3001010 GITHUB_REPOSITORY='FelineStateMachine/goq'1111 SIGIL_ASSET_TARGET_CONTRACT='linux-glibc2.17-x86_64'12-required_kinds=(cold-boot controller mouse soak network-direct network-relay reconnect second-client)12+required_kinds=(cold-boot controller mouse soak network-direct network-relay reconnect multi-viewer second-client)1313 all_kinds=("${required_kinds[@]}" loopback-preflight)1414 script_dir="$(CDPATH='' cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)"1515 repo_dir="$(CDPATH='' cd -- "$script_dir/.." && pwd -P)"
@@ -440,11 +440,20 @@ kind_keys() {
440440 keyframe_recovery_p95_ms441441 ;;442442 second-client)443- printf '%s\n' second_client_attempts second_client_rejections \443+ printf '%s\n' protocol_mode second_client_attempts second_client_rejections \444444 authorized_primary_uninterrupted rejection_reason445445 ;;446+ multi-viewer)447+ printf '%s\n' protocol_mode configured_viewer_capacity concurrent_viewers \448+ shared_media_generation shared_video_sources shared_video_encoders \449+ shared_audio_sources shared_audio_encoders shared_publishers shared_adaptive_actuators \450+ viewer_progress slot_0_holders_max focus_handoff_p95_ms slow_viewer_isolation \451+ same_peer_replacement live_view_revocation survivors_uninterrupted final_cleanup \452+ ordinary_service_restored453+ ;;446454 loopback-preflight)447- printf '%s\n' loopback_proof profile host_sha256 active_client_rejection reconnect_cycles cleanup455+ printf '%s\n' loopback_proof profile host_sha256 control_v2 viewers reconnect_cycles \456+ same_peer_replacement shared_generation slot_0_single_holder bounded_queues final_cleanup448457 ;;449458 *) return 1 ;;450459 esac
@@ -604,12 +613,46 @@ validate_reconnect() {
604613 validate_second_client() {605614 local source="$1"606615 local attempts rejections616+ require_equal "$(source_get "$source" protocol_mode)" legacy-exclusive-v1 protocol_mode607617 attempts="$(source_get "$source" second_client_attempts)"; require_number "$attempts" second_client_attempts608618 rejections="$(source_get "$source" second_client_rejections)"; require_number "$rejections" second_client_rejections609619 number_ge "$attempts" 3 || die "second-client evidence requires at least three attempts"610620 require_equal "$rejections" "$attempts" second_client_rejections611621 require_equal "$(source_get "$source" authorized_primary_uninterrupted)" pass authorized_primary_uninterrupted612- require_equal "$(source_get "$source" rejection_reason)" active-client rejection_reason622+ require_equal "$(source_get "$source" rejection_reason)" legacy-active-client rejection_reason623+}624+625+validate_multi_viewer() {626+ local source="$1"627+ local capacity viewers handoff_p95628+ require_equal "$(source_get "$source" protocol_mode)" native-moq-v2 protocol_mode629+ capacity="$(source_get "$source" configured_viewer_capacity)"630+ viewers="$(source_get "$source" concurrent_viewers)"631+ handoff_p95="$(source_get "$source" focus_handoff_p95_ms)"632+ require_number "$capacity" configured_viewer_capacity633+ require_number "$viewers" concurrent_viewers634+ require_number "$handoff_p95" focus_handoff_p95_ms635+ number_ge "$capacity" 3 || die "multi-viewer capacity must be at least three"636+ number_le "$capacity" 8 || die "multi-viewer capacity exceeds the hard ceiling"637+ number_ge "$viewers" 3 || die "multi-viewer evidence requires three concurrent viewers"638+ number_le "$viewers" "$capacity" || die "concurrent viewers exceed configured capacity"639+ number_le "$handoff_p95" 1000 || die "focus handoff p95 exceeds 1000 ms"640+ for key in shared_video_sources shared_video_encoders shared_audio_sources \641+ shared_audio_encoders shared_publishers shared_adaptive_actuators slot_0_holders_max; do642+ require_number "$(source_get "$source" "$key")" "$key"643+ done644+ require_equal "$(source_get "$source" shared_video_sources)" 1 shared_video_sources645+ require_equal "$(source_get "$source" shared_video_encoders)" 1 shared_video_encoders646+ require_equal "$(source_get "$source" shared_audio_sources)" 1 shared_audio_sources647+ require_equal "$(source_get "$source" shared_audio_encoders)" 1 shared_audio_encoders648+ require_equal "$(source_get "$source" shared_publishers)" 1 shared_publishers649+ require_equal "$(source_get "$source" shared_adaptive_actuators)" 1 shared_adaptive_actuators650+ require_equal "$(source_get "$source" slot_0_holders_max)" 1 slot_0_holders_max651+ for key in shared_media_generation viewer_progress slow_viewer_isolation \652+ same_peer_replacement live_view_revocation survivors_uninterrupted final_cleanup \653+ ordinary_service_restored; do654+ require_equal "$(source_get "$source" "$key")" pass "$key"655+ done613656 }614657 615658 validate_loopback() {
@@ -618,8 +661,13 @@ validate_loopback() {
618661 require_equal "$(source_get "$source" loopback_proof)" ok loopback_proof619662 require_equal "$(source_get "$source" profile)" release profile620663 require_equal "$(source_get "$source" host_sha256)" "$(source_get "$source" sigil_sha256)" host_sha256621- require_equal "$(source_get "$source" active_client_rejection)" ok active_client_rejection622- require_equal "$(source_get "$source" cleanup)" ok cleanup664+ require_equal "$(source_get "$source" control_v2)" ok control_v2665+ require_equal "$(source_get "$source" viewers)" 3 viewers666+ require_equal "$(source_get "$source" same_peer_replacement)" ok same_peer_replacement667+ require_equal "$(source_get "$source" shared_generation)" ok shared_generation668+ require_equal "$(source_get "$source" slot_0_single_holder)" ok slot_0_single_holder669+ require_equal "$(source_get "$source" bounded_queues)" ok bounded_queues670+ require_equal "$(source_get "$source" final_cleanup)" ok final_cleanup623671 cycles="$(source_get "$source" reconnect_cycles)"; require_number "$cycles" reconnect_cycles624672 number_ge "$cycles" 3 || die "loopback preflight requires at least three reconnect cycles"625673 }
@@ -632,6 +680,7 @@ validate_kind() {
632680 soak) validate_soak "$1" ;;633681 network-direct|network-relay) validate_network "$1" "$2" ;;634682 reconnect) validate_reconnect "$1" ;;683+ multi-viewer) validate_multi_viewer "$1" ;;635684 second-client) validate_second_client "$1" ;;636685 loopback-preflight) validate_loopback "$1" ;;637686 *) die "unsupported evidence kind: $2" ;;
M scripts/relay-spike-proof.sh +2 -1
M scripts/run-bazzite-hardware-uat.sh +63 -1
@@ -146,7 +146,7 @@ if [[ "${SIGIL_HARDWARE_UAT_SOURCE_ONLY:-}" == 1 ]]; then
146146 fi147147 148148 usage() {149- echo "usage: $0 UAT_ROOT UAT_COMMIT WORKFLOW_RUN_ID [--render-node PATH --va-encoder FACTORY]" >&2149+ echo "usage: $0 UAT_ROOT UAT_COMMIT WORKFLOW_RUN_ID [--render-node PATH --va-encoder FACTORY] [--multi-viewer-evidence FILE]" >&2150150 }151151 152152 if [[ $# -lt 3 ]]; then
@@ -162,6 +162,7 @@ render_node_override=''
162162 va_encoder_override=''163163 render_node_override_set=false164164 va_encoder_override_set=false165+multi_viewer_evidence=''165166 while [[ $# -gt 0 ]]; do166167 case "$1" in167168 --render-node)
@@ -178,12 +179,57 @@ while [[ $# -gt 0 ]]; do
178179 va_encoder_override_set=true179180 shift 2180181 ;;182+ --multi-viewer-evidence)183+ [[ $# -ge 2 && -z "$multi_viewer_evidence" && "$2" == /* ]] \184+ || { usage; exit 64; }185+ multi_viewer_evidence="$2"186+ shift 2187+ ;;181188 *)182189 usage183190 exit 64184191 ;;185192 esac186193 done194+195+multi_viewer_value() {196+ local key="$1"197+ awk -F= -v wanted="$key" '198+ $1 == wanted { count += 1; value = substr($0, index($0, "=") + 1) }199+ END { if (count != 1 || value == "") exit 1; print value }200+ ' "$multi_viewer_evidence"201+}202+203+validate_multi_viewer_evidence() {204+ local size mode key value205+ [[ -f "$multi_viewer_evidence" && ! -L "$multi_viewer_evidence" ]]206+ size="$(stat -c '%s' "$multi_viewer_evidence")"207+ mode="$(stat -c '%a' "$multi_viewer_evidence")"208+ [[ "$size" =~ ^[0-9]+$ && "$size" -le 65536 ]]209+ [[ "$mode" =~ ^[0-7]{3,4}$ && $((8#$mode & 8#022)) -eq 0 ]]210+ awk -F= '211+ NF != 2 || $1 !~ /^[a-z0-9_]+$/ || $2 !~ /^[A-Za-z0-9.,_:+-]+$/ { exit 1 }212+ seen[$1]++ { if (seen[$1] > 1) exit 1 }213+ ' "$multi_viewer_evidence"214+ [[ "$(multi_viewer_value source_commit)" == "$uat_commit" ]]215+ [[ "$(multi_viewer_value protocol_mode)" == native-moq-v2 ]]216+ [[ "$(multi_viewer_value concurrent_viewers)" -eq 3 ]]217+ for key in shared_video_sources shared_video_encoders shared_audio_sources \218+ shared_audio_encoders shared_publishers shared_adaptive_actuators slot_0_holders_max; do219+ [[ "$(multi_viewer_value "$key")" -eq 1 ]]220+ done221+ value="$(multi_viewer_value focus_handoff_p95_ms)"222+ [[ "$value" =~ ^[0-9]+$ && "$value" -le 1000 ]]223+ value="$(multi_viewer_value reconnect_cycles)"224+ [[ "$value" =~ ^[0-9]+$ && "$value" -ge 3 ]]225+ value="$(multi_viewer_value rss_p95_mib)"226+ [[ "$value" =~ ^[0-9]+$ && "$value" -le 2048 ]]227+ for key in media_progress audio_progress slow_viewer_isolation same_peer_replacement \228+ live_view_revocation survivors_uninterrupted bounded_queues final_cleanup \229+ ordinary_service_restored; do230+ [[ "$(multi_viewer_value "$key")" == pass ]]231+ done232+}187233 if [[ "$render_node_override_set" == true || "$va_encoder_override_set" == true ]]; then188234 [[ "$render_node_override_set" == true && "$va_encoder_override_set" == true ]] || {189235 echo "--render-node and --va-encoder must be provided together" >&2
@@ -1057,9 +1103,25 @@ sha256sum -c "$private/original-identity.sha256"
10571103 [[ "$(readlink "$HOME/.local/libexec/sigil-spark/current")" == "$(cat "$private/original-current-link")" ]]10581104 [[ "$(stat -c '%i:%s:%Y:%a:%U:%G' "$real_identity")" == "$(cat "$private/original-identity.stat")" ]]10591105 1106+multi_viewer_status=not-run1107+if [[ -n "$multi_viewer_evidence" ]]; then1108+ validate_multi_viewer_evidence1109+ multi_viewer_status=pass1110+fi1111+10601112 {10611113 echo hardware_uat=pass10621114 cat "$private/summary.pending"1115+ echo "multi_viewer_uat=$multi_viewer_status"1116+ if [[ "$multi_viewer_status" == pass ]]; then1117+ for key in protocol_mode concurrent_viewers shared_video_sources shared_video_encoders \1118+ shared_audio_sources shared_audio_encoders shared_publishers shared_adaptive_actuators \1119+ slot_0_holders_max focus_handoff_p95_ms reconnect_cycles rss_p95_mib \1120+ slow_viewer_isolation same_peer_replacement live_view_revocation survivors_uninterrupted \1121+ bounded_queues final_cleanup; do1122+ printf 'multi_viewer_%s=%s\n' "$key" "$(multi_viewer_value "$key")"1123+ done1124+ fi10631125 echo original_service_restored=pass10641126 echo original_config_preserved=pass10651127 echo original_identity_preserved=pass
M scripts/run-shell-tests.sh +4 -3
M scripts/tests/authenticated-probe-proof.sh +12 -4
@@ -6,6 +6,12 @@ script_dir="$(CDPATH='' cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)"
66 repo_dir="$(CDPATH='' cd -- "$script_dir/../.." && pwd -P)"77 temp_parent="${TMPDIR:-/tmp}"88 temp_parent="${temp_parent%/}"9+# Darwin's per-user TMPDIR is long enough to overflow sockaddr_un once the10+# owner-only authorization socket is appended. Use the same private mktemp11+# directory under the short system alias; the generated child remains mode 0700.12+if [[ "$(uname -s)" == Darwin ]]; then13+ temp_parent=/tmp14+fi915 temp_root="$(mktemp -d "$temp_parent/sigil-auth-probe.XXXXXX")"1016 1117 host_pid=""
@@ -169,10 +175,12 @@ grep -Fq 'host rejected control stream: Portal peer is not authorized' "$replay_
169175 }170176 enrollment="$($host_bin enrollment show --config "$host_config")"171177 grep -Fxq 'enrollment=active' <<<"$enrollment" || die 'probe enrollment is not active'172-grep -Fxq "peer_node_id=$probe_node_id" <<<"$enrollment" \173- || die 'enrollment belongs to the wrong peer'174-grep -Fxq 'grants=view,pointer-keyboard' <<<"$enrollment" \175- || die 'enrollment grants changed'178+grep -Fxq 'viewer_count=1' <<<"$enrollment" || die 'probe enrollment count changed'179+grep -Eq '^viewer=viewer-[0-9a-f]{16} grants=view,pointer-keyboard authorization_revision=[1-9][0-9]* enrolled_at_unix=[0-9]+$' \180+ <<<"$enrollment" || die 'opaque enrollment handle or grants changed'181+if grep -Fq "$probe_node_id" <<<"$enrollment"; then182+ die 'enrollment inspection exposed the raw peer identity'183+fi176184 177185 printf 'authenticated_probe_proof=ok\n'178186 printf 'invitation_redemption=ok\n'
A scripts/tests/bazzite-hardware-uat-multi-viewer.sh +34 -0
M scripts/tests/public-alpha-uat.sh +18 -5
@@ -240,7 +240,7 @@ write_common() {
240240 local observed="$3"241241 local output="$4"242242 {243- printf 'uat_schema=goq-public-alpha-evidence-v2\n'243+ printf 'uat_schema=goq-public-alpha-evidence-v3\n'244244 printf 'evidence_kind=%s\n' "$kind"245245 printf 'observed_at_unix=%s\n' "$observed"246246 printf 'git_commit=%s\n' "$(manifest_value "$bundle" git_commit)"
@@ -304,13 +304,26 @@ write_fixture() {
304304 'keyframe_recovery_p95_ms=900' >> "$output"305305 ;;306306 second-client)307- printf '%s\n' 'second_client_attempts=3' 'second_client_rejections=3' \308- 'authorized_primary_uninterrupted=pass' 'rejection_reason=active-client' >> "$output"307+ printf '%s\n' 'protocol_mode=legacy-exclusive-v1' 'second_client_attempts=3' \308+ 'second_client_rejections=3' 'authorized_primary_uninterrupted=pass' \309+ 'rejection_reason=legacy-active-client' >> "$output"310+ ;;311+ multi-viewer)312+ printf '%s\n' 'protocol_mode=native-moq-v2' 'configured_viewer_capacity=3' \313+ 'concurrent_viewers=3' 'shared_media_generation=pass' \314+ 'shared_video_sources=1' 'shared_video_encoders=1' 'shared_audio_sources=1' \315+ 'shared_audio_encoders=1' 'shared_publishers=1' 'shared_adaptive_actuators=1' \316+ 'viewer_progress=pass' 'slot_0_holders_max=1' 'focus_handoff_p95_ms=250' \317+ 'slow_viewer_isolation=pass' 'same_peer_replacement=pass' \318+ 'live_view_revocation=pass' 'survivors_uninterrupted=pass' 'final_cleanup=pass' \319+ 'ordinary_service_restored=pass' >> "$output"309320 ;;310321 loopback-preflight)311322 printf '%s\n' 'loopback_proof=ok' 'profile=release' \312323 "host_sha256=$(manifest_value "$bundle" sigil_sha256)" \313- 'active_client_rejection=ok' 'reconnect_cycles=3' 'cleanup=ok' >> "$output"324+ 'control_v2=ok' 'viewers=3' 'reconnect_cycles=3' 'same_peer_replacement=ok' \325+ 'shared_generation=ok' 'slot_0_single_holder=ok' 'bounded_queues=ok' \326+ 'final_cleanup=ok' >> "$output"314327 ;;315328 *) printf 'unknown fixture kind: %s\n' "$kind" >&2; exit 1 ;;316329 esac
@@ -321,7 +334,7 @@ record_all_required() {
321334 local bundle="$1"322335 local kind323336 local evidence324- for kind in cold-boot controller mouse soak network-direct network-relay reconnect second-client; do337+ for kind in cold-boot controller mouse soak network-direct network-relay reconnect multi-viewer second-client; do325338 evidence="$temporary_root/$kind-$RANDOM.evidence"326339 write_fixture "$bundle" "$kind" "$evidence"327340 "$harness" record --evidence-dir "$bundle" --kind "$kind" --file "$evidence" > /dev/null
M scripts/tests/sigil-appliance-status.sh +4 -0
M scripts/verify-demo-build.sh +4 -0
M src-tauri/src/media/connect.rs +21 -2
@@ -6,6 +6,7 @@ use iroh::{Endpoint, SecretKey, endpoint::presets};
66 use serde::Serialize;77 use sigil_protocol::{88 InputEvent, InvitationGrants, KeyframeRequestReasonV3, PointerSurfaceDimensions,9+ SignedSubscriptionCapability,910 };1011 use tauri::{1112 AppHandle, Emitter,
@@ -233,14 +234,31 @@ pub(crate) async fn connect_client(
233234 }),234235 NegotiatedMediaStream::V1(_) => None,235236 };237+ let subscription_expires_at_unix = match &media_negotiation {238+ NegotiatedMediaStream::V2(negotiation) => Some(239+ SignedSubscriptionCapability::decode(&negotiation.media_subscription_capability)240+ .map_err(|error| format!("Invalid media subscription capability: {error}"))?241+ .claims242+ .expires_at_unix,243+ ),244+ NegotiatedMediaStream::V1(_) => None,245+ };236246 let media_generation = next_media_generation(&state.client_media_generation)?;237247 let network_diagnostics = Arc::new(StdMutex::new(NetworkSessionDiagnostics::new(238248 Instant::now(),239249 false,240250 )));241251 if moq_authentication.is_some() {242- lock_network_diagnostics(&network_diagnostics)243- .configure_authenticated_media(MediaDeliveryRole::DirectHost);252+ let mut diagnostics = lock_network_diagnostics(&network_diagnostics);253+ diagnostics.configure_authenticated_media(MediaDeliveryRole::DirectHost);254+ diagnostics.configure_v2_session(255+ initial_session_snapshot256+ .as_ref()257+ .ok_or_else(|| "Control v2 omitted its diagnostics snapshot".to_string())?,258+ subscription_expires_at_unix259+ .ok_or_else(|| "Control v2 omitted its subscription expiry".to_string())?,260+ Instant::now(),261+ )?;244262 }245263 let media_verification_failures =246264 lock_network_diagnostics(&network_diagnostics).media_verification_failure_counter();
@@ -455,6 +473,7 @@ pub(crate) async fn connect_client(
455473 frame_recv,456474 control_rx,457475 Arc::clone(&state.session_snapshot),476+ Arc::clone(&network_diagnostics),458477 ));459478 None460479 };
M src-tauri/src/media/input_delivery.rs +1 -0
M src-tauri/src/media/metrics.rs +3 -3
M src-tauri/src/media/network_diagnostics.rs +146 -3
@@ -9,12 +9,13 @@ use std::{
99 use iroh::endpoint::{Connection, PathId};1010 use serde::Serialize;1111 use sigil_protocol::{12- AdaptiveBitrateDecisionV1, AdaptiveBitrateReasonFlagsV1, AdaptiveBitrateStateV1,13- MediaFeedbackFlags, MediaFeedbackReportV1,12+ AdaptiveBitrateDecisionV1, AdaptiveBitrateReasonFlagsV1, AdaptiveBitrateStateV1, FocusStateV2,13+ MediaFeedbackFlags, MediaFeedbackReportV1, SessionSnapshotV2,1414 };1515 1616 const LATENCY_BUCKETS_MS: usize = 5_001;1717 pub(crate) const INPUT_ACK_PENDING_CAPACITY: usize = 1_024;18+const MAX_DIAGNOSTIC_VIEWERS: usize = 8;1819 1920 pub(crate) fn lock_network_diagnostics(2021 diagnostics: &StdMutex<NetworkSessionDiagnostics>,
@@ -57,6 +58,7 @@ pub(crate) enum NetworkLeg {
5758 pub(crate) enum MediaDeliveryRole {5859 #[default]5960 DirectHost,61+ #[allow(dead_code)]6062 ViewerRelay,6163 }6264
@@ -68,6 +70,118 @@ struct MediaAuthenticationDiagnostics {
6870 verification_failures: Arc<AtomicU64>,6971 }7072 73+#[derive(Clone, Debug)]74+struct SessionProtocolDiagnostics {75+ mode: &'static str,76+ local_handle: Option<String>,77+ focus_generation: Option<u64>,78+ roster_revision: Option<u64>,79+ roster_updated_at: Option<Instant>,80+ subscription_expires_at_unix: Option<u64>,81+ stale_snapshot_total: u64,82+ invalid_snapshot_total: u64,83+}84+85+impl Default for SessionProtocolDiagnostics {86+ fn default() -> Self {87+ Self {88+ mode: "legacy_exclusive_v1",89+ local_handle: None,90+ focus_generation: None,91+ roster_revision: None,92+ roster_updated_at: None,93+ subscription_expires_at_unix: None,94+ stale_snapshot_total: 0,95+ invalid_snapshot_total: 0,96+ }97+ }98+}99+100+impl SessionProtocolDiagnostics {101+ fn configure_v2(102+ &mut self,103+ snapshot: &SessionSnapshotV2,104+ subscription_expires_at_unix: u64,105+ now: Instant,106+ ) -> Result<(), String> {107+ self.mode = "moq_multi_viewer_v2";108+ self.subscription_expires_at_unix = Some(subscription_expires_at_unix);109+ self.observe_snapshot(snapshot, now, true)110+ }111+112+ fn observe_snapshot(113+ &mut self,114+ snapshot: &SessionSnapshotV2,115+ now: Instant,116+ initial: bool,117+ ) -> Result<(), String> {118+ if let Err(error) = snapshot.validate() {119+ self.invalid_snapshot_total = self.invalid_snapshot_total.saturating_add(1);120+ return Err(format!("Invalid session diagnostics snapshot: {error}"));121+ }122+ if snapshot.viewers.len() > MAX_DIAGNOSTIC_VIEWERS {123+ self.invalid_snapshot_total = self.invalid_snapshot_total.saturating_add(1);124+ return Err("Session diagnostics roster exceeds the hard viewer bound".to_string());125+ }126+ if !initial127+ && self128+ .roster_revision129+ .is_some_and(|revision| snapshot.revision <= revision)130+ {131+ self.stale_snapshot_total = self.stale_snapshot_total.saturating_add(1);132+ return Ok(());133+ }134+ let local = snapshot.self_viewer();135+ self.local_handle = Some(local.presence_id.as_str().to_owned());136+ self.focus_generation = match &snapshot.focus {137+ FocusStateV2::Held {138+ holder,139+ session_id,140+ focus_generation,141+ ..142+ } if holder == &local.presence_id && *session_id == local.session_id => {143+ Some(*focus_generation)144+ }145+ _ => None,146+ };147+ self.roster_revision = Some(snapshot.revision);148+ self.roster_updated_at = Some(now);149+ Ok(())150+ }151+152+ fn snapshot(&self, now: Instant, now_unix: u64) -> SessionProtocolSnapshot {153+ SessionProtocolSnapshot {154+ mode: self.mode,155+ local_handle: self.local_handle.clone(),156+ focus_generation: self.focus_generation,157+ roster_revision: self.roster_revision,158+ roster_age_ms: self.roster_updated_at.map(|updated| {159+ u64::try_from(now.saturating_duration_since(updated).as_millis())160+ .unwrap_or(u64::MAX)161+ }),162+ subscription_expires_at_unix: self.subscription_expires_at_unix,163+ subscription_seconds_remaining: self164+ .subscription_expires_at_unix165+ .map(|expiry| expiry.saturating_sub(now_unix)),166+ stale_snapshot_total: self.stale_snapshot_total,167+ invalid_snapshot_total: self.invalid_snapshot_total,168+ }169+ }170+}171+172+#[derive(Clone, Debug, Serialize)]173+pub(crate) struct SessionProtocolSnapshot {174+ mode: &'static str,175+ local_handle: Option<String>,176+ focus_generation: Option<u64>,177+ roster_revision: Option<u64>,178+ roster_age_ms: Option<u64>,179+ subscription_expires_at_unix: Option<u64>,180+ subscription_seconds_remaining: Option<u64>,181+ stale_snapshot_total: u64,182+ invalid_snapshot_total: u64,183+}184+71185 #[derive(Clone, Debug, Serialize)]72186 pub(crate) struct AdaptiveViewerSnapshot {73187 scope: &'static str,
@@ -612,6 +726,7 @@ pub(crate) struct NetworkSessionDiagnostics {
612726 input_ack: InputAckDiagnostics,613727 media_authentication: MediaAuthenticationDiagnostics,614728 adaptive: AdaptiveViewerSnapshot,729+ session: SessionProtocolDiagnostics,615730 }616731 617732 impl NetworkSessionDiagnostics {
@@ -624,9 +739,32 @@ impl NetworkSessionDiagnostics {
624739 input_ack: InputAckDiagnostics::new(input_ack_negotiated),625740 media_authentication: MediaAuthenticationDiagnostics::default(),626741 adaptive: AdaptiveViewerSnapshot::default(),742+ session: SessionProtocolDiagnostics::default(),627743 }628744 }629745 746+ pub(crate) fn configure_v2_session(747+ &mut self,748+ snapshot: &SessionSnapshotV2,749+ subscription_expires_at_unix: u64,750+ now: Instant,751+ ) -> Result<(), String> {752+ self.session753+ .configure_v2(snapshot, subscription_expires_at_unix, now)754+ }755+756+ pub(crate) fn observe_session_snapshot(757+ &mut self,758+ snapshot: &SessionSnapshotV2,759+ now: Instant,760+ ) -> Result<(), String> {761+ self.session.observe_snapshot(snapshot, now, false)762+ }763+764+ pub(crate) fn mark_session_snapshot_invalid(&mut self) {765+ self.session.invalid_snapshot_total = self.session.invalid_snapshot_total.saturating_add(1);766+ }767+630768 pub(crate) fn configure_authenticated_media(&mut self, delivery_role: MediaDeliveryRole) {631769 self.media_authentication.mode = sigil_protocol::MediaAuthMode::Ed25519.label();632770 self.media_authentication.generation_certified = true;
@@ -684,8 +822,11 @@ impl NetworkSessionDiagnostics {
684822 }685823 686824 pub(crate) fn snapshot(&self, now: Instant) -> NetworkDiagnosticsSnapshot {825+ let now_unix = std::time::SystemTime::now()826+ .duration_since(std::time::UNIX_EPOCH)827+ .map_or(0, |duration| duration.as_secs());687828 NetworkDiagnosticsSnapshot {688- version: 1,829+ version: 2,689830 session_elapsed_ms: u64::try_from(690831 now.saturating_duration_since(self.started_at).as_millis(),691832 )
@@ -696,6 +837,7 @@ impl NetworkSessionDiagnostics {
696837 input_ack: self.input_ack.snapshot(),697838 media_authentication: self.media_authentication.snapshot(),698839 adaptive: self.adaptive.clone(),840+ session: self.session.snapshot(now, now_unix),699841 }700842 }701843 }
@@ -710,6 +852,7 @@ pub(crate) struct NetworkDiagnosticsSnapshot {
710852 input_ack: InputAckSnapshot,711853 media_authentication: MediaAuthenticationSnapshot,712854 adaptive: AdaptiveViewerSnapshot,855+ session: SessionProtocolSnapshot,713856 }714857 715858 #[cfg(test)]
M src-tauri/src/media/session_control.rs +13 -0
@@ -8,6 +8,7 @@ use sigil_protocol::{
88 use tauri::{AppHandle, Emitter};99 1010 use crate::commands::state::{SessionControlSender, SessionSnapshotState};11+use crate::media::network_diagnostics::{NetworkSessionDiagnostics, lock_network_diagnostics};1112 1213 pub(crate) const SESSION_CONTROL_COMMAND_CAPACITY: usize = 4;1314
@@ -44,6 +45,7 @@ pub(crate) async fn install_initial_snapshot(
4445 .map_err(|error| format!("Failed to publish initial session snapshot: {error}"))4546 }4647 48+#[allow(clippy::too_many_arguments)]4749 pub(crate) async fn run_session_control(4850 app: AppHandle,4951 native_generation: u64,
@@ -52,6 +54,7 @@ pub(crate) async fn run_session_control(
5254 mut recv: iroh::endpoint::RecvStream,5355 mut commands: tokio::sync::mpsc::Receiver<ClientControlEnvelopeV2>,5456 state: Arc<tokio::sync::Mutex<Option<SessionSnapshotState>>>,57+ diagnostics: Arc<std::sync::Mutex<NetworkSessionDiagnostics>>,5558 ) {5659 loop {5760 tokio::select! {
@@ -81,8 +84,16 @@ pub(crate) async fn run_session_control(
8184 .map(|(_, snapshot)| snapshot.revision)8285 .unwrap_or(0);8386 if snapshot.revision <= current_revision {87+ let _ = lock_network_diagnostics(&diagnostics)88+ .observe_session_snapshot(&snapshot, std::time::Instant::now());8489 continue;8590 }91+ if let Err(error) = lock_network_diagnostics(&diagnostics)92+ .observe_session_snapshot(&snapshot, std::time::Instant::now())93+ {94+ eprintln!("[client] invalid session diagnostics snapshot: {error}");95+ break;96+ }8697 *guard = Some((native_generation, snapshot.clone()));8798 drop(guard);8899 let _ = app.emit(
@@ -104,6 +115,7 @@ pub(crate) async fn run_session_control(
104115 }105116 Ok(None) => break,106117 Err(error) => {118+ lock_network_diagnostics(&diagnostics).mark_session_snapshot_invalid();107119 eprintln!("[client] invalid control v2 message: {error}");108120 break;109121 }
@@ -151,6 +163,7 @@ pub(crate) async fn send_focus_command(
151163 }152164 }153165 166+#[cfg(test)]154167 pub(crate) async fn latest_snapshot(155168 state: &tokio::sync::Mutex<Option<SessionSnapshotState>>,156169 native_generation: u64,
M src-tauri/src/media/transport.rs +1 -0