Skip to content

Commit b029e2d

Browse files
allan sargeantclaude
andcommitted
Add real SRT relay integration tests, CI, and route persistence
- Integration tests in crates/srt-io/tests/relay.rs drive the full input -> crosspoint -> output path with real srt-tokio client sockets as the encoder/decoder, including a live re-route mid-stream over an already-established SRT connection. This is meaningfully stronger verification than the earlier lsof/curl-level checks. - GitHub Actions CI: fmt --check, clippy -D warnings, test, on every push/PR to main. - Optional routing persistence (Phase 3 roadmap item): an output's in-memory route is written to a JSON state file on change and reloaded on startup, overriding the config default_source. Disabled by default (opt in via [state] in the TOML config) so existing in-memory-only behavior is unchanged unless configured. Verified with a real process restart, not just unit tests. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
1 parent bbf2f49 commit b029e2d

8 files changed

Lines changed: 292 additions & 2 deletions

File tree

.github/workflows/ci.yml

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
name: CI
2+
3+
on:
4+
push:
5+
branches: [main]
6+
pull_request:
7+
branches: [main]
8+
9+
env:
10+
CARGO_TERM_COLOR: always
11+
12+
jobs:
13+
test:
14+
runs-on: ubuntu-latest
15+
steps:
16+
- uses: actions/checkout@v4
17+
- uses: dtolnay/rust-toolchain@stable
18+
with:
19+
components: rustfmt, clippy
20+
- uses: Swatinem/rust-cache@v2
21+
- name: fmt
22+
run: cargo fmt --all -- --check
23+
- name: clippy
24+
run: cargo clippy --all-targets -- -D warnings
25+
- name: test
26+
run: cargo test --all

Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

config/example.toml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,13 @@
1111
# Local web UI + REST API for the crosspoint grid.
1212
bind = "0.0.0.0:8080"
1313

14+
[state]
15+
# Optional. If present, routing changes made via the web UI/API are
16+
# persisted here and reloaded on startup (overriding each output's
17+
# default_source below). Remove this section to keep routing in-memory
18+
# only, reset to default_source on every restart.
19+
path = "state/routes.json"
20+
1421
[[inputs]]
1522
id = "cam1"
1623
mode = "listener"

crates/router/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ srt-io = { path = "../srt-io" }
1414
crosspoint-web = { path = "../web" }
1515
tokio = { version = "1", features = ["full"] }
1616
serde = { version = "1", features = ["derive"] }
17+
serde_json = "1"
1718
toml = "0.8"
1819
tracing = "0.1"
1920
tracing-subscriber = { version = "0.3", features = ["env-filter"] }

crates/router/src/config.rs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
use std::path::PathBuf;
2+
13
use serde::Deserialize;
24
use srt_io::Endpoint;
35

@@ -8,6 +10,16 @@ pub struct Config {
810
pub inputs: Vec<InputConfig>,
911
#[serde(default)]
1012
pub outputs: Vec<OutputConfig>,
13+
/// If present, routing changes are persisted to disk and reloaded on
14+
/// startup (overriding each output's `default_source`). Omit to keep
15+
/// routing in-memory only, reset to `default_source` on every restart.
16+
#[serde(default)]
17+
pub state: Option<StateConfig>,
18+
}
19+
20+
#[derive(Deserialize)]
21+
pub struct StateConfig {
22+
pub path: PathBuf,
1123
}
1224

1325
#[derive(Deserialize)]

crates/router/src/main.rs

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
mod config;
2+
mod state;
23

4+
use std::collections::HashMap;
35
use std::net::SocketAddr;
46

57
use anyhow::{Context, Result};
@@ -34,16 +36,40 @@ async fn main() -> Result<()> {
3436
tracing::info!(id = %input.id, "starting SRT input");
3537
srt_io::spawn_input(input.id, input.endpoint, crosspoint.clone());
3638
}
39+
40+
let persisted_routes: HashMap<String, String> = match &config.state {
41+
Some(state_cfg) => {
42+
let routes = state::load_routes(&state_cfg.path);
43+
if !routes.is_empty() {
44+
tracing::info!(
45+
path = %state_cfg.path.display(),
46+
count = routes.len(),
47+
"loaded persisted routing state"
48+
);
49+
}
50+
routes
51+
}
52+
None => HashMap::new(),
53+
};
54+
3755
for output in config.outputs {
38-
tracing::info!(id = %output.id, "starting SRT output");
56+
let initial_source = persisted_routes
57+
.get(&output.id)
58+
.cloned()
59+
.unwrap_or(output.default_source);
60+
tracing::info!(id = %output.id, source = %initial_source, "starting SRT output");
3961
srt_io::spawn_output(
4062
output.id,
4163
output.endpoint,
42-
output.default_source,
64+
initial_source,
4365
crosspoint.clone(),
4466
);
4567
}
4668

69+
if let Some(state_cfg) = config.state {
70+
state::spawn_persistence(state_cfg.path, crosspoint.clone());
71+
}
72+
4773
let bind: SocketAddr = config
4874
.web
4975
.bind

crates/router/src/state.rs

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
//! Optional persistence of the crosspoint's routing table across restarts.
2+
//! Deliberately polling-based rather than a change-hook on
3+
//! `crosspoint-core`: it keeps the core engine free of any
4+
//! persistence-specific API, the same tradeoff the web UI already makes
5+
//! with its own 1s state poll.
6+
7+
use std::collections::HashMap;
8+
use std::path::{Path, PathBuf};
9+
use std::sync::Arc;
10+
use std::time::Duration;
11+
12+
use crosspoint_core::Crosspoint;
13+
use tokio::time::sleep;
14+
15+
const PERSIST_POLL_INTERVAL: Duration = Duration::from_millis(500);
16+
17+
/// Load a previously persisted output -> source routing table. Any problem
18+
/// reading or parsing the file (missing, corrupt, unreadable) is logged and
19+
/// treated as "nothing persisted yet" rather than failing startup — a
20+
/// router should still come up on its config defaults if its state file is
21+
/// bad, not refuse to start.
22+
pub fn load_routes(path: &Path) -> HashMap<String, String> {
23+
match std::fs::read_to_string(path) {
24+
Ok(raw) => serde_json::from_str(&raw).unwrap_or_else(|err| {
25+
tracing::warn!(path = %path.display(), %err, "state file exists but failed to parse, ignoring");
26+
HashMap::new()
27+
}),
28+
Err(err) if err.kind() == std::io::ErrorKind::NotFound => HashMap::new(),
29+
Err(err) => {
30+
tracing::warn!(path = %path.display(), %err, "failed to read state file, ignoring");
31+
HashMap::new()
32+
}
33+
}
34+
}
35+
36+
fn save_routes(path: &Path, routes: &HashMap<String, String>) {
37+
let body = match serde_json::to_string_pretty(routes) {
38+
Ok(body) => body,
39+
Err(err) => {
40+
tracing::warn!(%err, "failed to serialize routing state");
41+
return;
42+
}
43+
};
44+
if let Some(parent) = path.parent() {
45+
if !parent.as_os_str().is_empty() {
46+
if let Err(err) = std::fs::create_dir_all(parent) {
47+
tracing::warn!(parent = %parent.display(), %err, "failed to create state directory");
48+
return;
49+
}
50+
}
51+
}
52+
// Write-then-rename so a crash mid-write can never leave a truncated
53+
// state file behind for the next startup to trip over.
54+
let tmp_path = path.with_extension("json.tmp");
55+
if let Err(err) = std::fs::write(&tmp_path, body) {
56+
tracing::warn!(path = %tmp_path.display(), %err, "failed to write state file");
57+
return;
58+
}
59+
if let Err(err) = std::fs::rename(&tmp_path, path) {
60+
tracing::warn!(path = %path.display(), %err, "failed to move state file into place");
61+
}
62+
}
63+
64+
/// Spawn a task that watches the crosspoint's routing table and persists it
65+
/// to `path` whenever it changes. Runs until the process exits.
66+
pub fn spawn_persistence(path: PathBuf, crosspoint: Arc<Crosspoint>) {
67+
tokio::spawn(async move {
68+
let mut last = crosspoint.routes();
69+
loop {
70+
sleep(PERSIST_POLL_INTERVAL).await;
71+
let current = crosspoint.routes();
72+
if current != last {
73+
tracing::debug!(path = %path.display(), "routing changed, persisting");
74+
save_routes(&path, &current);
75+
last = current;
76+
}
77+
}
78+
});
79+
}

crates/srt-io/tests/relay.rs

Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
//! Integration tests that exercise the full input -> crosspoint -> output
2+
//! relay path over *real* SRT connections (real handshake, real protocol
3+
//! traffic via srt-tokio acting as the test's "encoder"/"decoder" clients)
4+
//! rather than just asserting a UDP socket is bound. This is the strongest
5+
//! local verification available without real third-party SRT hardware.
6+
7+
use std::time::{Duration, Instant};
8+
9+
use bytes::Bytes;
10+
use crosspoint_core::Crosspoint;
11+
use futures::SinkExt;
12+
use srt_io::{spawn_input, spawn_output, Endpoint};
13+
use srt_tokio::SrtSocket;
14+
use tokio::time::{sleep, timeout};
15+
use tokio_stream::StreamExt;
16+
17+
const RECV_TIMEOUT: Duration = Duration::from_secs(5);
18+
/// Buffer for connection setup / task scheduling on localhost before we
19+
/// start relying on ordering between "peer connected" and "relay task
20+
/// subscribed to its source" — see the module doc on why this matters.
21+
const SETTLE: Duration = Duration::from_millis(400);
22+
23+
async fn call(addr: &str) -> SrtSocket {
24+
SrtSocket::builder()
25+
.call(addr, None)
26+
.await
27+
.unwrap_or_else(|e| panic!("failed to connect to {addr}: {e}"))
28+
}
29+
30+
#[tokio::test]
31+
async fn relays_bytes_end_to_end_over_real_srt() {
32+
let crosspoint = Crosspoint::new();
33+
34+
spawn_input(
35+
"test-source".into(),
36+
Endpoint::Listener {
37+
bind: "127.0.0.1:18601".into(),
38+
},
39+
crosspoint.clone(),
40+
);
41+
spawn_output(
42+
"test-output".into(),
43+
Endpoint::Listener {
44+
bind: "127.0.0.1:18602".into(),
45+
},
46+
"test-source".into(),
47+
crosspoint.clone(),
48+
);
49+
sleep(SETTLE).await;
50+
51+
// Real SRT clients standing in for an encoder and a decoder, exactly as
52+
// a real one would connect to this router.
53+
let mut encoder = call("127.0.0.1:18601").await;
54+
let mut decoder = call("127.0.0.1:18602").await;
55+
sleep(SETTLE).await;
56+
57+
let payload = Bytes::from_static(b"hello from a real srt sender");
58+
encoder
59+
.send((Instant::now(), payload.clone()))
60+
.await
61+
.expect("encoder send");
62+
63+
let (_t, received) = timeout(RECV_TIMEOUT, decoder.try_next())
64+
.await
65+
.expect("timed out waiting for the relayed payload")
66+
.expect("decoder read error")
67+
.expect("decoder stream ended before receiving anything");
68+
69+
assert_eq!(received, payload);
70+
}
71+
72+
#[tokio::test]
73+
async fn output_switches_source_live_over_real_srt() {
74+
let crosspoint = Crosspoint::new();
75+
76+
spawn_input(
77+
"source-a".into(),
78+
Endpoint::Listener {
79+
bind: "127.0.0.1:18611".into(),
80+
},
81+
crosspoint.clone(),
82+
);
83+
spawn_input(
84+
"source-b".into(),
85+
Endpoint::Listener {
86+
bind: "127.0.0.1:18612".into(),
87+
},
88+
crosspoint.clone(),
89+
);
90+
spawn_output(
91+
"test-output".into(),
92+
Endpoint::Listener {
93+
bind: "127.0.0.1:18613".into(),
94+
},
95+
"source-a".into(),
96+
crosspoint.clone(),
97+
);
98+
sleep(SETTLE).await;
99+
100+
let mut encoder_a = call("127.0.0.1:18611").await;
101+
let mut encoder_b = call("127.0.0.1:18612").await;
102+
let mut decoder = call("127.0.0.1:18613").await;
103+
sleep(SETTLE).await;
104+
105+
// Routed to source-a by default: a payload from A arrives, one from B
106+
// (sent but never selected) does not.
107+
encoder_a
108+
.send((Instant::now(), Bytes::from_static(b"from-a-1")))
109+
.await
110+
.unwrap();
111+
encoder_b
112+
.send((Instant::now(), Bytes::from_static(b"from-b-ignored")))
113+
.await
114+
.unwrap();
115+
116+
let (_t, first) = timeout(RECV_TIMEOUT, decoder.try_next())
117+
.await
118+
.expect("timed out waiting for first payload")
119+
.expect("decoder read error")
120+
.expect("decoder stream ended early");
121+
assert_eq!(first, Bytes::from_static(b"from-a-1"));
122+
123+
// Live re-route to source-b, same output connection, no reconnect.
124+
assert!(crosspoint.route("test-output", "source-b"));
125+
sleep(SETTLE).await;
126+
127+
encoder_b
128+
.send((Instant::now(), Bytes::from_static(b"from-b-1")))
129+
.await
130+
.unwrap();
131+
132+
let (_t, second) = timeout(RECV_TIMEOUT, decoder.try_next())
133+
.await
134+
.expect("timed out waiting for second payload")
135+
.expect("decoder read error")
136+
.expect("decoder stream ended early");
137+
assert_eq!(second, Bytes::from_static(b"from-b-1"));
138+
}

0 commit comments

Comments
 (0)