open-standards/joule-code/joule-code-rs/crates/joule-moq/src/session.rs
Transaction Science 08c499f8ca joule-code: port CommunicationOS transport (jwp + joule-moq) into the public standard
The MoQT transport that joule-code-collab's network rung needs lived in the
private joulesperbit repo (as inv-moq + jwp), so a public consumer couldn't
depend on it. Port both into the public open-standards joule-code workspace,
self-contained (concrete dep versions, not workspace-inherited) so a bare git
clone builds them:

- crates/jwp: Joule Wire Protocol — energy-aware binary framing (CBOR, zstd/lz4,
  TCP + optional QUIC). Self-contained, external deps only.
- crates/joule-moq: MoQT (Media-over-QUIC) wire protocol + JWP signed-receipt
  extension for CommunicationOS — Publisher/Subscriber/Relay traits, MockFabric
  (in-process) + a production QUIC path (quic feature). Renamed from inv-moq
  to drop the invisible-* namespace and match the joule-code branding; depends
  only on jwp.

Both build (default + quic) and pass their tests (joule-moq 29, jwp 152) in the
new home. The transport-moqt example now path-deps the public joule-moq instead
of reaching into joulesperbit, and its end-to-end BFT-ops-over-MoQT-with-signed-
receipts tests pass. This unblocks public consumers (agent-zed) from git-depping
the transport for cross-machine fleet coordination.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-13 21:29:05 -04:00

194 lines
5.8 KiB
Rust

//! Session state machine for a MoQT endpoint.
//!
//! A session is a single QUIC connection between two MoQT peers. Both ends
//! traverse the same lifecycle: `Init → Handshaking → Established → Closing
//! → Closed`. The role (publisher / subscriber / pubsub / relay) is
//! negotiated during handshake.
use crate::error::{MoqError, MoqResult};
use crate::messages::SetupMessage;
use crate::MOQT_DRAFT_VERSION;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum SessionRole {
Publisher,
Subscriber,
PubSub,
Relay,
}
impl SessionRole {
pub fn role_bits(&self) -> u8 {
match self {
SessionRole::Publisher => 0x01,
SessionRole::Subscriber => 0x02,
SessionRole::PubSub => 0x03,
SessionRole::Relay => 0x03,
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum SessionState {
Init,
Handshaking,
Established,
Closing,
Closed,
}
/// Cross-cutting state for a single MoQT session.
pub struct MoqSession {
pub role: SessionRole,
pub state: SessionState,
pub negotiated_version: Option<u32>,
pub jwp_receipts_enabled: bool,
/// Active subscriptions, keyed by subscribe_id.
pub subscriptions: HashMap<u64, SubscriptionState>,
/// Track alias → subscribe_id reverse index.
pub alias_index: HashMap<u64, u64>,
}
#[derive(Clone, Debug)]
pub struct SubscriptionState {
pub subscribe_id: u64,
pub track_alias: u64,
pub require_receipts: bool,
pub last_group: Option<u64>,
pub last_object: Option<u64>,
pub joules_consumed_micro: u64,
}
impl MoqSession {
pub fn new(role: SessionRole) -> Self {
Self {
role,
state: SessionState::Init,
negotiated_version: None,
jwp_receipts_enabled: false,
subscriptions: HashMap::new(),
alias_index: HashMap::new(),
}
}
/// Produce the SETUP message this endpoint will send first.
pub fn build_setup(&self, request_jwp: bool, label: Option<String>) -> SetupMessage {
SetupMessage {
versions: vec![MOQT_DRAFT_VERSION],
role: self.role.role_bits(),
jwp_receipts: request_jwp,
peer_label: label,
}
}
/// Process a peer SETUP and transition to Handshaking → Established.
pub fn accept_setup(&mut self, peer: &SetupMessage) -> MoqResult<SetupMessage> {
if !peer.versions.contains(&MOQT_DRAFT_VERSION) {
return Err(MoqError::UnsupportedVersion(
peer.versions.first().copied().unwrap_or(0),
));
}
self.state = SessionState::Handshaking;
self.negotiated_version = Some(MOQT_DRAFT_VERSION);
self.jwp_receipts_enabled = peer.jwp_receipts;
let response = self.build_setup(self.jwp_receipts_enabled, None);
self.state = SessionState::Established;
Ok(response)
}
pub fn add_subscription(
&mut self,
subscribe_id: u64,
track_alias: u64,
require_receipts: bool,
) -> MoqResult<()> {
if self.state != SessionState::Established {
return Err(MoqError::InvalidSessionState(format!(
"subscribe requires Established, was {:?}",
self.state
)));
}
self.subscriptions.insert(
subscribe_id,
SubscriptionState {
subscribe_id,
track_alias,
require_receipts,
last_group: None,
last_object: None,
joules_consumed_micro: 0,
},
);
self.alias_index.insert(track_alias, subscribe_id);
Ok(())
}
pub fn remove_subscription(&mut self, subscribe_id: u64) {
if let Some(s) = self.subscriptions.remove(&subscribe_id) {
self.alias_index.remove(&s.track_alias);
}
}
pub fn record_object(
&mut self,
track_alias: u64,
group_id: u64,
object_id: u64,
joules_micro: u64,
) -> MoqResult<()> {
let sub_id = *self
.alias_index
.get(&track_alias)
.ok_or(MoqError::SubscriptionNotFound(track_alias))?;
let sub = self
.subscriptions
.get_mut(&sub_id)
.ok_or(MoqError::SubscriptionNotFound(track_alias))?;
sub.last_group = Some(group_id);
sub.last_object = Some(object_id);
sub.joules_consumed_micro = sub.joules_consumed_micro.saturating_add(joules_micro);
Ok(())
}
pub fn close(&mut self) {
self.state = SessionState::Closing;
self.subscriptions.clear();
self.alias_index.clear();
self.state = SessionState::Closed;
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn handshake_round_trip() {
let mut srv = MoqSession::new(SessionRole::PubSub);
let client_setup = SetupMessage {
versions: vec![MOQT_DRAFT_VERSION],
role: 0x02,
jwp_receipts: true,
peer_label: Some("client".into()),
};
let resp = srv.accept_setup(&client_setup).unwrap();
assert_eq!(srv.state, SessionState::Established);
assert!(srv.jwp_receipts_enabled);
assert!(resp.jwp_receipts);
}
#[test]
fn rejects_unsupported_version() {
let mut srv = MoqSession::new(SessionRole::Publisher);
let bad_setup = SetupMessage {
versions: vec![0x1234_5678],
role: 0x02,
jwp_receipts: false,
peer_label: None,
};
let result = srv.accept_setup(&bad_setup);
assert!(matches!(result, Err(MoqError::UnsupportedVersion(_))));
}
}