|
1 | 1 | use std::process::{Child, Command, Stdio}; |
| 2 | +use std::sync::Arc; |
| 3 | +use std::sync::atomic::{AtomicBool, Ordering}; |
2 | 4 | use std::thread; |
3 | 5 | use std::time::Duration; |
4 | 6 |
|
@@ -131,7 +133,26 @@ impl TestRouter { |
131 | 133 |
|
132 | 134 | match zenoh::open(config).wait() { |
133 | 135 | 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)); |
135 | 156 | println!("Zenoh router ready on {}", endpoint); |
136 | 157 | return Self { |
137 | 158 | port, |
@@ -191,6 +212,34 @@ pub fn wait_for_ready(duration: Duration) { |
191 | 212 | thread::sleep(duration); |
192 | 213 | } |
193 | 214 |
|
| 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 | + |
194 | 243 | /// Deterministically wait for a service to be ready by polling with test requests |
195 | 244 | #[allow(dead_code)] |
196 | 245 | pub fn wait_for_service_ready( |
@@ -398,3 +447,85 @@ pub fn spawn_python_service_client( |
398 | 447 |
|
399 | 448 | ProcessGuard::new(child, "python_service_client") |
400 | 449 | } |
| 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