Skip to content

Commit 0307c74

Browse files
committed
fix(discovery): harden discovery-jobs thread against panics
Replace assert! with warn! in remove_unhealthy_service, recover poisoned RwLocks via into_inner() throughout, and wrap the jobs loop body in catch_unwind so a panic cannot kill the discovery thread. Log the panic payload on catch so errors are actionable in production. Add comments documenting the two-layer defense, the lock-poison recovery tradeoff, and the single-consumer TOCTOU assumption.
1 parent 5a14d36 commit 0307c74

1 file changed

Lines changed: 55 additions & 14 deletions

File tree

src/electrum/discovery.rs

Lines changed: 55 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -124,7 +124,7 @@ impl DiscoveryManager {
124124
pub fn add_server_request(&self, added_by: IpAddr, features: ServerFeatures) -> Result<()> {
125125
self.verify_compatibility(&features)?;
126126

127-
let mut queue = self.queue.write().unwrap();
127+
let mut queue = self.queue.write().unwrap_or_else(|e| e.into_inner());
128128
ensure!(queue.len() < MAX_QUEUE_SIZE, "queue size exceeded");
129129

130130
// TODO optimize
@@ -205,7 +205,7 @@ impl DiscoveryManager {
205205
/// before being removed due to unavailability.
206206
pub fn add_default_server(&self, hostname: Hostname, services: Vec<Service>) -> Result<()> {
207207
let addr = ServerAddr::resolve(&hostname)?;
208-
let mut queue = self.queue.write().unwrap();
208+
let mut queue = self.queue.write().unwrap_or_else(|e| e.into_inner());
209209
queue.extend(
210210
services
211211
.into_iter()
@@ -219,7 +219,7 @@ impl DiscoveryManager {
219219
// XXX return a random sample instead of everything?
220220
self.healthy
221221
.read()
222-
.unwrap()
222+
.unwrap_or_else(|e| e.into_inner())
223223
.iter()
224224
.map(|(addr, server)| {
225225
ServerEntry(addr.clone(), server.hostname.clone(), server.feature_strs())
@@ -234,14 +234,19 @@ impl DiscoveryManager {
234234
/// Run the next health check in the queue (a single one)
235235
fn run_health_check(&self) -> Result<()> {
236236
// abort if there are no entries in the queue, or its still too early for the next one up
237-
if self.queue.read().unwrap().peek().map_or(true, |next| {
237+
if self.queue.read().unwrap_or_else(|e| e.into_inner()).peek().map_or(true, |next| {
238238
next.last_check
239239
.map_or(false, |t| t.elapsed() < HEALTH_CHECK_FREQ)
240240
}) {
241241
return Ok(());
242242
}
243243

244-
let mut job = self.queue.write().unwrap().pop().unwrap();
244+
// Only spawn_jobs_thread calls pop(), so this is the sole consumer; the
245+
// let-else guards against the (currently unreachable) race where the queue
246+
// drains between the peek above and this pop.
247+
let Some(mut job) = self.queue.write().unwrap_or_else(|e| e.into_inner()).pop() else {
248+
return Ok(());
249+
};
245250
debug!("processing {:?}", job);
246251

247252
let was_healthy = job.is_healthy();
@@ -259,7 +264,7 @@ impl DiscoveryManager {
259264
job.last_healthy = job.last_check;
260265
job.consecutive_failures = 0;
261266
// schedule the next health check
262-
self.queue.write().unwrap().push(job);
267+
self.queue.write().unwrap_or_else(|e| e.into_inner()).push(job);
263268

264269
Ok(())
265270
}
@@ -275,7 +280,7 @@ impl DiscoveryManager {
275280
job.consecutive_failures += 1;
276281

277282
if job.should_retry() {
278-
self.queue.write().unwrap().push(job);
283+
self.queue.write().unwrap_or_else(|e| e.into_inner()).push(job);
279284
} else {
280285
debug!("giving up on {:?}", job);
281286
}
@@ -288,7 +293,11 @@ impl DiscoveryManager {
288293
/// Upsert the server/service into the healthy set
289294
fn save_healthy_service(&self, job: &HealthCheck, features: ServerFeatures) {
290295
let addr = job.addr.clone();
291-
let mut healthy = self.healthy.write().unwrap();
296+
debug!(
297+
"saving healthy service hostname='{}' addr='{}' service={:?}",
298+
job.hostname, addr, job.service
299+
);
300+
let mut healthy = self.healthy.write().unwrap_or_else(|e| e.into_inner());
292301
healthy
293302
.entry(addr)
294303
.or_insert_with(|| Server::new(job.hostname.clone(), features))
@@ -299,16 +308,29 @@ impl DiscoveryManager {
299308
/// Remove the service, and remove the server entirely if it has no other reamining healthy services
300309
fn remove_unhealthy_service(&self, job: &HealthCheck) {
301310
let addr = job.addr.clone();
302-
let mut healthy = self.healthy.write().unwrap();
311+
debug!(
312+
"removing unhealthy service hostname='{}' addr='{}' service={:?}",
313+
job.hostname, addr, job.service
314+
);
315+
let mut healthy = self.healthy.write().unwrap_or_else(|e| e.into_inner());
303316
if let Entry::Occupied(mut entry) = healthy.entry(addr) {
304317
let server = entry.get_mut();
305-
assert!(server.services.remove(&job.service));
318+
if !server.services.remove(&job.service) {
319+
warn!(
320+
"service={:?} hostname='{}' missing from healthy set, corrupted state",
321+
job.service,
322+
job.hostname
323+
);
324+
}
306325
if server.services.is_empty() {
307326
entry.remove_entry();
308327
}
309328
} else {
310-
// FIXME This was an unreachable but it was reached.
311-
log::warn!("missing expected server, corrupted state");
329+
warn!(
330+
"hostname='{}' addr='{}' missing from healthy map, corrupted state",
331+
job.hostname,
332+
job.addr
333+
);
312334
}
313335
}
314336

@@ -376,9 +398,28 @@ impl DiscoveryManager {
376398
}
377399

378400
pub fn spawn_jobs_thread(manager: Arc<DiscoveryManager>) {
401+
// Two-layer panic hardening:
402+
// 1. catch_unwind prevents a panic in any single iteration from killing this thread.
403+
// 2. All RwLock accesses use unwrap_or_else(|e| e.into_inner()) so that if a panic
404+
// fires while a lock is held (poisoning it), the next iteration recovers the inner
405+
// value and continues rather than dying on the poisoned lock. The recovered state
406+
// may be partially-modified, but for best-effort peer discovery that is acceptable.
407+
// These two defenses are co-dependent: catch_unwind prevents thread death;
408+
// unwrap_or_else prevents the *next* iteration from dying due to the poisoned lock
409+
// left behind by the previous panic.
379410
spawn_thread("discovery-jobs", move || loop {
380-
if let Err(e) = manager.run_health_check() {
381-
debug!("health check failed: {:?}", e);
411+
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
412+
if let Err(e) = manager.run_health_check() {
413+
debug!("health check failed: {:?}", e);
414+
}
415+
}));
416+
if let Err(panic) = result {
417+
let msg = panic
418+
.downcast_ref::<&str>()
419+
.copied()
420+
.or_else(|| panic.downcast_ref::<String>().map(String::as_str))
421+
.unwrap_or("unknown payload");
422+
error!("discovery-jobs panicked reason='{}', continuing after delay", msg);
382423
}
383424
// XXX use a dynamic JOB_INTERVAL, adjusted according to the queue size and HEALTH_CHECK_FREQ?
384425
thread::sleep(JOB_INTERVAL);

0 commit comments

Comments
 (0)