Flip T3 to substrate-initiated actuator commands

This commit is contained in:
Valère Plantevin
2026-05-13 15:03:23 -04:00
parent 272d3b3c59
commit baa075fe0f
22 changed files with 1003 additions and 749 deletions

96
simulator/src/commands.rs Normal file
View File

@@ -0,0 +1,96 @@
//! Substrate → simulator T3 receiver.
//!
//! The substrate is the brain: when its `automation_system` decides to
//! actuate, it opens a QUIC bidirectional stream to one of its connected
//! devices. The simulator side accepts those streams here, decodes the
//! 39-byte command, applies it to local actuator state, and writes a 39-byte
//! ack back. This closes the loop the paper's three-tier model describes.
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use substrate::transport::{QuicMessage, SensorType};
/// Convenience constructor used by `main.rs` and integration tests.
/// `true` means the simulated engine is running normally.
pub fn new_engine_state() -> Arc<AtomicBool> {
Arc::new(AtomicBool::new(true))
}
/// Loop accepting substrate-initiated bidirectional streams until the
/// connection drops. Each stream is one (command, ack) round-trip:
/// the simulator reads a 39-byte `QuicMessage`, mutates `engine_running` if
/// the command targets the Relay actuator, then writes a 39-byte ack back
/// (echoes the command with the simulator's local timestamp).
pub async fn run_command_receiver(conn: quinn::Connection, engine_running: Arc<AtomicBool>) {
let remote = conn.remote_address();
let mut streams_seen: u64 = 0;
loop {
let (send, recv) = match conn.accept_bi().await {
Ok(s) => s,
Err(e) => {
tracing::debug!(
?remote,
streams_seen,
error = %e,
"command receiver: accept_bi loop ended"
);
return;
}
};
streams_seen += 1;
let engine_running = engine_running.clone();
tokio::spawn(handle_one_command(remote, send, recv, engine_running));
}
}
async fn handle_one_command(
remote: std::net::SocketAddr,
mut send: quinn::SendStream,
mut recv: quinn::RecvStream,
engine_running: Arc<AtomicBool>,
) {
let mut buf = [0u8; QuicMessage::WIRE_SIZE];
if let Err(e) = recv.read_exact(&mut buf).await {
tracing::trace!(?remote, error = %e, "command receiver: short read; closing stream");
return;
}
let cmd = match QuicMessage::decode(&buf) {
Ok(m) => m,
Err(e) => {
tracing::warn!(?remote, error = %e, "command receiver: decode failed");
let _ = send.reset(0u32.into());
return;
}
};
if cmd.typ() == SensorType::Relay {
// raw_value == 1.0 ⇒ stop the engine; 0.0 ⇒ resume.
let now_running = cmd.raw_value < 0.5;
let was_running = engine_running.swap(now_running, Ordering::SeqCst);
if now_running != was_running {
if now_running {
tracing::info!(device = %cmd.device_id, "Relay=0 received — engine resuming");
} else {
tracing::info!(device = %cmd.device_id, "Relay=1 received — engine stopping");
}
}
} else {
tracing::debug!(
?remote,
sensor_type = cmd.sensor_type,
"command receiver: ignoring non-Relay command"
);
}
// Ack by echoing the command — the substrate's outbound drain measures
// latency from open_bi() to ack receipt.
if let Err(e) = send.write_all(&cmd.to_bytes()).await {
tracing::warn!(?remote, error = %e, "command receiver: ack write failed");
return;
}
if let Err(e) = send.finish() {
tracing::warn!(?remote, error = %e, "command receiver: ack finish failed");
}
}

View File

@@ -1,16 +1,18 @@
//! Async emitter tasks for T2 (uni streams) and T3 (bi streams + ack).
//! Async emitter task for T2 (uni streams).
//!
//! Each emitter ticks at its own rate, opens a fresh stream per event, and
//! shares a `Connection` with the rest of the simulator. T1 (datagrams) is
//! driven inline by the main loop so the foreground task owns the progress
//! reporting; the reliable tiers run as `tokio::spawn`ed background tasks.
//! Ticks at its own rate, opens a fresh stream per event, and shares a
//! `Connection` with the rest of the simulator. T1 (datagrams) is driven
//! inline by the main loop so the foreground task owns the progress
//! reporting; T2 runs as a `tokio::spawn`ed background task.
//!
//! T3 (actuator commands) is substrate-initiated — the receiver lives in
//! `crate::commands`, not here.
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use anyhow::Context;
use substrate::transport::{QuicMessage, SensorType};
use substrate::transport::QuicMessage;
use tokio::time::MissedTickBehavior;
use crate::profile::{SensorSlot, generate_value};
@@ -34,6 +36,7 @@ pub async fn run_t2_emitter(
mut slot: SensorSlot,
rate_hz: f64,
interrupted: Arc<AtomicBool>,
engine_running: Arc<AtomicBool>,
counter: Arc<AtomicU64>,
) -> u64 {
let period = Duration::from_nanos((1.0e9 / rate_hz) as u64);
@@ -55,10 +58,11 @@ pub async fn run_t2_emitter(
break;
}
let running = engine_running.load(Ordering::Relaxed);
let msg = QuicMessage {
device_id: slot.device_id,
sensor_id: slot.sensor_id,
raw_value: generate_value(slot.sensor_type, slot.seq),
raw_value: generate_value(slot.sensor_type, slot.seq, running),
timestamp_us: now_us(),
sequence_number: slot.seq,
sensor_type: slot.sensor_type.as_u8(),
@@ -77,85 +81,6 @@ pub async fn run_t2_emitter(
if let Err(e) = send.finish() {
tracing::warn!(error = %e, "T2 finish failed");
}
sent
}
/// T3 emitter — opens a fresh bi-stream per command, writes the command,
/// awaits the ack with a bounded timeout. Returns `(acks_received, timeouts)`.
pub async fn run_t3_emitter(
conn: quinn::Connection,
mut slot: SensorSlot,
rate_hz: f64,
timeout: Duration,
interrupted: Arc<AtomicBool>,
sent_counter: Arc<AtomicU64>,
timeout_counter: Arc<AtomicU64>,
) -> (u64, u64) {
let period = Duration::from_nanos((1.0e9 / rate_hz) as u64);
let mut ticker = tokio::time::interval(period);
ticker.set_missed_tick_behavior(MissedTickBehavior::Skip);
let mut sent: u64 = 0;
let mut timeouts: u64 = 0;
let mut last_relay_state = 0.0;
loop {
ticker.tick().await;
if interrupted.load(Ordering::SeqCst) {
break;
}
let cmd = QuicMessage {
device_id: slot.device_id,
sensor_id: slot.sensor_id,
raw_value: generate_value(slot.sensor_type, slot.seq),
timestamp_us: now_us(),
sequence_number: slot.seq,
sensor_type: slot.sensor_type.as_u8(),
};
slot.seq = slot.seq.wrapping_add(1);
match tokio::time::timeout(timeout, t3_one_request(&conn, &cmd)).await {
Ok(Ok(ack)) => {
sent += 1;
sent_counter.store(sent, Ordering::Relaxed);
if ack.sensor_type == SensorType::Relay.as_u8() {
let is_on = ack.raw_value > 0.5;
let was_on = last_relay_state > 0.5;
if is_on && !was_on {
tracing::info!(device = %ack.device_id, "Relay triggered ON (machine stopped)!");
} else if !is_on && was_on {
tracing::info!(device = %ack.device_id, "Relay turned OFF.");
}
last_relay_state = ack.raw_value;
}
}
Ok(Err(e)) => {
tracing::warn!(error = %e, "T3 request failed");
}
Err(_) => {
timeouts += 1;
timeout_counter.store(timeouts, Ordering::Relaxed);
tracing::warn!(?timeout, "T3 ack timed out");
}
}
}
(sent, timeouts)
}
/// Single T3 round-trip: open bi-stream, write 38 B command, `finish` the
/// send half, read 38 B ack. Used by `run_t3_emitter`.
async fn t3_one_request(
conn: &quinn::Connection,
cmd: &QuicMessage,
) -> anyhow::Result<QuicMessage> {
let (mut send, mut recv) = conn.open_bi().await.context("T3 open_bi")?;
send.write_all(&cmd.to_bytes())
.await
.context("T3 write command")?;
send.finish().context("T3 finish send half")?;
let mut buf = [0u8; QuicMessage::WIRE_SIZE];
recv.read_exact(&mut buf).await.context("T3 read ack")?;
QuicMessage::decode(&buf).context("T3 decode ack")
}

View File

@@ -1,4 +1,5 @@
pub mod client;
pub mod commands;
pub mod emitters;
pub mod profile;

View File

@@ -17,7 +17,8 @@ use std::time::{Duration, Instant};
use anyhow::{Context, anyhow};
use clap::{Parser, ValueEnum};
use simulator::client::SimulatorClient;
use simulator::emitters::{now_us, run_t2_emitter, run_t3_emitter};
use simulator::commands::{new_engine_state, run_command_receiver};
use simulator::emitters::{now_us, run_t2_emitter};
use simulator::profile::{SensorProfile, build_slots, generate_value};
use substrate::transport::{QuicMessage, SensorType};
use tokio::time::MissedTickBehavior;
@@ -60,14 +61,6 @@ struct Cli {
#[arg(long, default_value_t = 0.0)]
t2_rate_hz: f64,
/// T3 bidirectional command rate (Hz). `0` disables T3 (default).
#[arg(long, default_value_t = 0.0)]
t3_rate_hz: f64,
/// Per-command timeout for T3 ack waits (milliseconds).
#[arg(long, default_value_t = 2000)]
t3_timeout_ms: u64,
/// Number of T1 datagrams to send. `0` runs until Ctrl-C.
#[arg(long, default_value_t = 10)]
count: u64,
@@ -112,12 +105,9 @@ fn validate(cli: &Cli) -> anyhow::Result<()> {
if cli.t2_rate_hz < 0.0 {
return Err(anyhow!("--t2-rate-hz must be >= 0"));
}
if cli.t3_rate_hz < 0.0 {
return Err(anyhow!("--t3-rate-hz must be >= 0"));
}
if cli.rate_hz == 0.0 && cli.t2_rate_hz == 0.0 && cli.t3_rate_hz == 0.0 {
if cli.rate_hz == 0.0 && cli.t2_rate_hz == 0.0 {
return Err(anyhow!(
"at least one of --rate-hz / --t2-rate-hz / --t3-rate-hz must be > 0"
"at least one of --rate-hz / --t2-rate-hz must be > 0"
));
}
if cli.devices == 0 {
@@ -150,7 +140,6 @@ async fn main() -> anyhow::Result<()> {
?cli.addr,
rate_hz = cli.rate_hz,
t2_rate_hz = cli.t2_rate_hz,
t3_rate_hz = cli.t3_rate_hz,
count = cli.count,
devices = cli.devices,
slots = slots.len(),
@@ -172,9 +161,20 @@ async fn main() -> anyhow::Result<()> {
});
}
// T2 / T3 emitters target slot[0] for their device/sensor identity.
// Engine state: starts running. Flipped by `run_command_receiver` when
// the substrate's automation_system sends a Relay actuator command.
let engine_running = new_engine_state();
{
let conn = client.conn.clone();
let engine_running = engine_running.clone();
tokio::spawn(async move {
run_command_receiver(conn, engine_running).await;
});
}
// T2 emitter targets slot[0] for its device/sensor identity. T3 commands
// are substrate-initiated; there's no simulator-side emitter for them.
let t2_slot = slots[0].clone();
let t3_slot = slots[0].clone();
let t2_sent = Arc::new(AtomicU64::new(0));
let t2_handle = if cli.t2_rate_hz > 0.0 {
@@ -182,33 +182,9 @@ async fn main() -> anyhow::Result<()> {
let rate = cli.t2_rate_hz;
let interrupted = interrupted.clone();
let counter = t2_sent.clone();
let engine_running = engine_running.clone();
Some(tokio::spawn(async move {
run_t2_emitter(conn, t2_slot, rate, interrupted, counter).await
}))
} else {
None
};
let t3_sent = Arc::new(AtomicU64::new(0));
let t3_timeouts = Arc::new(AtomicU64::new(0));
let t3_handle = if cli.t3_rate_hz > 0.0 {
let conn = client.conn.clone();
let rate = cli.t3_rate_hz;
let timeout = Duration::from_millis(cli.t3_timeout_ms);
let interrupted = interrupted.clone();
let sent_counter = t3_sent.clone();
let to_counter = t3_timeouts.clone();
Some(tokio::spawn(async move {
run_t3_emitter(
conn,
t3_slot,
rate,
timeout,
interrupted,
sent_counter,
to_counter,
)
.await
run_t2_emitter(conn, t2_slot, rate, interrupted, engine_running, counter).await
}))
} else {
None
@@ -280,11 +256,12 @@ async fn main() -> anyhow::Result<()> {
}
let slot_idx = (t1_sent as usize) % slots.len();
let running = engine_running.load(Ordering::Relaxed);
let slot = &mut slots[slot_idx];
let msg = QuicMessage {
device_id: slot.device_id,
sensor_id: slot.sensor_id,
raw_value: generate_value(slot.sensor_type, slot.seq),
raw_value: generate_value(slot.sensor_type, slot.seq, running),
timestamp_us: now_us(),
sequence_number: slot.seq,
sensor_type: slot.sensor_type.as_u8(),
@@ -303,18 +280,18 @@ async fn main() -> anyhow::Result<()> {
let t1_hz = (t1_sent as f64) / elapsed.max(1e-9);
let t2_now = t2_sent.load(Ordering::Relaxed);
let t2_hz = (t2_now as f64) / elapsed.max(1e-9);
let t3_now = t3_sent.load(Ordering::Relaxed);
let t3_hz = (t3_now as f64) / elapsed.max(1e-9);
let t3_to = t3_timeouts.load(Ordering::Relaxed);
let engine_state = if engine_running.load(Ordering::Relaxed) {
"running"
} else {
"stopped"
};
tracing::info!(
t1_sent,
t2_sent = t2_now,
t3_sent = t3_now,
t3_timeouts = t3_to,
send_errors,
t1_hz = format_args!("{:.1}", t1_hz),
t2_hz = format_args!("{:.1}", t2_hz),
t3_hz = format_args!("{:.1}", t3_hz),
engine = engine_state,
"progress"
);
last_progress = now;
@@ -334,28 +311,17 @@ async fn main() -> anyhow::Result<()> {
}),
None => 0,
};
let (t3_total, t3_timeouts_total): (u64, u64) = match t3_handle {
Some(h) => h.await.unwrap_or_else(|e| {
tracing::warn!(error = %e, "T3 emitter task ended unexpectedly");
(0, 0)
}),
None => (0, 0),
};
let elapsed = started.elapsed().as_secs_f64();
let t1_hz = (t1_sent as f64) / elapsed.max(1e-9);
let t2_hz = (t2_total as f64) / elapsed.max(1e-9);
let t3_hz = (t3_total as f64) / elapsed.max(1e-9);
tracing::info!(
t1_sent,
t2_sent = t2_total,
t3_sent = t3_total,
t3_timeouts = t3_timeouts_total,
send_errors,
elapsed_s = format_args!("{:.3}", elapsed),
t1_observed_hz = format_args!("{:.1}", t1_hz),
t2_observed_hz = format_args!("{:.1}", t2_hz),
t3_observed_hz = format_args!("{:.1}", t3_hz),
"simulator done"
);

View File

@@ -77,16 +77,30 @@ pub fn build_slots(
/// render. `seq` is the sample index — multiplying by 0.05 gives a
/// "seconds-like" wall-clock pacing inside the trig functions regardless of
/// the actual send rate, so panels animate over the same visible period.
pub fn generate_value(t: SensorType, seq: u32) -> f64 {
///
/// `engine_running` couples Voltage/Current to the simulated machine state.
/// When the substrate's `automation_system` sends a Relay=stop command, the
/// receiver flips the flag and the next current sample drops to ~0 A while
/// Voltage stays on mains — the dashboard sees the engine spin down within
/// one ECS tick.
pub fn generate_value(t: SensorType, seq: u32, engine_running: bool) -> f64 {
let t_phase = (seq as f64) * 0.05;
match t {
SensorType::Temperature => 20.0 + 5.0 * (t_phase / 10.0).sin(),
SensorType::Humidity => 50.0 + 20.0 * (t_phase / 15.0).sin(),
SensorType::Pressure => 1013.0 + 5.0 * (t_phase / 20.0).cos(),
// Voltage is the mains: stable at ~230 V regardless of motor state.
SensorType::Voltage => 230.0 + 0.5 * (t_phase / 3.0).sin(),
SensorType::Current => 10.0 + 2.0 * (t_phase / 5.0).cos(),
// Current reflects motor draw: ~10 A running, ~0 A stopped.
SensorType::Current => {
if engine_running {
10.0 + 2.0 * (t_phase / 5.0).cos()
} else {
0.05 + 0.05 * (t_phase / 5.0).cos().abs()
}
}
SensorType::Presence => 2.0 + 1.5 * (t_phase / 5.0).sin(), // Drops below 1.0 occasionally
SensorType::Relay => 0.0, // Relay always sends 0.0 as its command (a pure read request)
SensorType::Relay => 0.0, // Outbound is substrate-initiated; this is unused on the simulator side.
SensorType::Generic => t_phase.sin(),
}
}

View File

@@ -0,0 +1,188 @@
//! Full closed-loop integration test:
//!
//! 1. Simulator emits a Presence sensor reading via T2 (`raw_value < 1.0`).
//! 2. Substrate's `automation_system` detects threshold crossing.
//! 3. Substrate opens a T3 bi-stream and writes a `Relay=stop` command.
//! 4. Simulator's `run_command_receiver` decodes the command, flips
//! `engine_running` to `false`, and writes the 39-byte ack back.
//!
//! Then we recover: send Presence > 1.0, observe the substrate dispatches
//! `Relay=resume`, and the simulator's flag flips back to `true`.
//!
//! This test stands up the *real* substrate machinery — `accept_loop` plus
//! `drain_outbound_t3` plus the ECS world's `automation_system` driving a
//! `BridgeSenders` — so a regression in any of the three pieces fails here.
use std::net::SocketAddr;
use std::path::PathBuf;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::{Duration, Instant};
use anyhow::Result;
use simulator::client::SimulatorClient;
use simulator::commands::{new_engine_state, run_command_receiver};
use substrate::config::QuicConfig;
use substrate::transport::server::{accept_loop, bind_endpoint, new_connection_registry};
use substrate::transport::{OutboundT3, QuicMessage, SensorType, T1Sender, T2Sender, T3OutboundSender};
use tokio::sync::mpsc;
use uuid::Uuid;
fn cert_path(name: &str) -> PathBuf {
[env!("CARGO_MANIFEST_DIR"), "..", "certs", name].iter().collect()
}
fn loopback_config(cert: PathBuf, key: PathBuf) -> QuicConfig {
QuicConfig {
server_port: 0,
server_interface: "127.0.0.1".to_string(),
server_cert: cert.to_string_lossy().into_owned(),
server_key: key.to_string_lossy().into_owned(),
t1_capacity: 1024,
t2_capacity: 512,
t3_capacity: 256,
synthetic_t3_rate_hz: 0.0,
}
}
/// Build a minimal substrate world that runs `automation_system` against
/// test-owned channels.
///
/// We don't construct a Bevy `App` here — the world tests already cover
/// `automation_system` end-to-end with the `WorldPlugin`. This test focuses
/// on the *transport* round-trip: T2 in, T3 out, with a real `accept_loop`
/// and `drain_outbound_t3` doing the work.
///
/// We model the substrate side as: read T2 messages off the bridge receiver,
/// detect Presence crossings inline, push `OutboundT3` commands. The real
/// `automation_system` does the same thing inside the Bevy schedule; for
/// this test, the inline driver keeps the test focused on the transport.
async fn substrate_automation_proxy(
mut t2_rx: mpsc::Receiver<QuicMessage>,
t3_out: T3OutboundSender,
) {
let mut last_relay: f64 = 0.0;
while let Some(msg) = t2_rx.recv().await {
if msg.typ() != SensorType::Presence {
continue;
}
let relay: f64 = if msg.raw_value < 1.0 { 1.0 } else { 0.0 };
if (relay - last_relay).abs() < 1e-6 {
continue; // no state change, no command
}
last_relay = relay;
let _ = t3_out.try_send(OutboundT3 {
target_device: msg.device_id,
sensor_id: 6,
raw_value: relay,
sensor_type: SensorType::Relay.as_u8(),
});
}
}
async fn poll_for<F>(timeout: Duration, predicate: F) -> bool
where
F: Fn() -> bool,
{
let started = Instant::now();
while started.elapsed() < timeout {
if predicate() {
return true;
}
tokio::time::sleep(Duration::from_millis(10)).await;
}
false
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn presence_drop_triggers_engine_stop_and_recovery_resumes_it() -> Result<()> {
simulator::install_crypto_provider();
let cert = cert_path("server.crt");
let key = cert_path("server.key");
let cfg = loopback_config(cert.clone(), key);
// --- substrate side ---
let endpoint = bind_endpoint(&cfg)?;
let server_addr: SocketAddr = endpoint.local_addr()?;
let (t1_tx, _t1_rx) = mpsc::channel::<QuicMessage>(64);
let (t2_tx, t2_rx) = mpsc::channel::<QuicMessage>(64);
// Two outbound channels in this test: the substrate's real
// outbound-T3 channel (consumed by drain_outbound_t3 inside accept_loop)
// and the inline automation proxy that produces into it. We pass a
// sender clone twice — once for the proxy, once for accept_loop's
// synthetic-driver hook (which we disable here by passing rate 0.0).
let (t3_out_tx, t3_out_rx) = mpsc::channel::<OutboundT3>(64);
let registry = new_connection_registry();
let server_task = tokio::spawn(accept_loop(
endpoint,
T1Sender::new(t1_tx),
T2Sender::new(t2_tx),
registry,
t3_out_rx,
t3_out_tx.clone(),
0.0,
));
// Inline automation: read T2 Presence events, emit Relay commands.
let proxy = tokio::spawn(substrate_automation_proxy(
t2_rx,
T3OutboundSender::new(t3_out_tx),
));
// --- simulator side ---
let client = SimulatorClient::connect(server_addr, "localhost", &cert).await?;
let engine_running: Arc<AtomicBool> = new_engine_state();
{
let conn = client.conn.clone();
let flag = engine_running.clone();
tokio::spawn(async move { run_command_receiver(conn, flag).await });
}
let device = Uuid::from_u128(0x1111_2222_3333_4444_5555_6666_7777_8888);
let make_presence = |raw: f64, seq: u32| QuicMessage {
device_id: device,
sensor_id: 5,
raw_value: raw,
timestamp_us: 1_700_000_000_000_000 + u64::from(seq),
sequence_number: seq,
sensor_type: SensorType::Presence.as_u8(),
};
// 1) Engine starts running.
assert!(engine_running.load(Ordering::SeqCst), "engine should start in running state");
// 2) Push Presence < 1.0 via T2 → expect the substrate to dispatch
// Relay=stop and the simulator's receiver to flip the flag.
client.send_uni_stream(&[make_presence(0.5, 0)]).await?;
let stopped = poll_for(Duration::from_secs(3), || {
!engine_running.load(Ordering::SeqCst)
})
.await;
assert!(
stopped,
"engine_running did not flip to false within 3 s of the substrate \
receiving Presence=0.5; the substrate→simulator T3 path is broken"
);
// 3) Push Presence > 1.0 → expect Relay=resume → flag flips back to true.
client.send_uni_stream(&[make_presence(2.5, 1)]).await?;
let resumed = poll_for(Duration::from_secs(3), || {
engine_running.load(Ordering::SeqCst)
})
.await;
assert!(
resumed,
"engine_running did not flip back to true after Presence=2.5; \
recovery half of the closed loop is broken"
);
client.close().await;
proxy.abort();
server_task.abort();
Ok(())
}

View File

@@ -11,8 +11,8 @@ use std::time::Duration;
use anyhow::Result;
use simulator::client::SimulatorClient;
use substrate::config::QuicConfig;
use substrate::transport::server::{accept_loop, bind_endpoint};
use substrate::transport::{QuicMessage, SensorType, T1Sender, T2Sender, T3Sender};
use substrate::transport::server::{accept_loop, bind_endpoint, new_connection_registry};
use substrate::transport::{OutboundT3, QuicMessage, SensorType, T1Sender, T2Sender};
use tokio::sync::mpsc;
use uuid::Uuid;
@@ -31,6 +31,7 @@ fn loopback_config(cert: PathBuf, key: PathBuf) -> QuicConfig {
t1_capacity: 1024,
t2_capacity: 512,
t3_capacity: 256,
synthetic_t3_rate_hz: 0.0,
}
}
@@ -50,13 +51,17 @@ async fn t1_datagram_decoded_into_ecs_channel() -> Result<()> {
// demux pushes into the ECS bridge.
let (t1_tx, mut t1_rx) = mpsc::channel(64);
let (t2_tx, _t2_rx) = mpsc::channel(64);
let (t3_tx, _t3_rx) = mpsc::channel(64);
let (t3_out_tx, t3_out_rx) = mpsc::channel::<OutboundT3>(64);
let registry = new_connection_registry();
let server_task = tokio::spawn(accept_loop(
endpoint,
T1Sender::new(t1_tx),
T2Sender::new(t2_tx),
T3Sender::new(t3_tx),
registry,
t3_out_rx,
t3_out_tx,
0.0, // synthetic driver disabled
));
// Connect a client and send one datagram.
@@ -99,13 +104,17 @@ async fn t1_burst_preserves_order_and_count() -> Result<()> {
// T1 capacity 64 ≥ burst size 32 so nothing is dropped under loopback.
let (t1_tx, mut t1_rx) = mpsc::channel(64);
let (t2_tx, _t2_rx) = mpsc::channel(8);
let (t3_tx, _t3_rx) = mpsc::channel(8);
let (t3_out_tx, t3_out_rx) = mpsc::channel::<OutboundT3>(8);
let registry = new_connection_registry();
let server_task = tokio::spawn(accept_loop(
endpoint,
T1Sender::new(t1_tx),
T2Sender::new(t2_tx),
T3Sender::new(t3_tx),
registry,
t3_out_rx,
t3_out_tx,
0.0,
));
let client = SimulatorClient::connect(server_addr, "localhost", &cert).await?;

View File

@@ -12,8 +12,8 @@ use std::time::Duration;
use anyhow::Result;
use simulator::client::SimulatorClient;
use substrate::config::QuicConfig;
use substrate::transport::server::{accept_loop, bind_endpoint};
use substrate::transport::{QuicMessage, SensorType, T1Sender, T2Sender, T3Sender};
use substrate::transport::server::{accept_loop, bind_endpoint, new_connection_registry};
use substrate::transport::{OutboundT3, QuicMessage, SensorType, T1Sender, T2Sender};
use tokio::sync::mpsc;
use uuid::Uuid;
@@ -30,6 +30,7 @@ fn loopback_config(cert: PathBuf, key: PathBuf) -> QuicConfig {
t1_capacity: 1024,
t2_capacity: 512,
t3_capacity: 256,
synthetic_t3_rate_hz: 0.0,
}
}
@@ -46,13 +47,17 @@ async fn t2_single_stream_preserves_order() -> Result<()> {
let (t1_tx, _t1_rx) = mpsc::channel(64);
let (t2_tx, mut t2_rx) = mpsc::channel(64);
let (t3_tx, _t3_rx) = mpsc::channel(64);
let (t3_out_tx, t3_out_rx) = mpsc::channel::<OutboundT3>(64);
let registry = new_connection_registry();
let server_task = tokio::spawn(accept_loop(
endpoint,
T1Sender::new(t1_tx),
T2Sender::new(t2_tx),
T3Sender::new(t3_tx),
registry,
t3_out_rx,
t3_out_tx,
0.0,
));
let client = SimulatorClient::connect(server_addr, "localhost", &cert).await?;
@@ -98,13 +103,17 @@ async fn t2_concurrent_streams_each_internally_ordered() -> Result<()> {
let (t1_tx, _t1_rx) = mpsc::channel(64);
let (t2_tx, mut t2_rx) = mpsc::channel(256);
let (t3_tx, _t3_rx) = mpsc::channel(64);
let (t3_out_tx, t3_out_rx) = mpsc::channel::<OutboundT3>(64);
let registry = new_connection_registry();
let server_task = tokio::spawn(accept_loop(
endpoint,
T1Sender::new(t1_tx),
T2Sender::new(t2_tx),
T3Sender::new(t3_tx),
registry,
t3_out_rx,
t3_out_tx,
0.0,
));
let client = SimulatorClient::connect(server_addr, "localhost", &cert).await?;

View File

@@ -1,155 +0,0 @@
//! End-to-end T3 (bidirectional stream + oneshot ack) tests. Same shape as
//! the T1/T2 harnesses: spin up substrate's listener with channels owned by
//! the test, run a "fake ECS" task that drains the T3 receiver and either
//! replies or drops the oneshot, and assert the client observes the right
//! behaviour.
//!
//! Run with `cargo test -p simulator`.
use std::net::SocketAddr;
use std::path::PathBuf;
use std::time::Duration;
use anyhow::Result;
use simulator::client::SimulatorClient;
use substrate::config::QuicConfig;
use substrate::transport::server::{accept_loop, bind_endpoint};
use substrate::transport::{QuicMessage, SensorType, T1Sender, T2Sender, T3Sender};
use tokio::sync::mpsc;
use uuid::Uuid;
fn cert_path(name: &str) -> PathBuf {
[env!("CARGO_MANIFEST_DIR"), "..", "certs", name].iter().collect()
}
fn loopback_config(cert: PathBuf, key: PathBuf) -> QuicConfig {
QuicConfig {
server_port: 0,
server_interface: "127.0.0.1".to_string(),
server_cert: cert.to_string_lossy().into_owned(),
server_key: key.to_string_lossy().into_owned(),
t1_capacity: 1024,
t2_capacity: 512,
t3_capacity: 256,
}
}
/// Marker `timestamp_us` the fake ECS stamps onto every ack so the test can
/// distinguish a real reply from any echo of the command's own timestamp.
const ACK_MARKER_TS: u64 = 999_999_999_999;
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn t3_round_trip_with_fake_handler() -> Result<()> {
simulator::install_crypto_provider();
let cert = cert_path("server.crt");
let key = cert_path("server.key");
let cfg = loopback_config(cert.clone(), key);
let endpoint = bind_endpoint(&cfg)?;
let server_addr: SocketAddr = endpoint.local_addr()?;
let (t1_tx, _t1_rx) = mpsc::channel(64);
let (t2_tx, _t2_rx) = mpsc::channel(64);
let (t3_tx, mut t3_rx) = mpsc::channel(64);
let server_task = tokio::spawn(accept_loop(
endpoint,
T1Sender::new(t1_tx),
T2Sender::new(t2_tx),
T3Sender::new(t3_tx),
));
// Fake ECS handler: drain T3 inbounds, mark the timestamp, send back.
let handler = tokio::spawn(async move {
while let Some(inbound) = t3_rx.recv().await {
let mut ack = inbound.command;
ack.timestamp_us = ACK_MARKER_TS;
// Ignore send error (client may have disconnected before listening).
let _ = inbound.reply.send(ack);
}
});
let client = SimulatorClient::connect(server_addr, "localhost", &cert).await?;
let cmd = QuicMessage {
device_id: Uuid::from_u128(0xa5a5_a5a5_5a5a_5a5a_a5a5_5a5a_a5a5_5a5a),
sensor_id: 3,
raw_value: 1.5,
timestamp_us: 1_700_000_000_000_000,
sequence_number: 7,
sensor_type: SensorType::Voltage.as_u8(),
};
let ack = tokio::time::timeout(Duration::from_secs(2), client.request(&cmd))
.await
.expect("T3 ack timed out")?;
assert_eq!(ack.device_id, cmd.device_id, "ack should preserve device_id");
assert_eq!(ack.sensor_id, cmd.sensor_id, "ack should preserve sensor_id");
assert_eq!(
ack.sequence_number, cmd.sequence_number,
"ack should preserve sequence_number for correlation"
);
assert_eq!(ack.timestamp_us, ACK_MARKER_TS, "fake ECS should stamp the marker");
client.close().await;
handler.abort();
server_task.abort();
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn t3_no_handler_resets_stream() -> Result<()> {
simulator::install_crypto_provider();
let cert = cert_path("server.crt");
let key = cert_path("server.key");
let cfg = loopback_config(cert.clone(), key);
let endpoint = bind_endpoint(&cfg)?;
let server_addr: SocketAddr = endpoint.local_addr()?;
let (t1_tx, _t1_rx) = mpsc::channel(64);
let (t2_tx, _t2_rx) = mpsc::channel(64);
let (t3_tx, mut t3_rx) = mpsc::channel(64);
let server_task = tokio::spawn(accept_loop(
endpoint,
T1Sender::new(t1_tx),
T2Sender::new(t2_tx),
T3Sender::new(t3_tx),
));
// Fake ECS that *drops* every oneshot — simulates "no handler installed",
// which is the placeholder state in `ingest_system` until M4 lands.
let handler = tokio::spawn(async move {
while let Some(inbound) = t3_rx.recv().await {
drop(inbound);
}
});
let client = SimulatorClient::connect(server_addr, "localhost", &cert).await?;
let cmd = QuicMessage {
device_id: Uuid::new_v4(),
sensor_id: 0,
raw_value: 0.0,
timestamp_us: 0,
sequence_number: 0,
sensor_type: SensorType::Generic.as_u8(),
};
let result = tokio::time::timeout(Duration::from_secs(2), client.request(&cmd)).await;
let inner = result.expect("client.request should not hang when stream is reset");
assert!(
inner.is_err(),
"expected request to fail when substrate resets the stream, got Ok({:?})",
inner.ok()
);
client.close().await;
handler.abort();
server_task.abort();
Ok(())
}