Description
In both schedulers, the cleanup call tasks.write().unwrap().remove(&id) is located at the very end of the loop, only reachable via a normal break. If task.run() (or .await) panics, the thread/future unwinds
without ever reaching remove. The entry stays in the map forever with status = Running.
There is no catch_unwind, no Drop guard, and no panic hook — panics in user task code are completely unhandled.
Affected files
- src/scheduler/async_scheduler.rs — line 87 (task.run().await) and line 97 (remove)
- src/scheduler/sync_scheduler.rs — line 87 (task.run()) and line 97 (remove)
Impact
- Ghost task appears permanently in scheduler.get() with status = Running.
- scheduler.schedule() for the same task returns Err(AlreadyScheduled) forever.
- Memory is leaked.
- The panic is silently swallowed with no error surfaced to the user.
Fix
Async: Wrap the loop in std::panic::catch_unwind using AssertUnwindSafe, or use a deferred cleanup via a guard type:
struct RemoveOnDrop { id: TaskId, tasks: Arc<RwLock<HashMap<TaskId, AsyncTask>>> }
impl Drop for RemoveOnDrop {
fn drop(&mut self) { self.tasks.write().unwrap().remove(&self.id); }
}
Sync: Same RemoveOnDrop guard pattern — the Drop runs on both normal exit and panic unwind.
Consider also propagating the panic info via tracing::error! so users are aware their task failed.
Description
In both schedulers, the cleanup call tasks.write().unwrap().remove(&id) is located at the very end of the loop, only reachable via a normal break. If task.run() (or .await) panics, the thread/future unwinds
without ever reaching remove. The entry stays in the map forever with status = Running.
There is no catch_unwind, no Drop guard, and no panic hook — panics in user task code are completely unhandled.
Affected files
Impact
Fix
Async: Wrap the loop in std::panic::catch_unwind using AssertUnwindSafe, or use a deferred cleanup via a guard type:
struct RemoveOnDrop { id: TaskId, tasks: Arc<RwLock<HashMap<TaskId, AsyncTask>>> }
impl Drop for RemoveOnDrop {
fn drop(&mut self) { self.tasks.write().unwrap().remove(&self.id); }
}
Sync: Same RemoveOnDrop guard pattern — the Drop runs on both normal exit and panic unwind.
Consider also propagating the panic info via tracing::error! so users are aware their task failed.