Skip to content

Commit bea0fc5

Browse files
yuki-uchidaclaude
andcommitted
feat(relay): tear down upstream subscription when a malformed track is detected
A per-track watch task reports the sticky malformed latch into the relay event pipeline, where the upstream subscription is unregistered, UNSUBSCRIBE is forwarded upstream (draft-14 2.5 MUST), and ingress is stopped. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 13d0457 commit bea0fc5

11 files changed

Lines changed: 492 additions & 2 deletions

File tree

architecture_decision_record/relay/architecture.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,13 @@ sequences::{PublishNamespace, Subscribe, Fetch, …}.handle(...)
7676
MAX_REQUEST_ID, REQUESTS_BLOCKED, PUBLISH_NAMESPACE_CANCEL, PUBLISH_DONE,
7777
SUBSCRIBE_UPDATE, FETCH_CANCEL, TRACK_STATUS) are logged in the event span
7878
and dropped by the worker; they have no `sequences` entry.
79+
- One relay-internal event exists: `MalformedTrackDetected(session_id,
80+
track_key)`, emitted by the ingress-side malformed watch task (not by a
81+
peer). It is routed to the upstream publisher session's worker and handled
82+
by `sequences::malformed_track::MalformedTrackCleanup`: remove the
83+
`ActiveUpstreamSubscription`, send upstream UNSUBSCRIBE (§2.5 MUST), and
84+
stop ingress via `IngressCommand::StopTrack`. Duplicate reports are
85+
idempotent (the table entry is only found once).
7986
- Terminal events (`Disconnected` / `ProtocolViolation`) trigger
8087
`cleanup_session` (idempotent) and end the worker. Cleanup: remove the
8188
session from the pub/sub directory, stop affected egress readers, forward
@@ -143,6 +150,13 @@ from `TrackCache` over a new uni stream.
143150
ignored (draft-14 §8.2 multiple-publisher dedup is a known TODO), and only
144151
the owning publisher's `Stop` tears the reader down.
145152
- Readers append every object into `TrackCache` and broadcast a `TrackEvent`.
153+
- On `Start` the coordinator also spawns a per-track
154+
`MalformedTrackWatchTask` that waits on the cache's sticky §2.5 malformed
155+
latch and reports `SessionEvent::MalformedTrackDetected` into the event
156+
pipeline; the task is dropped (aborted) on `StopTrack` so it never blocks
157+
cache eviction. Downstream, `EgressRunner` watches the same latch and
158+
terminates subscriptions with PUBLISH_DONE(MALFORMED_TRACK); `FetchIngest`
159+
bails on the latch and sends upstream FETCH_CANCEL for its own fetch.
146160

147161
### Cache (`modules/relay/cache`)
148162
- `TrackCache`: `group_id → subgroup_id → GroupCache` for streams plus a

relay/src/modules/event_handler.rs

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ use crate::modules::{
1313
sequences::{
1414
CascadingRelayContext,
1515
fetch::Fetch,
16+
malformed_track::MalformedTrackCleanup,
1617
publish::Publish,
1718
publish_namespace::PublishNamespace,
1819
publish_namespace_done::PublishNamespaceDone,
@@ -153,6 +154,7 @@ impl EventHandler {
153154
| SessionEvent::Fetch(id, _)
154155
| SessionEvent::FetchCancel(id, _)
155156
| SessionEvent::TrackStatus(id, _)
157+
| SessionEvent::MalformedTrackDetected(id, _)
156158
| SessionEvent::Disconnected(id)
157159
| SessionEvent::ProtocolViolation(id) => *id,
158160
};
@@ -385,6 +387,19 @@ impl EventHandler {
385387
.instrument(event_span)
386388
.await;
387389
}
390+
SessionEvent::MalformedTrackDetected(session_id, track_key) => {
391+
MalformedTrackCleanup {}
392+
.handle(
393+
session_id,
394+
&session_span,
395+
&track_key,
396+
local_pub_sub_directory.as_ref(),
397+
&control_message_forwarder,
398+
&ingress_sender,
399+
)
400+
.instrument(event_span)
401+
.await;
402+
}
388403
SessionEvent::GoAway(..)
389404
| SessionEvent::MaxRequestId(..)
390405
| SessionEvent::RequestsBlocked(..)
@@ -591,6 +606,13 @@ impl EventHandler {
591606
track_namespace = %handler.track_namespace(),
592607
track_name = %handler.track_name(),
593608
),
609+
SessionEvent::MalformedTrackDetected(session_id, track_key) => tracing::info_span!(
610+
parent: session_span,
611+
"relay.session.event",
612+
session_id = %session_id,
613+
event = "MalformedTrackDetected",
614+
track_key = %track_key,
615+
),
594616
}
595617
}
596618

relay/src/modules/relay/ingress.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
pub(crate) mod datagram_reader;
22
pub(crate) mod fetch_ingest;
33
pub(crate) mod ingress_coordinator;
4+
pub(crate) mod malformed_track_watch_task;
45
pub(crate) mod stream_ingress_task;
56
pub(crate) mod stream_reader;

relay/src/modules/relay/ingress/ingress_coordinator.rs

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,10 +11,12 @@ use crate::modules::{
1111
cache::store::TrackCacheStore,
1212
ingress::{
1313
datagram_reader::{DatagramReader, DatagramReceiveCommand, DatagramReceiveStart},
14+
malformed_track_watch_task::MalformedTrackWatchTask,
1415
stream_ingress_task::{StreamIngressCommand, StreamIngressTask, StreamReceiveStart},
1516
},
1617
notifications::track_notifier::ObjectNotifyProducerMap,
1718
},
19+
session_event::SessionEvent,
1820
session_repository::SessionRepository,
1921
types::{SessionId, TrackKey},
2022
};
@@ -48,6 +50,7 @@ impl IngressCoordinator {
4850
session_repo: Arc<tokio::sync::Mutex<SessionRepository>>,
4951
cache_store: Arc<TrackCacheStore>,
5052
object_notify_producer_map: Arc<ObjectNotifyProducerMap>,
53+
session_event_sender: mpsc::UnboundedSender<SessionEvent>,
5154
) -> Self {
5255
let (stream_tx, stream_rx) = mpsc::channel::<StreamIngressCommand>(64);
5356
let (datagram_tx, datagram_rx) = mpsc::channel::<DatagramReceiveCommand>(64);
@@ -57,14 +60,15 @@ impl IngressCoordinator {
5760
object_notify_producer_map.clone(),
5861
);
5962
let datagram_reader =
60-
DatagramReader::run(datagram_rx, cache_store, object_notify_producer_map);
63+
DatagramReader::run(datagram_rx, cache_store.clone(), object_notify_producer_map);
6164

6265
let (command_sender, mut command_receiver) = mpsc::channel::<IngressCommand>(512);
6366
let session_repo_for_runner = session_repo;
6467

6568
let command_runner = tokio::spawn(async move {
6669
let mut join_set = tokio::task::JoinSet::new();
6770
let mut create_stop_senders = HashMap::<TrackKey, watch::Sender<bool>>::new();
71+
let mut malformed_watch_tasks = HashMap::<TrackKey, MalformedTrackWatchTask>::new();
6872
loop {
6973
tokio::select! {
7074
Some(command) = command_receiver.recv() => {
@@ -102,6 +106,15 @@ impl IngressCoordinator {
102106
}
103107
let (create_stop_sender, mut create_stop_receiver) = watch::channel(false);
104108
create_stop_senders.insert(track_key.clone(), create_stop_sender);
109+
malformed_watch_tasks.insert(
110+
track_key.clone(),
111+
MalformedTrackWatchTask::run(
112+
cache_store.get_or_create(&track_key),
113+
track_key.clone(),
114+
command.publisher_session_id,
115+
session_event_sender.clone(),
116+
),
117+
);
105118
let create_receiver_span = tracing::info_span!(
106119
parent: &command.parent_span,
107120
"relay.upstream.ingress",
@@ -194,6 +207,7 @@ impl IngressCoordinator {
194207
let _ = stop_sender.send(true);
195208
tracing::info!(%track_key, "upstream ingress stop requested");
196209
}
210+
malformed_watch_tasks.remove(&track_key);
197211
let stream_result = stream_tx
198212
.send(StreamIngressCommand::Stop {
199213
track_key: track_key.clone(),
Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
use std::sync::Arc;
2+
3+
use tokio::{sync::mpsc, task::JoinHandle};
4+
5+
use crate::modules::{
6+
relay::cache::track_cache::TrackCache,
7+
session_event::SessionEvent,
8+
types::{SessionId, TrackKey},
9+
};
10+
11+
/// Watches a track's §2.5 malformed latch and reports the detection to the
12+
/// relay event pipeline, where the upstream subscription is torn down.
13+
/// Dropped (and thereby aborted) when ingress for the track stops, so it
14+
/// never outlives the track's cache entry.
15+
pub(crate) struct MalformedTrackWatchTask {
16+
join_handle: JoinHandle<()>,
17+
}
18+
19+
impl MalformedTrackWatchTask {
20+
pub(crate) fn run(
21+
cache: Arc<TrackCache>,
22+
track_key: TrackKey,
23+
publisher_session_id: SessionId,
24+
event_sender: mpsc::UnboundedSender<SessionEvent>,
25+
) -> Self {
26+
let join_handle = tokio::spawn(async move {
27+
cache.malformed_track_detected().await;
28+
if event_sender
29+
.send(SessionEvent::MalformedTrackDetected(
30+
publisher_session_id,
31+
track_key.clone(),
32+
))
33+
.is_err()
34+
{
35+
tracing::warn!(%track_key, "failed to report malformed track detection");
36+
}
37+
});
38+
Self { join_handle }
39+
}
40+
}
41+
42+
impl Drop for MalformedTrackWatchTask {
43+
fn drop(&mut self) {
44+
self.join_handle.abort();
45+
}
46+
}
47+
48+
#[cfg(test)]
49+
mod tests {
50+
use std::time::Duration;
51+
52+
use bytes::Bytes;
53+
use moqt::{ExtensionHeaders, SubgroupHeader, SubgroupId, SubgroupObject, SubgroupObjectField};
54+
55+
use super::*;
56+
use crate::modules::{core::data_object::DataObject, relay::types::StreamSubgroupId};
57+
58+
fn make_object(payload: &'static [u8]) -> DataObject {
59+
let message_type =
60+
SubgroupHeader::new(0, 0, SubgroupId::Value(0), 0, false, false).message_type;
61+
DataObject::SubgroupObject(SubgroupObjectField {
62+
message_type,
63+
object_id_delta: 0,
64+
extension_headers: ExtensionHeaders::default(),
65+
subgroup_object: SubgroupObject::new_payload(Bytes::from_static(payload)),
66+
})
67+
}
68+
69+
async fn latch_malformed(cache: &TrackCache) {
70+
let subgroup = StreamSubgroupId::Value(0);
71+
let _ = cache
72+
.append_stream_object(0, &subgroup, Some(0), make_object(b"a"))
73+
.await;
74+
let _ = cache
75+
.append_stream_object(0, &subgroup, Some(0), make_object(b"b"))
76+
.await;
77+
assert!(cache.is_malformed());
78+
}
79+
80+
#[tokio::test]
81+
async fn reports_detection_to_the_event_pipeline() {
82+
// Arrange
83+
let cache = Arc::new(TrackCache::new());
84+
let track_key = TrackKey::new("ns", "track");
85+
let (event_sender, mut event_receiver) = mpsc::unbounded_channel();
86+
let _task = MalformedTrackWatchTask::run(cache.clone(), track_key.clone(), 7, event_sender);
87+
88+
// Act: conflicting duplicate objects latch the track.
89+
latch_malformed(&cache).await;
90+
91+
// Assert: the detection event names the track and its publisher session.
92+
let event = tokio::time::timeout(Duration::from_secs(3), event_receiver.recv())
93+
.await
94+
.expect("watch task should report the detection")
95+
.expect("event channel should stay open");
96+
match event {
97+
SessionEvent::MalformedTrackDetected(session_id, reported_track_key) => {
98+
assert_eq!(session_id, 7);
99+
assert_eq!(reported_track_key, track_key);
100+
}
101+
_ => panic!("expected MalformedTrackDetected"),
102+
}
103+
}
104+
105+
#[tokio::test]
106+
async fn dropped_watcher_reports_nothing() {
107+
// Arrange
108+
let cache = Arc::new(TrackCache::new());
109+
let (event_sender, mut event_receiver) = mpsc::unbounded_channel();
110+
let task = MalformedTrackWatchTask::run(
111+
cache.clone(),
112+
TrackKey::new("ns", "track"),
113+
7,
114+
event_sender,
115+
);
116+
117+
// Act: ingress stops the track before any detection.
118+
drop(task);
119+
latch_malformed(&cache).await;
120+
121+
// Assert: the aborted watcher sends no event (sender dropped).
122+
assert!(
123+
tokio::time::timeout(Duration::from_secs(1), event_receiver.recv())
124+
.await
125+
.expect("channel should close once the watcher is dropped")
126+
.is_none()
127+
);
128+
}
129+
}

relay/src/modules/sequences.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
pub(crate) mod fetch;
2+
pub(crate) mod malformed_track;
23
pub(crate) mod publish;
34
pub(crate) mod publish_namespace;
45
pub(crate) mod publish_namespace_done;

0 commit comments

Comments
 (0)