Skip to content

Commit aec5665

Browse files
lklimekclaude
andcommitted
fix(rs-dapi-client,rs-sdk): fix 4 health check review findings
FIX 1: Break out of health_check_loop when cancellation is consumed inside probe_and_update_batch. The FusedFuture becomes terminated, causing the outer select_biased! to skip the stop branch forever. FIX 2: Only store the new CancellationToken into the mutex after confirming the tokio runtime exists and the task will be spawned. Previously, is_health_check_running() returned true in FFI paths where no task was actually running. FIX 3: Don't re-ban already-banned nodes during health check re-probes. Each ban() call increments ban_count with exponential backoff; deterministic re-probing drove ban duration to absurd levels (112 days after 12 cycles). FIX 4: Hold the mutex for the entire start_health_check operation to prevent concurrent calls from orphaning tasks. The previous implementation released and re-acquired the mutex, allowing interleaving. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent b70f39d commit aec5665

2 files changed

Lines changed: 74 additions & 7 deletions

File tree

packages/rs-dapi-client/src/health_check.rs

Lines changed: 57 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,14 @@ async fn health_check_loop(
130130
&mut stop,
131131
)
132132
.await;
133+
134+
// FIX 1: If cancellation was consumed inside probe_and_update_batch,
135+
// the fused future is now terminated and the outer select_biased!
136+
// would skip it, making the loop unkillable. Break explicitly.
137+
if stop.is_terminated() {
138+
tracing::debug!("health check cancelled during probing");
139+
break;
140+
}
133141
}
134142

135143
let sleep_duration = match address_list.get_next_ban_expiry() {
@@ -186,7 +194,14 @@ async fn probe_and_update_batch(
186194
}
187195
}
188196
Err(error) => {
189-
address_list.ban(address);
197+
// Only ban on first failure; don't escalate already-banned nodes.
198+
// The health check re-probes on every cycle, and each ban() call
199+
// increments ban_count with exponential backoff. Without this guard,
200+
// deterministic re-probing drives ban duration to absurd levels
201+
// (e.g. 112 days after 12 cycles).
202+
if !address_list.is_banned(address) {
203+
address_list.ban(address);
204+
}
190205
tracing::debug!(%address, error = %error, "health check: node is unhealthy, banned");
191206
}
192207
}
@@ -399,6 +414,47 @@ mod tests {
399414
);
400415
}
401416

417+
/// FIX-1: Verify that cancellation during active probing (not just during sleep)
418+
/// actually stops the loop. Before the fix, FusedFuture termination inside
419+
/// probe_and_update_batch made the outer loop skip the stop branch, running forever.
420+
#[tokio::test]
421+
async fn test_cancel_during_active_probing_stops_loop() {
422+
let mut address_list = AddressList::new();
423+
// Use multiple slow-to-probe addresses so probing takes a while
424+
for port in 1..=5 {
425+
let addr: Address = format!("http://192.0.2.1:{}", port).parse().unwrap();
426+
address_list.add(addr);
427+
}
428+
429+
let pool = ConnectionPool::new(10);
430+
let config = HealthCheckConfig {
431+
// Long timeout so probing is still in-flight when we cancel
432+
probe_timeout: Duration::from_secs(10),
433+
max_concurrent_probes: 1,
434+
no_ban_idle_period: Duration::from_secs(300),
435+
};
436+
let cancel_token = CancellationToken::new();
437+
438+
let ct = cancel_token.clone();
439+
let al = address_list.clone();
440+
441+
let handle = tokio::spawn(async move {
442+
run_health_check(al, pool, config, cancel_token, None).await;
443+
});
444+
445+
// Cancel while probing is in-flight (not during the sleep phase)
446+
tokio::time::sleep(Duration::from_millis(100)).await;
447+
ct.cancel();
448+
449+
// If the fix is missing, the loop becomes a zombie and this will timeout
450+
let result = tokio::time::timeout(Duration::from_secs(5), handle).await;
451+
assert!(
452+
result.is_ok(),
453+
"health check must stop within 5s when cancelled during active probing"
454+
);
455+
result.unwrap().unwrap();
456+
}
457+
402458
/// QA-004: Verify cancel_token stops the loop even when addresses have active bans
403459
/// (i.e., when sleeping until ban expiry).
404460
#[tokio::test]

packages/rs-sdk/src/sdk.rs

Lines changed: 17 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -463,8 +463,17 @@ impl Sdk {
463463
/// If a health check is already running, it is stopped first.
464464
/// All `Sdk` clones share the same state, so starting from any clone
465465
/// starts it for all of them.
466+
///
467+
/// The mutex is held for the entire operation to prevent concurrent calls
468+
/// from orphaning tasks.
466469
pub fn start_health_check(&self, config: rs_dapi_client::HealthCheckConfig) {
467-
self.stop_health_check();
470+
let mut cancel_guard = self
471+
.health_check_cancel
472+
.lock()
473+
.expect("health_check_cancel mutex poisoned");
474+
475+
// Stop existing health check (atomic: no mutex release between stop and start)
476+
cancel_guard.cancel();
468477

469478
let new_cancel = self.cancel_token.child_token();
470479

@@ -478,14 +487,11 @@ impl Sdk {
478487
#[cfg(target_arch = "wasm32")]
479488
let hc_ca_cert: Option<()> = None;
480489

481-
*self
482-
.health_check_cancel
483-
.lock()
484-
.expect("health_check_cancel mutex poisoned") = new_cancel;
485-
486490
#[cfg(not(target_arch = "wasm32"))]
487491
{
488492
if let Ok(runtime) = tokio::runtime::Handle::try_current() {
493+
*cancel_guard = new_cancel;
494+
drop(cancel_guard);
489495
drop(
490496
runtime.spawn(rs_dapi_client::health_check::run_health_check(
491497
hc_address_list,
@@ -496,12 +502,17 @@ impl Sdk {
496502
)),
497503
);
498504
} else {
505+
// No runtime: don't store the token so is_health_check_running()
506+
// correctly returns false.
507+
drop(cancel_guard);
499508
tracing::warn!("no Tokio runtime found, cannot start health check");
500509
}
501510
}
502511

503512
#[cfg(target_arch = "wasm32")]
504513
{
514+
*cancel_guard = new_cancel;
515+
drop(cancel_guard);
505516
wasm_bindgen_futures::spawn_local(rs_dapi_client::health_check::run_health_check(
506517
hc_address_list,
507518
hc_pool,

0 commit comments

Comments
 (0)