From 8c90665a8b1023763ea3f883e0ef4091f90620bc Mon Sep 17 00:00:00 2001 From: Lukas Herman Date: Thu, 15 Oct 2026 09:53:25 -0400 Subject: [PATCH 1/2] ci: fix post-merge formatter and test regressions --- .github/workflows/ci.yml | 2 +- agents/pulsebeam-agent-web/src/browser.rs | 3 +- agents/pulsebeam-agent-web/src/engine.rs | 5 +-- agents/pulsebeam-agent-web/tests/browser.rs | 1 + .../tests/contracts/initialization-failure.js | 6 +-- .../tests/contracts/meet-media-observer.js | 10 ++++- .../tests/contracts/meet-publisher-start.js | 19 ++++++++ .../tests/contracts/public-strict-consumer.ts | 1 + .../tests/contracts/remote-audio-contract.js | 29 ++++++++++++ agents/pulsebeam-agent-web/tests/meet.rs | 11 +++++ agents/pulsebeam-agent-web/web/agent.ts | 14 ++++-- .../pulsebeam-agent-web/web/remote-audio.ts | 12 +++-- .../pulsebeam-agent-web/web/remote-catalog.ts | 3 ++ agents/react/tests/browser/ownership.tsx | 3 +- crates/pulsebeam-auth/src/api_key.rs | 34 +++++++++----- crates/pulsebeam-auth/src/keys.rs | 4 +- crates/pulsebeam-cli/src/main.rs | 34 +++++--------- crates/pulsebeam-core/BUILD.bazel | 2 +- .../src/tests/common/mod.rs | 4 +- crates/pulsebeam/src/control/api.rs | 8 +--- crates/pulsebeam/src/control/controller.rs | 4 +- crates/pulsebeam/src/control/negotiator.rs | 34 ++++++++++++-- crates/pulsebeam/src/main.rs | 7 ++- crates/pulsebeam/src/route.rs | 44 ++++++++++++++++++- server/auth/src/bin/generate_vectors.rs | 2 +- server/rust/BUILD.bazel | 2 - server/rust/src/development.rs | 9 +--- server/rust/tests/consumer.rs | 3 +- server/typescript/tests/consumer.mjs | 7 +-- 29 files changed, 224 insertions(+), 93 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 45b3590f0..222b219d8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -17,7 +17,7 @@ jobs: - uses: actions/checkout@v5 - uses: ./.github/actions/setup-ci - name: Run complete non-privileged gate - run: ./bazel test //:test --test_output=errors --zip_undeclared_test_outputs + run: ./bazel test //:test --keep_going --test_output=errors --zip_undeclared_test_outputs - name: Retain test logs and browser evidence if: always() uses: actions/upload-artifact@v4 diff --git a/agents/pulsebeam-agent-web/src/browser.rs b/agents/pulsebeam-agent-web/src/browser.rs index 5b965892d..784c5c11c 100644 --- a/agents/pulsebeam-agent-web/src/browser.rs +++ b/agents/pulsebeam-agent-web/src/browser.rs @@ -559,7 +559,8 @@ impl BrowserRuntime { let actor = actor .as_ref() .ok_or_else(|| js_error("runtime actor not initialized"))?; - actor.send_public(PublicCommand::SetLogLevel(level.into())) + actor + .send_public(PublicCommand::SetLogLevel(level.into())) .map_err(js_error)?; self.inner.logger.set_level(level); Ok(()) diff --git a/agents/pulsebeam-agent-web/src/engine.rs b/agents/pulsebeam-agent-web/src/engine.rs index 6c4eb6365..6d16d4a59 100644 --- a/agents/pulsebeam-agent-web/src/engine.rs +++ b/agents/pulsebeam-agent-web/src/engine.rs @@ -126,10 +126,7 @@ impl fmt::Debug for PublicCommand { .debug_tuple("SetConnected") .field(connected) .finish(), - Self::SetLogLevel(level) => formatter - .debug_tuple("SetLogLevel") - .field(level) - .finish(), + Self::SetLogLevel(level) => formatter.debug_tuple("SetLogLevel").field(level).finish(), } } } diff --git a/agents/pulsebeam-agent-web/tests/browser.rs b/agents/pulsebeam-agent-web/tests/browser.rs index 2861e336c..2af28afab 100644 --- a/agents/pulsebeam-agent-web/tests/browser.rs +++ b/agents/pulsebeam-agent-web/tests/browser.rs @@ -238,6 +238,7 @@ async fn web(server: &StaticServer, failure: bool) -> TestResult<()> { "attachRemoteVideo", "createAgent", "createCaptureSource", + "createManagedAgent", "nativeCaptureTrack" ] ); diff --git a/agents/pulsebeam-agent-web/tests/contracts/initialization-failure.js b/agents/pulsebeam-agent-web/tests/contracts/initialization-failure.js index 89fd2b561..5b7ae65e5 100644 --- a/agents/pulsebeam-agent-web/tests/contracts/initialization-failure.js +++ b/agents/pulsebeam-agent-web/tests/contracts/initialization-failure.js @@ -17,9 +17,9 @@ new Promise((resolve, reject) => { const after = agent.getSnapshot(); resolve( after.connection === "terminal-failure" && - agent.connection === after.connection && - after.failure === failure && - error === failure, + agent.connection === after.connection && + after.failure === failure && + error === failure, ); }); agent.setState({ connected: true }); diff --git a/agents/pulsebeam-agent-web/tests/contracts/meet-media-observer.js b/agents/pulsebeam-agent-web/tests/contracts/meet-media-observer.js index d8f82f050..4b28e6537 100644 --- a/agents/pulsebeam-agent-web/tests/contracts/meet-media-observer.js +++ b/agents/pulsebeam-agent-web/tests/contracts/meet-media-observer.js @@ -23,8 +23,16 @@ ), })), ); + const topics = JSON.stringify( + { + status: window.__meet?.sender?.getSnapshot().topics, + chats: window.__meet?.chats, + emojis: window.__meet?.emojis, + }, + (_, value) => (typeof value === "bigint" ? value.toString() : value), + ); throw new Error( - `Meet media condition: ${label}; UI: ${document.body.innerText.slice(0, 1500)}; transports: ${JSON.stringify(transports)}`, + `Meet media condition: ${label}; UI: ${document.body.innerText.slice(0, 1500)}; topics: ${topics}; transports: ${JSON.stringify(transports)}`, ); }; const peers = new Set(); diff --git a/agents/pulsebeam-agent-web/tests/contracts/meet-publisher-start.js b/agents/pulsebeam-agent-web/tests/contracts/meet-publisher-start.js index dee8cd3b1..fd560fa28 100644 --- a/agents/pulsebeam-agent-web/tests/contracts/meet-publisher-start.js +++ b/agents/pulsebeam-agent-web/tests/contracts/meet-publisher-start.js @@ -76,5 +76,24 @@ await source.close(); }, }); + await window.__meet.wait(() => { + const snapshot = sender.getSnapshot(); + const subscribers = snapshot.topics.subscribers; + return ( + snapshot.connection === "connected" && + subscribers.some( + (status) => + status.name === "chat" && + status.mode === "ordered" && + status.connected, + ) && + subscribers.some( + (status) => + status.name === "reactions" && + status.mode === "latest" && + status.connected, + ) + ); + }, "publisher topic subscriptions ready"); return true; })(); diff --git a/agents/pulsebeam-agent-web/tests/contracts/public-strict-consumer.ts b/agents/pulsebeam-agent-web/tests/contracts/public-strict-consumer.ts index 9b3e9df57..5aefa11c4 100644 --- a/agents/pulsebeam-agent-web/tests/contracts/public-strict-consumer.ts +++ b/agents/pulsebeam-agent-web/tests/contracts/public-strict-consumer.ts @@ -9,6 +9,7 @@ import { type AgentFailure, type AgentSnapshot, type AgentState, + type ConnectionState, type FailureClass, type PlaybackFailure, type RemoteMediaAttachment, diff --git a/agents/pulsebeam-agent-web/tests/contracts/remote-audio-contract.js b/agents/pulsebeam-agent-web/tests/contracts/remote-audio-contract.js index 117dac71b..945203f3b 100644 --- a/agents/pulsebeam-agent-web/tests/contracts/remote-audio-contract.js +++ b/agents/pulsebeam-agent-web/tests/contracts/remote-audio-contract.js @@ -376,6 +376,35 @@ received.every((track) => track.readyState === "live"), "close rejects recovery and leaves borrowed media live", ); + const retainedAudio = independent.participant("alice").audio("second"); + independent.resetPlayback(); + await wait( + () => otherContext.state === "closed", + "deactivation releases the audio context", + ); + assert( + otherOutput.routes.size === 0 && + decoders[1].paused && + decoders[1].srcObject === null, + "deactivation releases audible routes and private decoding", + ); + independent.update(snapshot); + await wait(() => { + retainedAudio.readWaveform(wave); + return ( + contexts[2]?.state === "running" && + wave.some((value) => Math.abs(value) > 0.00001) + ); + }, "reactivated audio output"); + assert( + independent.participant("alice").audio("second") === retainedAudio && + retainedAudio.receiving && + contexts.length === 3 && + outputs.get(contexts[2]).routes.size === 1 && + captured.every((track) => track.readyState === "live") && + received.every((track) => track.readyState === "live"), + "reactivation retains handles and borrowed media without duplicate output", + ); return true; } finally { catalogs.forEach((catalog) => catalog.close()); diff --git a/agents/pulsebeam-agent-web/tests/meet.rs b/agents/pulsebeam-agent-web/tests/meet.rs index 130649537..50aaa886a 100644 --- a/agents/pulsebeam-agent-web/tests/meet.rs +++ b/agents/pulsebeam-agent-web/tests/meet.rs @@ -117,6 +117,17 @@ async fn meet_media_contract(gesture_before_media: bool) -> TestResult<()> { driver.find(By::Css("form input")).await?.send_keys("outbound Meet chat").await?; click(&driver, "Send message").await?; click(&driver, "Send a reaction").await?; + let _: bool = evaluate_json(&bidi, &context, r#"(async () => { + await __meet.wait(() => { + const menu = document.querySelector('[role="menu"]'); + if (!menu) return false; + for (let node = menu; node; node = node.parentElement) { + if (node.getAnimations().some(animation => animation.pending || animation.playState === 'running')) return false; + } + return true; + }, 'reaction menu animation settled'); + return true; + })()"#).await?; click(&driver, "React with 👍").await?; let _: bool = evaluate_json(&bidi, &context, r#"(async () => { await __meet.wait(() => __meet.chats.some(({text}) => text === 'outbound Meet chat') && __meet.emojis.some(({emoji}) => emoji === '👍'), 'real Meet sends both topics'); diff --git a/agents/pulsebeam-agent-web/web/agent.ts b/agents/pulsebeam-agent-web/web/agent.ts index 56d651138..85bbad768 100644 --- a/agents/pulsebeam-agent-web/web/agent.ts +++ b/agents/pulsebeam-agent-web/web/agent.ts @@ -391,7 +391,8 @@ class AgentFacade implements Agent { try { runtime = new BrowserRuntime(this.#runtimeConfig); } catch (error) { - if (active()) this.#terminalFailure("invalid-configuration", message(error)); + if (active()) + this.#terminalFailure("invalid-configuration", message(error)); throw error; } this.#runtime = runtime; @@ -399,14 +400,16 @@ class AgentFacade implements Agent { if (active()) this.#runtimeEvent(event); }); runtime.set_error_listener((error: unknown) => { - if (active()) this.#emitFailure(localFailureClass(error), message(error)); + if (active()) + this.#emitFailure(localFailureClass(error), message(error)); }); try { runtime.replace_desired( desiredValue(this.#effectiveState(this.#state)), ); } catch (error) { - if (active()) this.#terminalFailure("invalid-configuration", message(error)); + if (active()) + this.#terminalFailure("invalid-configuration", message(error)); runtime.abort(); this.#runtime = undefined; throw error; @@ -447,6 +450,7 @@ class AgentFacade implements Agent { runtime.close(); runtime.abort(); } + this.#remote.resetPlayback(); this.#publish(emptySnapshot("disconnected")); } @@ -586,7 +590,9 @@ class AgentFacade implements Agent { void this.replaceLocalTrack( slot, track, - kind === "video" ? { contentHint: "motion" } : { contentHint: "speech" }, + kind === "video" + ? { contentHint: "motion" } + : { contentHint: "speech" }, ).catch(() => {}); } diff --git a/agents/pulsebeam-agent-web/web/remote-audio.ts b/agents/pulsebeam-agent-web/web/remote-audio.ts index 96c3069ab..8a41f3ec1 100644 --- a/agents/pulsebeam-agent-web/web/remote-audio.ts +++ b/agents/pulsebeam-agent-web/web/remote-audio.ts @@ -36,6 +36,8 @@ export class RemoteAudioPlayback { this.#decoder?.srcObject ? this.#decoder.play() : undefined, ]); if (this.#closed) throw new Error("agent is closed"); + if (context !== this.#context) + throw new Error("audio playback was reset"); if (context.state !== "running") throw new Error("audio playback is not running"); } catch (error) { @@ -174,9 +176,7 @@ export class RemoteAudioPlayback { ); } - close(): void { - if (this.#closed) return; - this.#closed = true; + reset(): void { for (const route of this.#routes.values()) { route.source.disconnect(); route.analyser.disconnect(); @@ -188,4 +188,10 @@ export class RemoteAudioPlayback { this.#context = undefined; if (context) void context.close().catch(() => {}); } + + close(): void { + if (this.#closed) return; + this.#closed = true; + this.reset(); + } } diff --git a/agents/pulsebeam-agent-web/web/remote-catalog.ts b/agents/pulsebeam-agent-web/web/remote-catalog.ts index fccbe9d5c..06eaa3dd9 100644 --- a/agents/pulsebeam-agent-web/web/remote-catalog.ts +++ b/agents/pulsebeam-agent-web/web/remote-catalog.ts @@ -217,6 +217,9 @@ export class RemoteCatalog implements RemoteMedia { resumeAudio(): Promise { return this.#playback.resume(); } + resetPlayback(): void { + this.#playback.reset(); + } read(id: string | undefined, buffer: Float32Array, spectrum: boolean): void { this.#playback.read(id, buffer, spectrum); } diff --git a/agents/react/tests/browser/ownership.tsx b/agents/react/tests/browser/ownership.tsx index ae61cfa16..37142cfbc 100644 --- a/agents/react/tests/browser/ownership.tsx +++ b/agents/react/tests/browser/ownership.tsx @@ -120,8 +120,7 @@ export async function runOwnershipContract() { root.unmount(); unmounted = true; const ownedCleanup = - replacement.connection === "disconnected" && - track.readyState === "live"; + replacement.connection === "disconnected" && track.readyState === "live"; track.stop(); return { ownedIndependent: diff --git a/crates/pulsebeam-auth/src/api_key.rs b/crates/pulsebeam-auth/src/api_key.rs index 69135d13b..bd5a5a2a9 100644 --- a/crates/pulsebeam-auth/src/api_key.rs +++ b/crates/pulsebeam-auth/src/api_key.rs @@ -90,16 +90,17 @@ impl FromStr for ApiKey { type Err = ApiKeyError; fn from_str(value: &str) -> Result { - let text = value.strip_prefix(PREFIX).ok_or(ApiKeyError::InvalidPrefix)?; + let text = value + .strip_prefix(PREFIX) + .ok_or(ApiKeyError::InvalidPrefix)?; if text.len() != ENCODED_DIGITS { return Err(ApiKeyError::InvalidLength); } let bytes = CROCKFORD .decode(text.as_bytes()) .map_err(|_| ApiKeyError::InvalidEncoding)?; - let data: [u8; ENCODED_BYTES] = bytes - .try_into() - .map_err(|_| ApiKeyError::InvalidEncoding)?; + let data: [u8; ENCODED_BYTES] = + bytes.try_into().map_err(|_| ApiKeyError::InvalidEncoding)?; // Reject noncanonical unused padding bits, including codec aliases // that would alter the underlying secret's textual representation. let canonical = CROCKFORD.encode(&data); @@ -114,9 +115,15 @@ impl FromStr for ApiKey { if canonical != normalized { return Err(ApiKeyError::InvalidEncoding); } - let project_id = data[..16].try_into().map_err(|_| ApiKeyError::InvalidEncoding)?; - let key_id = data[16..32].try_into().map_err(|_| ApiKeyError::InvalidEncoding)?; - let seed = data[32..].try_into().map_err(|_| ApiKeyError::InvalidEncoding)?; + let project_id = data[..16] + .try_into() + .map_err(|_| ApiKeyError::InvalidEncoding)?; + let key_id = data[16..32] + .try_into() + .map_err(|_| ApiKeyError::InvalidEncoding)?; + let seed = data[32..] + .try_into() + .map_err(|_| ApiKeyError::InvalidEncoding)?; Self::new(project_id, key_id, seed) } } @@ -152,7 +159,7 @@ mod tests { ); assert_eq!( key.key_id_bytes(), - &[0, 0, 0, 0, 0, 0x70, 0, 0x80, 0, 0, 0, 0, 0, 2] + &[0, 0, 0, 0, 0, 0, 0x70, 0, 0x80, 0, 0, 0, 0, 0, 0, 2] ); assert!(key.to_secret_string().starts_with("key_0")); assert_eq!(key.to_secret_string().len(), 108); @@ -165,9 +172,14 @@ mod tests { "key_00000000001R0100000000000040000000007004000000000001", "9TRDHKQQZTPK0QA24NX4JXGPC8H29RNMQPCK935R3QB033JQ7YR0" ); - assert!(good.replacen("key_0", "key_1", 1).parse::().is_err()); - assert!(good[..good.len() - 1].parse::().is_err()); + assert!( + good.replacen("key_0", "key_1", 1) + .parse::() + .is_err() + ); + let truncated = good.strip_suffix('0').unwrap(); + assert!(truncated.parse::().is_err()); assert!(good.replace("key_0", "sk_0").parse::().is_err()); - assert!(format!("{}1", &good[..good.len() - 1]).parse::().is_err()); + assert!(format!("{truncated}1").parse::().is_err()); } } diff --git a/crates/pulsebeam-auth/src/keys.rs b/crates/pulsebeam-auth/src/keys.rs index c5b3fd882..7a2f9ab17 100644 --- a/crates/pulsebeam-auth/src/keys.rs +++ b/crates/pulsebeam-auth/src/keys.rs @@ -248,9 +248,7 @@ mod tests { Err(KeyValidationError::InvalidPrefix { .. }) )); assert!(matches!( - public - .replacen("pk_0", "pk_1", 1) - .parse::(), + public.replacen("pk_0", "pk_1", 1).parse::(), Err(KeyValidationError::UnsupportedVersion) )); let short = public.chars().take(55).collect::(); diff --git a/crates/pulsebeam-cli/src/main.rs b/crates/pulsebeam-cli/src/main.rs index 2dc952812..afbcffdf0 100644 --- a/crates/pulsebeam-cli/src/main.rs +++ b/crates/pulsebeam-cli/src/main.rs @@ -20,8 +20,8 @@ use pulsebeam_agent_native::{ }; use pulsebeam_agent_native::{clock::clock_anchor, wallclock_at}; use pulsebeam_core::auth::{ - ApiKey, ApiSigningKey, DEVELOPMENT_API_KEY_ID, DEVELOPMENT_API_SIGNING_KEY, DEVELOPMENT_PROJECT_ID, - KeyRegistry, KeyRegistryEntry, + ApiKey, ApiSigningKey, DEVELOPMENT_API_KEY_ID, DEVELOPMENT_API_SIGNING_KEY, + DEVELOPMENT_PROJECT_ID, KeyRegistry, KeyRegistryEntry, }; use pulsebeam_core::identity::{ ApiKeyId, AudioTrackId, DataTrackId, ParticipantExternalId, ParticipantId, ProjectId, @@ -374,8 +374,7 @@ fn random_signing_key() -> ApiSigningKey { } fn generate_key() -> Result<()> { - let (api_key, registry) = - create_api_key(ProjectId::new(), Vec::new(), random_signing_key())?; + let (api_key, registry) = create_api_key(ProjectId::new(), Vec::new(), random_signing_key())?; print_key_material(&api_key, ®istry) } @@ -402,12 +401,9 @@ fn create_api_key( signing_key: ApiSigningKey, ) -> Result<(String, KeyRegistry)> { let key_id = ApiKeyId::new(); - let api_key = ApiKey::from_signing_key( - *project_id.as_bytes(), - *key_id.as_bytes(), - &signing_key, - )? - .to_secret_string(); + let api_key = + ApiKey::from_signing_key(*project_id.as_bytes(), *key_id.as_bytes(), &signing_key)? + .to_secret_string(); keys.push(KeyRegistryEntry { key_id, public_key: signing_key.public_key(), @@ -919,7 +915,6 @@ mod tests { } #[test] - fn benchmark_arguments_keep_the_documented_surface() { #[test] fn benchmark_arguments_keep_the_documented_surface() { let cli = Cli::try_parse_from([ "pulsebeam-cli", @@ -1127,12 +1122,8 @@ mod tests { assert!(matches!(config.command, Some(KeyCommand::Rotate))); assert!(Cli::try_parse_from(["pulsebeam-cli", "auth-key"]).is_err()); - assert!( - Cli::try_parse_from(["pulsebeam-cli", "key", "--project-id", "anything"]).is_err() - ); - assert!( - Cli::try_parse_from(["pulsebeam-cli", "key", "--output-dir", "auth"]).is_err() - ); + assert!(Cli::try_parse_from(["pulsebeam-cli", "key", "--project-id", "anything"]).is_err()); + assert!(Cli::try_parse_from(["pulsebeam-cli", "key", "--output-dir", "auth"]).is_err()); } #[test] @@ -1161,12 +1152,8 @@ mod tests { #[test] fn key_rotation_preserves_project_and_existing_public_keys() { let project_id = ProjectId::new(); - let (first_api_key, registry) = create_api_key( - project_id, - Vec::new(), - ApiSigningKey::from_seed([8; 32]), - ) - .unwrap(); + let (first_api_key, registry) = + create_api_key(project_id, Vec::new(), ApiSigningKey::from_seed([8; 32])).unwrap(); let first_key_id = registry.keys()[0].key_id; let (second_api_key, rotated) = create_api_key( @@ -1186,7 +1173,6 @@ mod tests { } #[test] - fn video_policy_excludes_self_audio_and_tracks_beyond_capacity() { #[test] fn video_policy_excludes_self_audio_and_tracks_beyond_capacity() { let mut snapshot = Snapshot { participant_id: Some("self".into()), diff --git a/crates/pulsebeam-core/BUILD.bazel b/crates/pulsebeam-core/BUILD.bazel index f4e46029e..197f6174a 100644 --- a/crates/pulsebeam-core/BUILD.bazel +++ b/crates/pulsebeam-core/BUILD.bazel @@ -30,7 +30,7 @@ rust_library( crate_name = "pulsebeam_core", crate_root = "src/lib.rs", srcs = glob(["src/**/*.rs"], exclude = ["src/bin/**"]), - compile_data = glob(["README.md", "src/**"], exclude = ["src/**/*.rs"]), + compile_data = glob(["README.md", "src/**"], exclude = ["src/**/*.rs"]) + ["//server/auth:development.json"], edition = crate_edition(), crate_features = [], rustc_env_files = [":cargo_env"], diff --git a/crates/pulsebeam-simulator/src/tests/common/mod.rs b/crates/pulsebeam-simulator/src/tests/common/mod.rs index e9ff79d40..1f099ec7f 100644 --- a/crates/pulsebeam-simulator/src/tests/common/mod.rs +++ b/crates/pulsebeam-simulator/src/tests/common/mod.rs @@ -9,8 +9,8 @@ pub use harness::{ }; use pulsebeam_core::auth::{ - DEVELOPMENT_API_KEY_ID, DEVELOPMENT_API_PUBLIC_KEY, DEVELOPMENT_PROJECT_ID, KeyRegistryEntry, - , KeyRegistry, + DEVELOPMENT_API_KEY_ID, DEVELOPMENT_API_PUBLIC_KEY, DEVELOPMENT_PROJECT_ID, KeyRegistry, + KeyRegistryEntry, }; use pulsebeam_runtime::net::UdpMode; use std::{ diff --git a/crates/pulsebeam/src/control/api.rs b/crates/pulsebeam/src/control/api.rs index 8b06bab56..4d2159521 100644 --- a/crates/pulsebeam/src/control/api.rs +++ b/crates/pulsebeam/src/control/api.rs @@ -829,11 +829,7 @@ fn build_openapi(base_path: &str) -> utoipa::openapi::OpenApi { )] struct ApiDoc; -pub fn router( - controller: ControllerSender, - cfg: ApiConfig, - key_registry: KeyRegistry, -) -> Router { +pub fn router(controller: ControllerSender, cfg: ApiConfig, key_registry: KeyRegistry) -> Router { let openapi = build_openapi(&cfg.base_path); let api = Router::new() .route("/native", post(create_native)) @@ -899,7 +895,7 @@ mod tests { use super::*; use pulsebeam_core::{ auth::{ - DEVELOPMENT_API_KEY_ID, DEVELOPMENT_API_SIGNING_KEY, DEVELOPMENT_API_PUBLIC_KEY, + DEVELOPMENT_API_KEY_ID, DEVELOPMENT_API_PUBLIC_KEY, DEVELOPMENT_API_SIGNING_KEY, DEVELOPMENT_PROJECT_ID, KeyRegistryEntry, }, identity::{ParticipantExternalId, RoomExternalId}, diff --git a/crates/pulsebeam/src/control/controller.rs b/crates/pulsebeam/src/control/controller.rs index 91e0f46ac..eb0dc7ae6 100644 --- a/crates/pulsebeam/src/control/controller.rs +++ b/crates/pulsebeam/src/control/controller.rs @@ -23,7 +23,7 @@ use crate::{ }, }; use pulsebeam_core::auth::{ - AuthorizationExpiry, MAX_COMPACT_TOKEN_LEN, KeyRegistry, TokenError, VerifiedAuthorization, + AuthorizationExpiry, KeyRegistry, MAX_COMPACT_TOKEN_LEN, TokenError, VerifiedAuthorization, verify_participant_token, }; use pulsebeam_runtime::mailbox; @@ -1578,7 +1578,7 @@ mod replacement_tests { #[tokio::test] async fn renewal_verifies_jwt_off_control_loop_and_commits_only_current_identity() { use pulsebeam_core::auth::{ - DEVELOPMENT_API_KEY_ID, DEVELOPMENT_API_SIGNING_KEY, DEVELOPMENT_API_PUBLIC_KEY, + DEVELOPMENT_API_KEY_ID, DEVELOPMENT_API_PUBLIC_KEY, DEVELOPMENT_API_SIGNING_KEY, DEVELOPMENT_PROJECT_ID, KeyRegistryEntry, }; let mut actor = actor(); diff --git a/crates/pulsebeam/src/control/negotiator.rs b/crates/pulsebeam/src/control/negotiator.rs index 27a5c0152..3208dcf49 100644 --- a/crates/pulsebeam/src/control/negotiator.rs +++ b/crates/pulsebeam/src/control/negotiator.rs @@ -394,6 +394,34 @@ mod tests { ); } + fn with_unique_mids(offer: SdpOffer) -> SdpOffer { + // str0m's random MIDs can collide within a batch of pending additions. + let sdp = offer.to_sdp_string(); + let bundle = sdp + .lines() + .filter(|line| line.starts_with("a=mid:")) + .enumerate() + .map(|(index, _)| format!("m{index}")) + .collect::>() + .join(" "); + let mut index = 0; + let lines = sdp + .lines() + .map(|line| { + if line.starts_with("a=group:BUNDLE ") { + format!("a=group:BUNDLE {bundle}") + } else if line.starts_with("a=mid:") { + let mid = format!("a=mid:m{index}"); + index += 1; + mid + } else { + line.to_owned() + } + }) + .collect::>(); + SdpOffer::from_sdp_string(&(lines.join("\r\n") + "\r\n")).unwrap() + } + #[test] fn negotiated_capacity_is_independent_per_kind_and_direction() { let mut rtc = RtcConfig::new().build(std::time::Instant::now()); @@ -408,9 +436,9 @@ mod tests { change.add_media(kind, direction, None, None, None); } } - let offer = change.apply().unwrap().0; + let offer = with_unique_mids(change.apply().unwrap().0); let mut negotiator = Negotiator::new(Vec::new()); - assert!(negotiator.create_answer(offer, IceCreds::new()).is_ok()); + negotiator.create_answer(offer, IceCreds::new()).unwrap(); } #[test] @@ -420,7 +448,7 @@ mod tests { for _ in 0..33 { change.add_media(MediaKind::Video, Direction::RecvOnly, None, None, None); } - let offer = change.apply().unwrap().0; + let offer = with_unique_mids(change.apply().unwrap().0); let mut negotiator = Negotiator::new(Vec::new()); assert!(matches!( negotiator.create_answer(offer, IceCreds::new()), diff --git a/crates/pulsebeam/src/main.rs b/crates/pulsebeam/src/main.rs index 910e277b0..7bdc0e833 100644 --- a/crates/pulsebeam/src/main.rs +++ b/crates/pulsebeam/src/main.rs @@ -86,8 +86,8 @@ fn main() { .with(fmt_layer) .init(); - let registry = configured_key_registry(&args, Path::new("key-registry.json")) - .unwrap_or_else(|error| { + let registry = + configured_key_registry(&args, Path::new("key-registry.json")).unwrap_or_else(|error| { pulsebeam_runtime::fatal!("invalid authentication configuration: {error:#}") }); tracing::info!(project_id = %registry.project_id(), "configured authentication project"); @@ -202,11 +202,11 @@ mod tests { #![allow(clippy::expect_used, clippy::panic, clippy::unwrap_used)] use super::*; - use std::path::PathBuf; use pulsebeam_core::{ auth::ApiSigningKey, identity::{ApiKeyId, ProjectId}, }; + use std::path::PathBuf; fn project(last: u8) -> ProjectId { ProjectId::from_bytes([0, 0, 0, 0, 0, 0, 0x70, 0, 0x80, 0, 0, 0, 0, 0, 0, last]) @@ -264,5 +264,4 @@ mod tests { assert!(configured_key_registry(&args, &path).is_err()); std::fs::remove_file(path).unwrap(); } - } diff --git a/crates/pulsebeam/src/route.rs b/crates/pulsebeam/src/route.rs index 546e04fc2..2d3876655 100644 --- a/crates/pulsebeam/src/route.rs +++ b/crates/pulsebeam/src/route.rs @@ -754,7 +754,11 @@ impl RouteRuntime { dedup: crate::participant::reverse::ReverseDedup, now: Instant, ) -> bool { - let Some(RouteAction::Reverse { .. }) = self.resolve(address) else { + // Feedback can arrive after retirement or slot reuse during reconnection. + let Some(action) = self.resolve(address) else { + return false; + }; + let RouteAction::Reverse { .. } = action else { debug_assert!(false, "a reverse envelope must resolve to a reverse route"); return false; }; @@ -1145,6 +1149,44 @@ mod tests { assert!(runtime.accept_reverse(route, packet.dedup(), Instant::now())); } + #[tokio::test(start_paused = true)] + async fn stale_reverse_packets_do_not_poison_a_replacement_route() { + let mut runtime = RouteRuntime::new(ShardId::new(0)); + let old = address(0, 0, 7); + let replacement = address(0, 0, 8); + let action = RouteAction::Reverse { + target: crate::keys::TrackHandle::default(), + }; + let packet = crate::participant::reverse::ReversePacket::reliable_control(vec![4, 5]); + let now = Instant::now(); + + assert!(!runtime.accept_reverse(old, packet.dedup(), now)); + runtime.install_action(old, action); + assert!(runtime.accept_reverse(old, packet.dedup(), now)); + assert!(runtime.retire(old)); + assert!(!runtime.accept_reverse(old, packet.dedup(), now)); + + runtime.install_action(replacement, action); + assert!(!runtime.accept_reverse(old, packet.dedup(), now)); + assert!(runtime.accept_reverse(replacement, packet.dedup(), now)); + assert!(!runtime.accept_reverse(replacement, packet.dedup(), now)); + } + + #[tokio::test(start_paused = true)] + #[should_panic(expected = "a reverse envelope must resolve to a reverse route")] + async fn reverse_packets_must_not_target_a_live_forward_route() { + let mut runtime = RouteRuntime::new(ShardId::new(0)); + let route = address(0, 0, 7); + runtime.install_action( + route, + RouteAction::Forward { + target: crate::keys::TrackHandle::default(), + }, + ); + let packet = crate::participant::reverse::ReversePacket::reliable_control(vec![4, 5]); + runtime.accept_reverse(route, packet.dedup(), Instant::now()); + } + #[tokio::test(start_paused = true)] async fn a_new_epoch_restarts_packet_accounting() { let mut runtime = RouteRuntime::new(ShardId::new(0)); diff --git a/server/auth/src/bin/generate_vectors.rs b/server/auth/src/bin/generate_vectors.rs index b1fd0f277..c49a2d9d7 100644 --- a/server/auth/src/bin/generate_vectors.rs +++ b/server/auth/src/bin/generate_vectors.rs @@ -64,7 +64,7 @@ fn main() -> Result<(), Box> { canonical_project_id: project.as_str(), canonical_key_id: key.as_str(), seed_hex: case.seed_hex, - public_key_hex: encode_hex(signing.public_key().as_bytes()), + public_key_hex: encode_hex(signing.verifying_key().as_bytes()), header, claims, signing_input, diff --git a/server/rust/BUILD.bazel b/server/rust/BUILD.bazel index 18f115c6e..8c6d20504 100644 --- a/server/rust/BUILD.bazel +++ b/server/rust/BUILD.bazel @@ -24,8 +24,6 @@ rust_library( crate_root = "src/lib.rs", srcs = glob(["src/**/*.rs"]), compile_data = [":development_json"], - rustc_flags = ["--cfg=pulsebeam_bazel"], - rustc_env = {"PULSEBEAM_DEVELOPMENT_JSON": "$(execpath :development_json)"}, edition = crate_edition(), rustc_env_files = [":cargo_env"], lint_config = "//crates/pulsebeam-core:lints", diff --git a/server/rust/src/development.rs b/server/rust/src/development.rs index 5d2728971..e67981e2e 100644 --- a/server/rust/src/development.rs +++ b/server/rust/src/development.rs @@ -1,5 +1,4 @@ //! Publicly known credentials used only by PulseBeam servers running with --dev. -#![allow(unexpected_cfgs)] // Bazel uses a compile-time file path; Cargo uses the packaged file. use serde::Deserialize; /// Insecure, fixed development credentials. Never use to authorize real users. @@ -13,12 +12,8 @@ pub struct InsecureDevCredentials { pub public_key: String, } -// Bazel's generated file lives under bazel-out, not next to this source file. -// Packaged Cargo consumers use the normal relative include. -#[cfg(pulsebeam_bazel)] -const DEVELOPMENT_JSON: &str = - include_str!(concat!("../../../", env!("PULSEBEAM_DEVELOPMENT_JSON"))); -#[cfg(not(pulsebeam_bazel))] +// Bazel generates this file beside the source; published packages bundle it +// at the same relative path. const DEVELOPMENT_JSON: &str = include_str!("development.json"); /// The same embedded development identity used by the SFU's --dev mode. diff --git a/server/rust/tests/consumer.rs b/server/rust/tests/consumer.rs index a9d68fd21..722db4590 100644 --- a/server/rust/tests/consumer.rs +++ b/server/rust/tests/consumer.rs @@ -147,8 +147,7 @@ fn packaged_development_credentials_match_canonical_json() { format!("{}1", &actual.api_key[..actual.api_key.len() - 1]), ] { assert!( - sign_participant_token_with_api_key(&invalid, "demo", "alice", 2_000_000_000) - .is_err() + sign_participant_token_with_api_key(&invalid, "demo", "alice", 2_000_000_000).is_err() ); } } diff --git a/server/typescript/tests/consumer.mjs b/server/typescript/tests/consumer.mjs index a201b2132..9d437af33 100644 --- a/server/typescript/tests/consumer.mjs +++ b/server/typescript/tests/consumer.mjs @@ -32,11 +32,8 @@ writeFileSync( join(directory, "consumer.mjs"), 'export { signParticipantToken, INSECURE_DEV_CREDENTIALS, INSECURE_DEV_API_KEY } from "@pulsebeam/server";', ); -const { - signParticipantToken, - INSECURE_DEV_CREDENTIALS, - INSECURE_DEV_API_KEY, -} = await import(pathToFileURL(join(directory, "consumer.mjs"))); +const { signParticipantToken, INSECURE_DEV_CREDENTIALS, INSECURE_DEV_API_KEY } = + await import(pathToFileURL(join(directory, "consumer.mjs"))); const development = JSON.parse(readFileSync(developmentFixture)); assert.deepEqual(INSECURE_DEV_CREDENTIALS, { projectId: development.projectId, From ccde2c2eaf456e440999c9f187482141c8cb4ee4 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 15 Oct 2026 16:27:03 +0000 Subject: [PATCH 2/2] build(deps): bump the github-actions group across 1 directory with 7 updates Bumps the github-actions group with 7 updates in the / directory: | Package | From | To | | --- | --- | --- | | [actions/checkout](https://github.com/actions/checkout) | `5` | `7` | | [actions/upload-artifact](https://github.com/actions/upload-artifact) | `4` | `7` | | [actions/download-artifact](https://github.com/actions/download-artifact) | `4` | `8` | | [docker/metadata-action](https://github.com/docker/metadata-action) | `5.8.0` | `6.2.0` | | [docker/login-action](https://github.com/docker/login-action) | `3.6.0` | `4.6.0` | | [actions/upload-pages-artifact](https://github.com/actions/upload-pages-artifact) | `4` | `5` | | [actions/deploy-pages](https://github.com/actions/deploy-pages) | `4` | `5` | Updates `actions/checkout` from 5 to 7 - [Release notes](https://github.com/actions/checkout/releases) - [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md) - [Commits](https://github.com/actions/checkout/compare/v5...v7) Updates `actions/upload-artifact` from 4 to 7 - [Release notes](https://github.com/actions/upload-artifact/releases) - [Commits](https://github.com/actions/upload-artifact/compare/v4...v7) Updates `actions/download-artifact` from 4 to 8 - [Release notes](https://github.com/actions/download-artifact/releases) - [Commits](https://github.com/actions/download-artifact/compare/v4...v8) Updates `docker/metadata-action` from 5.8.0 to 6.2.0 - [Release notes](https://github.com/docker/metadata-action/releases) - [Commits](https://github.com/docker/metadata-action/compare/c1e51972afc2121e065aed6d45c65596fe445f3f...dc802804100637a589fabce1cb79ff13a1411302) Updates `docker/login-action` from 3.6.0 to 4.6.0 - [Release notes](https://github.com/docker/login-action/releases) - [Commits](https://github.com/docker/login-action/compare/5e57cd118135c172c3672efd75eb46360885c0ef...dbcb813823bdd20940b903addbd779551569679f) Updates `actions/upload-pages-artifact` from 4 to 5 - [Release notes](https://github.com/actions/upload-pages-artifact/releases) - [Commits](https://github.com/actions/upload-pages-artifact/compare/v4...v5) Updates `actions/deploy-pages` from 4 to 5 - [Release notes](https://github.com/actions/deploy-pages/releases) - [Commits](https://github.com/actions/deploy-pages/compare/v4...v5) --- updated-dependencies: - dependency-name: actions/checkout dependency-version: '7' dependency-type: direct:production update-type: version-update:semver-major dependency-group: github-actions - dependency-name: actions/deploy-pages dependency-version: '5' dependency-type: direct:production update-type: version-update:semver-major dependency-group: github-actions - dependency-name: actions/download-artifact dependency-version: '8' dependency-type: direct:production update-type: version-update:semver-major dependency-group: github-actions - dependency-name: actions/upload-artifact dependency-version: '7' dependency-type: direct:production update-type: version-update:semver-major dependency-group: github-actions - dependency-name: actions/upload-pages-artifact dependency-version: '5' dependency-type: direct:production update-type: version-update:semver-major dependency-group: github-actions - dependency-name: docker/login-action dependency-version: 4.6.0 dependency-type: direct:production update-type: version-update:semver-major dependency-group: github-actions - dependency-name: docker/metadata-action dependency-version: 6.2.0 dependency-type: direct:production update-type: version-update:semver-major dependency-group: github-actions ... Signed-off-by: dependabot[bot] --- .github/workflows/ci.yml | 4 ++-- .github/workflows/docker.yml | 10 +++++----- .github/workflows/meet-pages.yml | 6 +++--- .github/workflows/nightly.yml | 4 ++-- .github/workflows/npm.yml | 2 +- .github/workflows/provisioning-acceptance.yml | 8 ++++---- .github/workflows/release.yml | 12 ++++++------ 7 files changed, 23 insertions(+), 23 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 222b219d8..fac3ef562 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -14,13 +14,13 @@ jobs: test: runs-on: ubuntu-24.04 steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v7 - uses: ./.github/actions/setup-ci - name: Run complete non-privileged gate run: ./bazel test //:test --keep_going --test_output=errors --zip_undeclared_test_outputs - name: Retain test logs and browser evidence if: always() - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: bazel-test-evidence # Browser evidence names can contain ':'; upload Bazel's archive instead. diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index a0fbeda5b..e25631cbb 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -29,14 +29,14 @@ jobs: contents: read packages: write steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v7 - uses: ./.github/actions/setup-ci - name: Build and load Bazel-owned linux/amd64 image if: ${{ !inputs.reuse_image }} run: ./bazel run --config=release //release:load - name: Restore validated release image if: ${{ inputs.reuse_image }} - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v8 with: name: pulsebeam-amd64-image path: ${{ runner.temp }}/pulsebeam-image @@ -53,14 +53,14 @@ jobs: run: docker save pulsebeam:local -o "$RUNNER_TEMP/pulsebeam-amd64-image.tar" - name: Retain validated release image if: ${{ inputs.validate_only }} - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: pulsebeam-amd64-image path: ${{ runner.temp }}/pulsebeam-amd64-image.tar if-no-files-found: error - name: Image tags id: meta - uses: docker/metadata-action@c1e51972afc2121e065aed6d45c65596fe445f3f + uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 with: images: ${{ env.GHCR_IMAGE }} tags: | @@ -71,7 +71,7 @@ jobs: type=raw,value=latest,enable={{is_default_branch}} - name: Registry login if: ${{ !inputs.validate_only && github.event_name != 'pull_request' }} - uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef + uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f with: registry: ghcr.io username: ${{ github.actor }} diff --git a/.github/workflows/meet-pages.yml b/.github/workflows/meet-pages.yml index a7ac34145..2a9936d16 100644 --- a/.github/workflows/meet-pages.yml +++ b/.github/workflows/meet-pages.yml @@ -13,11 +13,11 @@ jobs: permissions: contents: read steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v7 - uses: ./.github/actions/setup-ci - name: Build and validate deployable static export run: ./bazel test //apps/meet:check - - uses: actions/upload-pages-artifact@v4 + - uses: actions/upload-pages-artifact@v5 with: path: bazel-bin/apps/meet/out deploy: @@ -32,4 +32,4 @@ jobs: steps: - name: Deploy to GitHub Pages id: deployment - uses: actions/deploy-pages@v4 + uses: actions/deploy-pages@v5 diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index 405e27a82..e6940672b 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -21,7 +21,7 @@ jobs: timeout-minutes: 120 continue-on-error: true steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v7 - uses: ./.github/actions/setup-ci - name: Sweep and retain replay commands shell: bash @@ -34,7 +34,7 @@ jobs: fi ./bazel run //:sweep -- --seeds "$seeds" --from-seed "$from" 2>&1 | tee seed-sweep.log - if: always() - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: seed-sweep-evidence path: seed-sweep.log diff --git a/.github/workflows/npm.yml b/.github/workflows/npm.yml index f459b0998..f6da3b35c 100644 --- a/.github/workflows/npm.yml +++ b/.github/workflows/npm.yml @@ -15,7 +15,7 @@ jobs: contents: read id-token: write steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v7 - uses: ./.github/actions/setup-ci - name: Validate package release identity run: ./bazel run //release/npm:plan -- --tag "$GITHUB_REF_NAME" diff --git a/.github/workflows/provisioning-acceptance.yml b/.github/workflows/provisioning-acceptance.yml index e9fc1eab0..4bd14d746 100644 --- a/.github/workflows/provisioning-acceptance.yml +++ b/.github/workflows/provisioning-acceptance.yml @@ -14,7 +14,7 @@ jobs: native-image: runs-on: ubuntu-24.04 steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v7 - uses: ./.github/actions/setup-ci - name: Validate GNU release artifacts run: ./bazel test --config=release //release:elf_contract //release:contract --test_output=errors @@ -30,17 +30,17 @@ jobs: native-arm: runs-on: ubuntu-24.04-arm steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v7 - uses: ./.github/actions/setup-ci - name: Validate native aarch64 GNU release contracts run: ./bazel test --config=release //release:contract //release:elf_contract --test_output=errors meet: runs-on: ubuntu-24.04 steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v7 - uses: ./.github/actions/setup-ci - name: Assemble deployable Meet output without deployment run: ./bazel test //apps/meet:check --test_output=errors - - uses: actions/upload-pages-artifact@v4 + - uses: actions/upload-pages-artifact@v5 with: path: bazel-bin/apps/meet/out diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 3ba32077b..47a19c3b4 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -13,7 +13,7 @@ jobs: outputs: plan: ${{ steps.plan.outputs.plan }} steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v7 - uses: ./.github/actions/setup-ci - id: plan name: Validate version tag against package authority @@ -35,7 +35,7 @@ jobs: target: aarch64-unknown-linux-gnu runs-on: ${{ matrix.runner }} steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v7 - uses: ./.github/actions/setup-ci - name: Build architecture-appropriate release assets run: | @@ -49,7 +49,7 @@ jobs: tar -xJf pulsebeam-${{ matrix.target }}.tar.xz -C "$temporary" "$temporary/pulsebeam-${{ matrix.target }}/pulsebeam" --version rm -rf "$temporary" - - uses: actions/upload-artifact@v4 + - uses: actions/upload-artifact@v7 with: name: release-${{ matrix.target }} path: bazel-bin/release/artifacts/* @@ -58,10 +58,10 @@ jobs: needs: [plan, checks] runs-on: ubuntu-24.04 steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v7 - uses: ./.github/actions/setup-ci - run: ./bazel build //release:global - - uses: actions/upload-artifact@v4 + - uses: actions/upload-artifact@v7 with: name: release-global path: bazel-bin/release/global-artifacts/* @@ -81,7 +81,7 @@ jobs: permissions: contents: write steps: - - uses: actions/download-artifact@v4 + - uses: actions/download-artifact@v8 with: pattern: release-* path: artifacts