mod common;
use std::sync::atomic::{AtomicIsize, AtomicUsize, Ordering};
use std::sync::{Arc, Mutex, Once};
use std::time::Duration;
use lmflow::{register_kernel, Graph, Kernel, KernelCtx, Packet, Timestamp};
fn init() {
common::register_test_kernels();
static ONCE: Once = Once::new();
ONCE.call_once(|| {
register_kernel::<DelegatedSlowPass>("DelegatedSlowPass").unwrap();
register_kernel::<FiniteSource>("FiniteSource").unwrap();
});
}
static DELEGATED_ACTIVE: AtomicUsize = AtomicUsize::new(0);
static DELEGATED_MAX_ACTIVE: AtomicUsize = AtomicUsize::new(0);
#[derive(Default)]
struct DelegatedSlowPass;
impl Kernel for DelegatedSlowPass {
fn process(&mut self, cc: &mut KernelCtx) -> lmflow::Result<()> {
let active = DELEGATED_ACTIVE.fetch_add(1, Ordering::SeqCst) + 1;
DELEGATED_MAX_ACTIVE.fetch_max(active, Ordering::SeqCst);
std::thread::sleep(Duration::from_millis(5));
DELEGATED_ACTIVE.fetch_sub(1, Ordering::SeqCst);
cc.forward(0, 0)
}
}
#[derive(Default)]
struct FiniteSource {
emitted: bool,
}
impl Kernel for FiniteSource {
fn process(&mut self, context: &mut KernelCtx) -> lmflow::Result<()> {
if self.emitted {
context.source_done();
} else {
context.emit(0, Packet::from_i64(1))?;
self.emitted = true;
}
Ok(())
}
}
static ALIVE: AtomicIsize = AtomicIsize::new(0);
static ACCOUNTING: Mutex<()> = Mutex::new(());
unsafe extern "C" fn tracked_drop(p: *mut std::ffi::c_void) {
ALIVE.fetch_sub(1, Ordering::SeqCst);
drop(Box::from_raw(p as *mut i32));
}
fn tracked_packet(v: i32, ts: i64) -> Packet {
ALIVE.fetch_add(1, Ordering::SeqCst);
let ptr = Box::into_raw(Box::new(v)) as *mut std::ffi::c_void;
unsafe { Packet::from_foreign(ptr, 0, Some(tracked_drop)) }.at(Timestamp(ts))
}
#[test]
fn pool_declared_but_unused_is_still_valid() {
init();
let g = Graph::from_yaml(
r#"
executors:
- { name: "cpu", type: "ThreadPoolExecutor", num_threads: 2 }
nodes:
- { name: "p", kernel: "PassThrough", input_ports: ["in"], output_ports: ["out"] }
input_ports: ["in"]
output_ports: ["out"]
"#,
)
.unwrap();
assert_eq!(g.executor_names(), vec!["default", "cpu"]);
}
#[test]
fn rejects_empty_and_reserved_executor_names() {
init();
let err = Graph::from_yaml(
r#"
executors:
- { type: "ThreadPoolExecutor", num_threads: 2 }
nodes: []
"#,
)
.unwrap_err();
assert!(err.to_string().contains("must have a name"), "{err}");
let err = Graph::from_yaml(
r#"
executors:
- { name: "default", type: "ThreadPoolExecutor", num_threads: 2 }
nodes: []
"#,
)
.unwrap_err();
assert!(err.to_string().contains("is reserved"), "{err}");
}
#[test]
fn rejects_duplicate_executors() {
init();
let err = Graph::from_yaml(
r#"
executors:
- { name: "a", type: "ThreadPoolExecutor", num_threads: 1 }
- { name: "a", type: "ThreadPoolExecutor", num_threads: 1 }
nodes: []
"#,
)
.unwrap_err();
assert!(err.to_string().contains("defined more than once"), "{err}");
}
#[test]
fn all_nodes_on_default_pool_produce_correct_output() {
init();
let graph = Graph::from_yaml(
r#"
nodes:
- { name: "n1", kernel: "PassThrough", input_ports: ["in"], output_ports: ["m1"] }
- { name: "n2", kernel: "PassThrough", input_ports: ["m1"], output_ports: ["m2"] }
- { name: "n3", kernel: "PassThrough", input_ports: ["m2"], output_ports: ["out"] }
input_ports: ["in"]
output_ports: ["out"]
"#,
)
.unwrap();
let poller = graph.add_poller("out").unwrap();
graph.start().unwrap();
let input = graph.input("in").unwrap();
const N: i32 = 200;
for i in 0..N {
input.send(Packet::new(i).at(Timestamp(i as i64))).unwrap();
}
graph.close_all_inputs();
graph.wait_done_timeout(Duration::from_secs(30)).unwrap();
let mut got = Vec::new();
while let Some(p) = poller.try_next() {
got.push(*p.get::<i32>().unwrap());
}
assert_eq!(got.len(), N as usize, "must not drop packets");
assert_eq!(got, (0..N).collect::<Vec<_>>(), "order must be preserved");
}
#[test]
fn blocking_next_drains_tail_when_edge_closes_mid_drain() {
init();
const N: usize = 6;
const ROUNDS: usize = 4000;
for round in 0..ROUNDS {
let graph = Graph::from_yaml(
r#"
executors:
- { name: "pool", type: "ThreadPoolExecutor", num_threads: 2 }
nodes:
- { name: "a", kernel: "PassThrough", executor: "pool", input_ports: ["in"], output_ports: ["mid"] }
- { name: "b", kernel: "PassThrough", executor: "pool", input_ports: ["mid"], output_ports: ["out"] }
input_ports: ["in"]
output_ports: ["out"]
"#,
)
.unwrap();
let poller = graph.add_poller("out").unwrap();
graph.start().unwrap();
let input = graph.input("in").unwrap();
for i in 0..N {
input
.send(Packet::new(i as i32).at(Timestamp(i as i64)))
.unwrap();
}
graph.close_all_inputs();
let mut got = 0usize;
while poller.next().is_some() {
got += 1;
}
graph.wait_done_timeout(Duration::from_secs(30)).unwrap();
assert_eq!(
got, N,
"round {round}: next() 丢了尾包(closed 门闩被当成「队列已空」)"
);
}
}
#[test]
fn default_pool_nodes_actually_run_off_the_host_thread() {
init();
let seen = std::sync::Arc::new(Mutex::new(Vec::<String>::new()));
{
let graph = Graph::from_yaml(
r#"
nodes:
- { name: "p", kernel: "PassThrough", input_ports: ["in"], output_ports: ["out"] }
input_ports: ["in"]
output_ports: ["out"]
"#,
)
.unwrap();
let rec = seen.clone();
graph
.observe("out", move |_pkt| {
let name = std::thread::current()
.name()
.unwrap_or("(unnamed)")
.to_string();
rec.lock().expect("lock poisoned").push(name);
})
.unwrap();
graph.start().unwrap();
let input = graph.input("in").unwrap();
for i in 0..20i32 {
input.send(Packet::new(i).at(Timestamp(i as i64))).unwrap();
}
graph.close_all_inputs();
graph.wait_done_timeout(Duration::from_secs(30)).unwrap();
}
let names = seen.lock().expect("lock poisoned").clone();
assert_eq!(names.len(), 20, "observer should receive all 20 packets");
assert!(
names.iter().all(|n| n.starts_with("default-")),
"a node without executor must run on a default-N pool thread, actual: {names:?}"
);
}
#[test]
fn default_pool_advances_without_host_pumping() {
init();
let graph = Graph::from_yaml(
r#"
nodes:
- { name: "p", kernel: "PassThrough", input_ports: ["in"], output_ports: ["out"] }
input_ports: ["in"]
output_ports: ["out"]
"#,
)
.unwrap();
let seen = Arc::new(AtomicUsize::new(0));
let observed = seen.clone();
graph
.observe("out", move |_| {
observed.fetch_add(1, Ordering::SeqCst);
})
.unwrap();
graph.start().unwrap();
graph
.input("in")
.unwrap()
.send(Packet::new(7).at(Timestamp(0)))
.unwrap();
let deadline = std::time::Instant::now() + Duration::from_secs(2);
while seen.load(Ordering::SeqCst) == 0 && std::time::Instant::now() < deadline {
std::thread::sleep(Duration::from_millis(1));
}
assert_eq!(
seen.load(Ordering::SeqCst),
1,
"default worker must advance after send without wait/poller/pump_step"
);
graph.close_all_inputs();
graph.wait_done_timeout(Duration::from_secs(30)).unwrap();
}
#[derive(Clone, Copy, Debug)]
enum Placement {
Default,
ExplicitPool,
Delegating,
}
impl Placement {
const ALL: [Placement; 3] = [
Placement::Default,
Placement::ExplicitPool,
Placement::Delegating,
];
fn parts(self) -> (&'static str, &'static str) {
match self {
Placement::Default => ("", ""),
Placement::ExplicitPool => (
"executors:\n - { name: \"cpu\", type: \"ThreadPoolExecutor\", num_threads: 4 }\n",
", executor: \"cpu\"",
),
Placement::Delegating => (
"executors:\n - { name: \"host\", type: \"DelegatingExecutor\" }\n",
", executor: \"host\"",
),
}
}
fn linear_graph(self) -> Graph {
let (execs, on_node) = self.parts();
Graph::from_yaml(&format!(
r#"
{execs}nodes:
- {{ name: "n1", kernel: "PassThrough", input_ports: ["in"], output_ports: ["m1"]{on_node} }}
- {{ name: "n2", kernel: "PassThrough", input_ports: ["m1"], output_ports: ["m2"]{on_node} }}
- {{ name: "n3", kernel: "PassThrough", input_ports: ["m2"], output_ports: ["out"]{on_node} }}
input_ports: ["in"]
output_ports: ["out"]
"#
))
.unwrap_or_else(|e| panic!("{self:?} 应当能建图: {e}"))
}
fn expected_thread(self) -> &'static str {
match self {
Placement::Default => "default-",
Placement::ExplicitPool => "cpu-",
Placement::Delegating => "HOST",
}
}
}
#[test]
fn every_placement_preserves_all_packets_in_order() {
init();
const N: i32 = 100;
for p in Placement::ALL {
let graph = p.linear_graph();
let poller = graph.add_poller("out").unwrap();
graph.start().unwrap();
let input = graph.input("in").unwrap();
for i in 0..N {
input.send(Packet::new(i).at(Timestamp(i as i64))).unwrap();
}
graph.close_all_inputs();
graph.wait_done_timeout(Duration::from_secs(30)).unwrap();
let mut got = Vec::new();
while let Some(pkt) = poller.try_next() {
got.push(*pkt.get::<i32>().unwrap());
}
assert_eq!(got.len(), N as usize, "{p:?} 丢包了");
assert_eq!(got, (0..N).collect::<Vec<_>>(), "{p:?} 顺序被打乱");
}
}
#[test]
fn every_placement_terminates_and_conserves_ownership() {
let _acct = ACCOUNTING.lock().unwrap_or_else(|e| e.into_inner());
init();
for p in Placement::ALL {
let base = ALIVE.load(Ordering::SeqCst);
{
let graph = p.linear_graph();
let poller = graph.add_poller("out").unwrap();
graph.start().unwrap();
let input = graph.input("in").unwrap();
for i in 0..20i32 {
input.send(tracked_packet(i, i as i64)).unwrap();
}
graph.close_all_inputs();
graph.wait_done_timeout(Duration::from_secs(30)).unwrap();
while poller.try_next().is_some() {}
}
assert_eq!(
ALIVE.load(Ordering::SeqCst),
base,
"{p:?} 拆图后仍有包没被释放"
);
}
}
#[test]
fn every_placement_lands_on_its_own_thread() {
init();
let host = std::thread::current().id();
for p in Placement::ALL {
let seen = std::sync::Arc::new(Mutex::new(Vec::<String>::new()));
{
let graph = p.linear_graph();
let rec = seen.clone();
graph
.observe("out", move |_pkt| {
let name = if std::thread::current().id() == host {
"HOST".to_string()
} else {
std::thread::current()
.name()
.unwrap_or("(unnamed)")
.to_string()
};
rec.lock().expect("lock poisoned").push(name);
})
.unwrap();
graph.start().unwrap();
let input = graph.input("in").unwrap();
for i in 0..5i32 {
input.send(Packet::new(i).at(Timestamp(i as i64))).unwrap();
}
graph.close_all_inputs();
graph.wait_done_timeout(Duration::from_secs(30)).unwrap();
}
let names = seen.lock().expect("lock poisoned").clone();
assert_eq!(names.len(), 5, "{p:?} observer 应收到全部 5 个包");
let want = p.expected_thread();
assert!(
names.iter().all(|n| n.starts_with(want)),
"{p:?} 应跑在 `{want}*` 线程上,actual: {names:?}"
);
}
}
#[test]
fn every_placement_is_labelled_in_dot() {
init();
for p in Placement::ALL {
let dot = p.linear_graph().to_dot();
let want_label = match p {
Placement::Default => "@default",
Placement::ExplicitPool => "@cpu",
Placement::Delegating => "@host",
};
assert!(
dot.contains(want_label),
"{p:?} 应标出 {want_label}:\n{dot}"
);
assert!(dot.contains("cluster_legend"), "{p:?} 缺执行器图例:\n{dot}");
let delegating_legend = dot.contains("host thread (delegating)");
match p {
Placement::Delegating => assert!(
delegating_legend,
"委托执行器的图例该写明「交还宿主线程」,而不是标成 0t 的池:\n{dot}"
),
_ => assert!(
!delegating_legend,
"{p:?} 没有委托执行器,不该出现委托图例:\n{dot}"
),
}
}
}
#[test]
fn default_executor_is_a_named_thread_pool() {
init();
let graph = Graph::from_yaml(
r#"
nodes:
- { name: "p", kernel: "PassThrough", input_ports: ["in"], output_ports: ["out"] }
input_ports: ["in"]
output_ports: ["out"]
"#,
)
.unwrap();
assert_eq!(
graph.executor_names(),
vec!["default"],
"默认执行器必须可见、有名字"
);
}
#[test]
fn own_pool_is_how_you_control_threads() {
init();
let seen = std::sync::Arc::new(Mutex::new(Vec::<String>::new()));
{
let graph = Graph::from_yaml(
r#"
executors:
- { name: "solo", type: "ThreadPoolExecutor", num_threads: 1 }
nodes:
- { name: "p", kernel: "PassThrough", executor: "solo", input_ports: ["in"], output_ports: ["out"] }
input_ports: ["in"]
output_ports: ["out"]
"#,
)
.unwrap();
assert_eq!(graph.executor_names(), vec!["default", "solo"]);
let rec = seen.clone();
graph
.observe("out", move |_pkt| {
let name = std::thread::current()
.name()
.unwrap_or("(unnamed)")
.to_string();
rec.lock().expect("lock poisoned").push(name);
})
.unwrap();
graph.start().unwrap();
let input = graph.input("in").unwrap();
for i in 0..5i32 {
input.send(Packet::new(i).at(Timestamp(i as i64))).unwrap();
}
graph.close_all_inputs();
graph.wait_done_timeout(Duration::from_secs(30)).unwrap();
}
let names = seen.lock().expect("lock poisoned").clone();
assert!(
names.iter().all(|n| n == "solo-0"),
"num_threads: 1 应当只开一个 worker,actual: {names:?}"
);
}
#[test]
fn delegating_only_advances_when_the_host_enters_the_engine() {
init();
let graph = Placement::Delegating.linear_graph();
let poller = graph.add_poller("out").unwrap();
graph.start().unwrap();
let input = graph.input("in").unwrap();
for i in 0..5i32 {
input.send(Packet::new(i).at(Timestamp(i as i64))).unwrap();
}
assert!(
poller.try_next().is_none(),
"委托执行器上的节点在宿主进入引擎之前不该推进"
);
graph.close_all_inputs();
graph.wait_done_timeout(Duration::from_secs(30)).unwrap();
let mut got = Vec::new();
while let Some(p) = poller.try_next() {
got.push(*p.get::<i32>().unwrap());
}
assert_eq!(
got,
(0..5).collect::<Vec<_>>(),
"进了阻塞接口之后必须全部推进,且顺序确定"
);
}
#[test]
fn multiple_delegating_executors_are_pumped_fairly() {
init();
let graph = Graph::from_yaml(
r#"
executors:
- { name: "host_a", type: "DelegatingExecutor" }
- { name: "host_b", type: "DelegatingExecutor" }
nodes:
- { name: "a", kernel: "PassThrough", executor: "host_a", input_ports: ["in_a"], output_ports: ["out_a"] }
- { name: "b", kernel: "PassThrough", executor: "host_b", input_ports: ["in_b"], output_ports: ["out_b"] }
input_ports: ["in_a", "in_b"]
output_ports: ["out_a", "out_b"]
"#,
)
.unwrap();
let out_b = graph.add_poller("out_b").unwrap();
graph.start().unwrap();
for i in 0..10i32 {
graph
.input("in_a")
.unwrap()
.send(Packet::new(i).at(Timestamp(i as i64)))
.unwrap();
}
graph
.input("in_b")
.unwrap()
.send(Packet::new(99).at(Timestamp(0)))
.unwrap();
assert!(graph.pump_step(), "first delegated executor should advance");
assert!(
graph.pump_step(),
"second delegated executor should not starve"
);
assert_eq!(
out_b
.try_next()
.and_then(|packet| packet.get::<i32>().copied()),
Some(99),
"round-robin pumping must reach host_b even while host_a remains busy"
);
graph.close_all_inputs();
graph.wait_done_timeout(Duration::from_secs(30)).unwrap();
}
#[test]
fn delegating_execution_is_serial_across_host_threads() {
init();
DELEGATED_ACTIVE.store(0, Ordering::SeqCst);
DELEGATED_MAX_ACTIVE.store(0, Ordering::SeqCst);
let graph = Arc::new(
Graph::from_yaml(
r#"
executors:
- { name: "host_a", type: "DelegatingExecutor" }
- { name: "host_b", type: "DelegatingExecutor" }
nodes:
- { name: "a", kernel: "DelegatedSlowPass", executor: "host_a", input_ports: ["in_a"], output_ports: ["out_a"] }
- { name: "b", kernel: "DelegatedSlowPass", executor: "host_b", input_ports: ["in_b"], output_ports: ["out_b"] }
input_ports: ["in_a", "in_b"]
output_ports: ["out_a", "out_b"]
"#,
)
.unwrap(),
);
graph.add_poller("out_a").unwrap();
graph.add_poller("out_b").unwrap();
graph.start().unwrap();
graph
.input("in_a")
.unwrap()
.send(Packet::new(1).at(Timestamp(0)))
.unwrap();
graph
.input("in_b")
.unwrap()
.send(Packet::new(2).at(Timestamp(0)))
.unwrap();
let barrier = Arc::new(std::sync::Barrier::new(3));
let handles: Vec<_> = (0..2)
.map(|_| {
let graph = graph.clone();
let barrier = barrier.clone();
std::thread::spawn(move || {
barrier.wait();
graph.pump_step()
})
})
.collect();
barrier.wait();
for handle in handles {
let _ = handle.join().unwrap();
}
while graph.pump_step() {}
assert_eq!(
DELEGATED_MAX_ACTIVE.load(Ordering::SeqCst),
1,
"one graph must never execute delegated kernels concurrently"
);
graph.close_all_inputs();
graph.wait_done_timeout(Duration::from_secs(30)).unwrap();
}
#[test]
fn rust_wakeup_callback_rearms_after_pump_drain() {
init();
let graph = Graph::from_yaml(
r#"
executors:
- { name: host, type: DelegatingExecutor }
nodes:
- { name: pass, kernel: PassThrough, executor: host, input_ports: [in], output_ports: [out] }
input_ports: [in]
output_ports: [out]
"#,
)
.unwrap();
let wakes = Arc::new(AtomicUsize::new(0));
let seen = wakes.clone();
graph.set_wakeup_callback(move || {
seen.fetch_add(1, Ordering::SeqCst);
});
graph.start().unwrap();
let input = graph.input("in").unwrap();
input.send(Packet::new(1).at(Timestamp(0))).unwrap();
input.send(Packet::new(2).at(Timestamp(1))).unwrap();
assert_eq!(wakes.load(Ordering::SeqCst), 1);
while graph.pump_step() {}
input.send(Packet::new(3).at(Timestamp(2))).unwrap();
assert_eq!(wakes.load(Ordering::SeqCst), 2);
graph.clear_wakeup_callback();
graph.cancel();
assert!(matches!(
graph.wait_done_timeout(Duration::from_secs(1)),
Err(lmflow::Error::Cancelled)
));
}
#[test]
fn rust_wakeup_callback_rearms_after_panic() {
init();
let graph = Graph::from_yaml(
r#"
executors:
- { name: host, type: DelegatingExecutor }
nodes:
- { name: pass, kernel: PassThrough, executor: host, input_ports: [in], output_ports: [out] }
input_ports: [in]
output_ports: [out]
"#,
)
.unwrap();
let wakes = Arc::new(AtomicUsize::new(0));
let seen = wakes.clone();
graph.set_wakeup_callback(move || {
if seen.fetch_add(1, Ordering::SeqCst) == 0 {
panic!("first wakeup fails");
}
});
graph.start().unwrap();
let input = graph.input("in").unwrap();
input.send(Packet::new(1).at(Timestamp(0))).unwrap();
graph.cancel();
assert_eq!(wakes.load(Ordering::SeqCst), 2);
graph.clear_wakeup_callback();
assert!(matches!(
graph.wait_done_timeout(Duration::from_secs(1)),
Err(lmflow::Error::Cancelled)
));
}
#[test]
fn max_in_flight_on_default_pool_follows_its_thread_count() {
init();
let threads = std::thread::available_parallelism()
.map(|n| n.get())
.unwrap_or(1);
let built = Graph::from_yaml(
r#"
nodes:
- { name: "p", kernel: "PassThrough", input_ports: ["in"], output_ports: ["out"], max_in_flight: 4 }
input_ports: ["in"]
output_ports: ["out"]
"#,
);
if threads > 1 {
assert!(
built.is_ok(),
"默认池有 {threads} 个线程,max_in_flight>1 应当合法:{:?}",
built.err()
);
} else {
let err = built.expect_err("单线程默认池下并行度恒为 1,必须报错");
assert!(err.to_string().contains("more than one thread"), "{err}");
}
}
#[test]
fn delegating_executor_rejects_thread_fields() {
init();
for field in ["num_threads: 2", "affinity: [0]", "priority: 10"] {
let err = Graph::from_yaml(&format!(
r#"
executors:
- {{ name: "host", type: "DelegatingExecutor", {field} }}
nodes:
- {{ name: "p", kernel: "PassThrough", executor: "host", input_ports: ["in"], output_ports: ["out"] }}
input_ports: ["in"]
output_ports: ["out"]
"#
))
.unwrap_err();
assert!(
err.to_string().contains("owns no threads"),
"`{field}` 配在委托执行器上必须报错,actual: {err}"
);
}
}
#[test]
fn source_and_ordinary_node_may_share_single_thread_pool() {
init();
Graph::from_yaml(
r#"
executors:
- { name: "solo", type: "ThreadPoolExecutor", num_threads: 1 }
nodes:
- { name: "src", kernel: "FiniteSource", executor: "solo", input_ports: [], output_ports: ["mid"] }
- { name: "p", kernel: "PassThrough", executor: "solo", input_ports: ["mid"], output_ports: ["out"] }
output_ports: ["out"]
"#,
)
.expect("cooperative sources can share a single-thread pool with ordinary nodes");
}
#[test]
fn mixed_executors_do_not_deadlock() {
init();
let graph = Graph::from_yaml(
r#"
executors:
- { name: "cpu", type: "ThreadPoolExecutor", num_threads: 3 }
- { name: "host", type: "DelegatingExecutor" }
nodes:
- { name: "a", kernel: "PassThrough", executor: "cpu", input_ports: ["in"], output_ports: ["m1"] }
- { name: "b", kernel: "PassThrough", executor: "host", input_ports: ["m1"], output_ports: ["m2"] }
- { name: "c", kernel: "PassThrough", executor: "cpu", input_ports: ["m2"], output_ports: ["out"] }
input_ports: ["in"]
output_ports: ["out"]
"#,
)
.unwrap();
let poller = graph.add_poller("out").unwrap();
graph.start().unwrap();
let input = graph.input("in").unwrap();
for i in 0..50i32 {
input.send(Packet::new(i).at(Timestamp(i as i64))).unwrap();
let p = poller
.next_timeout(Duration::from_secs(10))
.expect("should not time out")
.expect("should have output");
assert_eq!(p.get::<i32>(), Some(&i));
}
graph.close_all_inputs();
graph.wait_done_timeout(Duration::from_secs(30)).unwrap();
}
#[test]
fn concurrent_fanout_branches() {
init();
let graph = Graph::from_yaml(
r#"
executors:
- { name: "cpu", type: "ThreadPoolExecutor", num_threads: 4 }
nodes:
- { name: "sp", kernel: "PassThrough", executor: "cpu", input_ports: ["in"], output_ports: ["fan"] }
- { name: "pa", kernel: "PassThrough", executor: "cpu", input_ports: ["fan"], output_ports: ["oa"] }
- { name: "pb", kernel: "PassThrough", executor: "cpu", input_ports: ["fan"], output_ports: ["ob"] }
input_ports: ["in"]
output_ports: ["oa", "ob"]
"#,
)
.unwrap();
let pa = graph.add_poller("oa").unwrap();
let pb = graph.add_poller("ob").unwrap();
graph.start().unwrap();
let input = graph.input("in").unwrap();
const N: i32 = 100;
for i in 0..N {
input.send(Packet::new(i).at(Timestamp(i as i64))).unwrap();
}
graph.close_all_inputs();
graph.wait_done_timeout(Duration::from_secs(30)).unwrap();
let count = |p: &lmflow::Poller| {
let mut n = 0;
while p.try_next().is_some() {
n += 1;
}
n
};
assert_eq!(count(&pa), N as usize, "branch a should receive all");
assert_eq!(count(&pb), N as usize, "branch b should receive all");
}
#[test]
fn wait_until_idle_waits_for_default_pool() {
init();
let graph = Graph::from_yaml(
r#"
nodes:
- { name: "p", kernel: "PassThrough", input_ports: ["in"], output_ports: ["out"] }
input_ports: ["in"]
output_ports: ["out"]
"#,
)
.unwrap();
let poller = graph.add_poller("out").unwrap();
graph.start().unwrap();
let input = graph.input("in").unwrap();
for i in 0..100i32 {
input.send(Packet::new(i).at(Timestamp(i as i64))).unwrap();
}
graph
.wait_until_idle_timeout(Duration::from_secs(30))
.unwrap();
assert!(graph.is_idle(), "must truly be idle after idle returns");
let mut n = 0;
while poller.try_next().is_some() {
n += 1;
}
assert_eq!(n, 100, "all packets should be produced after idle");
graph.close_all_inputs();
graph.wait_done_timeout(Duration::from_secs(10)).unwrap();
}
#[test]
fn poller_timeout_actually_times_out() {
init();
let graph = Graph::from_yaml(
r#"
executors:
- { name: "cpu", type: "ThreadPoolExecutor", num_threads: 1 }
nodes:
- { name: "p", kernel: "PassThrough", executor: "cpu", input_ports: ["in"], output_ports: ["out"] }
input_ports: ["in"]
output_ports: ["out"]
"#,
)
.unwrap();
let poller = graph.add_poller("out").unwrap();
graph.start().unwrap();
let t0 = std::time::Instant::now();
let r = poller.next_timeout(Duration::from_millis(300));
assert!(
t0.elapsed() < Duration::from_secs(5),
"must not hang forever"
);
assert!(
matches!(r, Ok(None)),
"idle with no data should return Ok(None), actual {r:?}"
);
}
#[test]
fn pause_and_resume() {
init();
let graph = Graph::from_yaml(
r#"
nodes:
- { name: "p", kernel: "PassThrough", input_ports: ["in"], output_ports: ["out"] }
input_ports: ["in"]
output_ports: ["out"]
"#,
)
.unwrap();
let poller = graph.add_poller("out").unwrap();
graph.start().unwrap();
graph.pause();
assert!(graph.is_paused());
let input = graph.input("in").unwrap();
for i in 0..10i32 {
input.send(Packet::new(i).at(Timestamp(i as i64))).unwrap();
}
std::thread::sleep(Duration::from_millis(100));
assert!(
poller.try_next().is_none(),
"no output should occur while paused"
);
assert_eq!(
graph.queue_depth("in"),
Some(10),
"packets should be backlogged"
);
graph.resume();
assert!(!graph.is_paused());
graph
.wait_until_idle_timeout(Duration::from_secs(30))
.unwrap();
let mut n = 0;
while poller.try_next().is_some() {
n += 1;
}
assert_eq!(
n, 10,
"after resume, all packets backlogged while paused must be processed"
);
graph.close_all_inputs();
graph.wait_done_timeout(Duration::from_secs(10)).unwrap();
}
#[test]
fn close_is_called_exactly_once_under_concurrency() {
init();
for round in 0..30 {
let graph = Graph::from_yaml(
r#"
nodes:
- { name: "s", kernel: "Sink", input_ports: ["in"], output_ports: [] }
input_ports: ["in"]
"#,
)
.unwrap();
graph.start().unwrap();
let input = graph.input("in").unwrap();
for i in 0..30i32 {
input.send(Packet::new(i).at(Timestamp(i as i64))).unwrap();
}
graph.close_all_inputs();
graph.wait_done_timeout(Duration::from_secs(30)).unwrap();
assert_eq!(
graph.counter_value("sink.closed"),
1,
"round {round}: Close must be called exactly once"
);
assert_eq!(
graph.counter_value("sink.packets"),
30,
"round {round}: every packet should be processed once"
);
}
}
#[test]
fn ownership_conserved_under_concurrency() {
let _lock = ACCOUNTING.lock().unwrap_or_else(|e| e.into_inner());
let base = ALIVE.load(Ordering::SeqCst);
init();
{
let graph = Graph::from_yaml(
r#"
nodes:
- { name: "sp", kernel: "PassThrough", input_ports: ["in"], output_ports: ["fan"] }
- { name: "pa", kernel: "PassThrough", input_ports: ["fan"], output_ports: ["oa"] }
- { name: "pb", kernel: "Sink", input_ports: ["fan"], output_ports: [] }
input_ports: ["in"]
output_ports: ["oa"]
"#,
)
.unwrap();
let poller = graph.add_poller("oa").unwrap();
graph.start().unwrap();
let input = graph.input("in").unwrap();
for i in 0..300i32 {
input.send(tracked_packet(i, i as i64)).unwrap();
}
graph.close_all_inputs();
graph.wait_done_timeout(Duration::from_secs(60)).unwrap();
while poller.try_next().is_some() {}
}
assert_eq!(
ALIVE.load(Ordering::SeqCst),
base,
"must not leak or double-free under concurrency + fanout"
);
}
#[test]
fn multiple_host_threads_sending() {
init();
let graph = std::sync::Arc::new(
Graph::from_yaml(
r#"
nodes:
- { name: "a", kernel: "PassThrough", input_ports: ["in1"], output_ports: ["o1"] }
- { name: "b", kernel: "PassThrough", input_ports: ["in2"], output_ports: ["o2"] }
input_ports: ["in1", "in2"]
output_ports: ["o1", "o2"]
"#,
)
.unwrap(),
);
let p1 = graph.add_poller("o1").unwrap();
let p2 = graph.add_poller("o2").unwrap();
graph.start().unwrap();
let g1 = graph.clone();
let g2 = graph.clone();
let t1 = std::thread::spawn(move || {
let i = g1.input("in1").unwrap();
for k in 0..100i32 {
i.send(Packet::new(k).at(Timestamp(k as i64))).unwrap();
}
});
let t2 = std::thread::spawn(move || {
let i = g2.input("in2").unwrap();
for k in 0..100i32 {
i.send(Packet::new(k + 1000).at(Timestamp(k as i64)))
.unwrap();
}
});
t1.join().unwrap();
t2.join().unwrap();
graph.close_all_inputs();
graph.wait_done_timeout(Duration::from_secs(60)).unwrap();
let count = |p: &lmflow::Poller| {
let mut n = 0;
while p.try_next().is_some() {
n += 1;
}
n
};
assert_eq!(count(&p1), 100);
assert_eq!(count(&p2), 100);
}
#[test]
fn concurrent_send_to_same_port_is_safe() {
let _acct = ACCOUNTING.lock().unwrap_or_else(|e| e.into_inner());
let base = ALIVE.load(Ordering::SeqCst);
let accepted = std::sync::Arc::new(AtomicUsize::new(0));
{
init();
let graph = std::sync::Arc::new(
Graph::from_yaml(
r#"
nodes:
- { name: "p", kernel: "PassThrough", input_ports: ["in"], output_ports: ["out"] }
input_ports: ["in"]
output_ports: ["out"]
"#,
)
.unwrap(),
);
let poller = graph.add_poller("out").unwrap();
graph.start().unwrap();
let mut handles = Vec::new();
for t in 0..4i64 {
let g = graph.clone();
let acc = accepted.clone();
handles.push(std::thread::spawn(move || {
let input = g.input("in").unwrap();
for k in 0..50i64 {
let ts = k * 4 + t;
if input.send(tracked_packet(ts as i32, ts)).is_ok() {
acc.fetch_add(1, Ordering::SeqCst);
}
}
}));
}
for h in handles {
h.join().unwrap();
}
graph.close_all_inputs();
graph.wait_done_timeout(Duration::from_secs(60)).unwrap();
let mut delivered = 0usize;
while poller.try_next().is_some() {
delivered += 1;
}
assert_eq!(
delivered,
accepted.load(Ordering::SeqCst),
"every accepted packet must be delivered exactly once (no loss, no dup)"
);
}
assert_eq!(
ALIVE.load(Ordering::SeqCst),
base,
"concurrent same-port sends must not leak (including non-monotonically-rejected packets)"
);
}
#[test]
fn dropping_graph_with_busy_pool_is_clean() {
let _lock = ACCOUNTING.lock().unwrap_or_else(|e| e.into_inner());
let base = ALIVE.load(Ordering::SeqCst);
init();
for _ in 0..10 {
let graph = Graph::from_yaml(
r#"
nodes:
- { name: "p", kernel: "PassThrough", input_ports: ["in"], output_ports: ["out"] }
input_ports: ["in"]
output_ports: ["out"]
"#,
)
.unwrap();
graph.start().unwrap();
let input = graph.input("in").unwrap();
for i in 0..100i32 {
input.send(tracked_packet(i, i as i64)).unwrap();
}
drop(input);
drop(graph);
}
assert_eq!(
ALIVE.load(Ordering::SeqCst),
base,
"teardown must still release all in-flight payloads"
);
}