Skip to content

Commit 8c7920e

Browse files
authored
test(hiroz-tests): signal-based producer guard + graph-poll waits (hu stack 4/9) (#235)
1 parent 4c428ca commit 8c7920e

3 files changed

Lines changed: 320 additions & 71 deletions

File tree

crates/hiroz-tests/tests/common/mod.rs

Lines changed: 132 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
use std::process::{Child, Command, Stdio};
2+
use std::sync::Arc;
3+
use std::sync::atomic::{AtomicBool, Ordering};
24
use std::thread;
35
use std::time::Duration;
46

@@ -131,7 +133,26 @@ impl TestRouter {
131133

132134
match zenoh::open(config).wait() {
133135
Ok(session) => {
134-
thread::sleep(Duration::from_millis(500));
136+
// Poll the router's TCP listener (40 * 50ms, ~2s budget)
137+
// instead of a blind fixed sleep; proceed anyway if it
138+
// never accepts (best-effort).
139+
let probe_addr = std::net::SocketAddr::from(([127, 0, 0, 1], port));
140+
for _ in 0..40 {
141+
// connect_timeout caps each probe at 50ms; plain connect
142+
// could block on the OS timeout and blow the budget.
143+
if std::net::TcpStream::connect_timeout(
144+
&probe_addr,
145+
Duration::from_millis(50),
146+
)
147+
.is_ok()
148+
{
149+
break;
150+
}
151+
thread::sleep(Duration::from_millis(50));
152+
}
153+
// TCP-accept only proves the listener is bound; short floor
154+
// covers the router's remaining routing/liveliness init.
155+
thread::sleep(Duration::from_millis(150));
135156
println!("Zenoh router ready on {}", endpoint);
136157
return Self {
137158
port,
@@ -191,6 +212,34 @@ pub fn wait_for_ready(duration: Duration) {
191212
thread::sleep(duration);
192213
}
193214

215+
/// Wait until a ROS node named `node_name` is visible in the graph, or `timeout`
216+
/// elapses. Deterministic replacement for a blind `wait_for_ready` sleep before
217+
/// interacting with a just-spawned node: it returns as soon as the node is
218+
/// discoverable (proceeds early on the fast path) instead of always sleeping a
219+
/// fixed time. Returns whether the node appeared — callers may proceed either
220+
/// way, since the following operation carries its own discovery timeout.
221+
#[allow(dead_code)]
222+
pub fn wait_for_ros_node(node_name: &str, router: &TestRouter, timeout: Duration) -> bool {
223+
let ctx = create_hiroz_context_with_router(router).expect("Failed to create probe context");
224+
let start = std::time::Instant::now();
225+
loop {
226+
if ctx
227+
.graph()
228+
.get_node_names()
229+
.iter()
230+
.any(|(name, _ns)| name == node_name)
231+
{
232+
println!("Node '{node_name}' discovered after {:?}", start.elapsed());
233+
return true;
234+
}
235+
if start.elapsed() >= timeout {
236+
eprintln!("Node '{node_name}' not visible after {timeout:?}; proceeding");
237+
return false;
238+
}
239+
thread::sleep(Duration::from_millis(50));
240+
}
241+
}
242+
194243
/// Deterministically wait for a service to be ready by polling with test requests
195244
#[allow(dead_code)]
196245
pub fn wait_for_service_ready(
@@ -398,3 +447,85 @@ pub fn spawn_python_service_client(
398447

399448
ProcessGuard::new(child, "python_service_client")
400449
}
450+
451+
/// Holds a background producer (Zenoh entities) alive until this guard drops.
452+
/// Drop signals a stop flag and detaches the thread (no join, so a producer
453+
/// blocked in recv can't hang teardown) — so teardown is best-effort, not
454+
/// synchronous: the entities may briefly outlive the drop. Preferred over a
455+
/// fixed-duration sleep: too short and the entity vanishes before `hu` reads it;
456+
/// too long and the producer's client session reconnect-spins after `TestRouter`
457+
/// drops, stealing CPU from later serial tests.
458+
#[allow(dead_code)]
459+
#[must_use = "binding must be kept alive (e.g. `let _producer = ...`); dropping it immediately tears the producer down"]
460+
pub struct ProducerGuard {
461+
stop: Arc<AtomicBool>,
462+
handle: Option<thread::JoinHandle<()>>,
463+
}
464+
465+
impl Drop for ProducerGuard {
466+
fn drop(&mut self) {
467+
// Signal stop, then detach: dropping a Zenoh session can block (async
468+
// close during Tokio teardown), so joining here risks hanging the test
469+
// thread. Detaching still stops the producer's active work immediately.
470+
self.stop.store(true, Ordering::Relaxed);
471+
if let Some(handle) = self.handle.take() {
472+
// Normally still running until we set `stop`. If already finished,
473+
// the producer exited early (usually a panic) — surface it: join()
474+
// on a finished thread returns immediately, and the panic would
475+
// otherwise be swallowed and misdiagnosed as "entity never appeared".
476+
if handle.is_finished()
477+
&& let Err(panic) = handle.join()
478+
{
479+
let msg = panic
480+
.downcast_ref::<&str>()
481+
.map(|s| s.to_string())
482+
.or_else(|| panic.downcast_ref::<String>().cloned())
483+
.unwrap_or_else(|| "<non-string panic payload>".to_string());
484+
eprintln!(
485+
"WARNING: test producer thread exited early (before teardown) \
486+
with a panic: {msg}. Downstream 'entity not discovered' \
487+
failures in this test are likely caused by this."
488+
);
489+
}
490+
// Otherwise detach: the still-running thread's session teardown can
491+
// block, and must not block the test thread.
492+
}
493+
}
494+
}
495+
496+
/// Spawn a producer running `body` on a fresh Tokio runtime. `body` must poll
497+
/// the stop flag to hold entities alive (e.g.
498+
/// `while !stop.load(Ordering::Relaxed) { tokio::time::sleep(..).await }`) and
499+
/// exits, dropping them, when the returned guard drops. `body` is async, so
500+
/// tasks it spawns (e.g. an action-server handler) keep running while it holds.
501+
#[allow(dead_code)]
502+
pub fn spawn_producer<Fut>(
503+
body: impl FnOnce(Arc<AtomicBool>) -> Fut + Send + 'static,
504+
) -> ProducerGuard
505+
where
506+
Fut: std::future::Future<Output = ()>,
507+
{
508+
let stop = Arc::new(AtomicBool::new(false));
509+
let s = stop.clone();
510+
let handle = thread::spawn(move || {
511+
tokio::runtime::Runtime::new().unwrap().block_on(body(s));
512+
});
513+
ProducerGuard {
514+
stop,
515+
handle: Some(handle),
516+
}
517+
}
518+
519+
/// Convenience for a passive producer: build entities up front, then hold them
520+
/// alive until the guard drops. `build` runs inside the Tokio runtime.
521+
#[allow(dead_code)]
522+
pub fn spawn_holder<T: Send + 'static>(
523+
build: impl FnOnce() -> T + Send + 'static,
524+
) -> ProducerGuard {
525+
spawn_producer(|stop| async move {
526+
let _held = build();
527+
while !stop.load(Ordering::Relaxed) {
528+
tokio::time::sleep(Duration::from_millis(50)).await;
529+
}
530+
})
531+
}

0 commit comments

Comments
 (0)