Skip to content

Commit 6bec14a

Browse files
authored
consensus: map spawn_blocking cancellation during shutdown to error instead of panicking (#27513)
## Description During the 2026-07-14 mainnet rolling restart, a validator panicked on shutdown at the synchronizer's `.expect("Spawn blocking should not fail")`: tokio cancels `spawn_blocking` tasks when the runtime shuts down, so the JoinHandle resolves to `JoinError::Cancelled` and the panic alert pages on an otherwise clean restart. Consensus has four such call sites. Adds a `task::spawn_blocking` wrapper that resolves to `ConsensusError::Shutdown` on cancellation and resumes real panics on the calling thread, and uses it at all four sites. `Shutdown` is already handled quietly by callers as the established signal for components going away mid-request. ## Test plan New unit tests in `task.rs` cover the wrapper contract: a `spawn_blocking` cancelled by runtime shutdown maps to `ConsensusError::Shutdown`, and panics from the closure still propagate. --- ## Release notes Check each box that your changes affect. If none of the boxes relate to your changes, release notes aren't required. For each box you select, include information after the relevant heading that describes the impact of your changes that a user might notice and any actions they must take to implement updates. - [ ] Protocol: - [ ] Nodes (Validators and Full nodes): - [ ] gRPC: - [ ] JSON-RPC: - [ ] GraphQL: - [ ] CLI: - [ ] Rust SDK: - [ ] Indexing Framework:
1 parent f144de0 commit 6bec14a

5 files changed

Lines changed: 107 additions & 55 deletions

File tree

consensus/core/src/authority_service.rs

Lines changed: 13 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ use crate::{
2929
network::{BlockStream, ExtendedSerializedBlock, PeerId, ValidatorNetworkService},
3030
round_tracker::RoundTracker,
3131
synchronizer::SynchronizerHandle,
32+
task::spawn_blocking,
3233
transaction_vote_tracker::TransactionVoteTracker,
3334
};
3435

@@ -176,20 +177,18 @@ impl<C: CoreThreadDispatcher> ValidatorNetworkService for AuthorityService<C> {
176177
// Reject blocks failing parsing and validations.
177178
let block_verifier = self.block_verifier.clone();
178179
let serialized = serialized_block.block.clone();
179-
let (verified_block, reject_txn_votes) = tokio::task::spawn_blocking(move || {
180-
block_verifier.verify_and_vote(signed_block, serialized)
181-
})
182-
.await
183-
.expect("verify_and_vote blocking task panicked")
184-
.tap_err(|e| {
185-
self.context
186-
.metrics
187-
.node_metrics
188-
.invalid_blocks
189-
.with_label_values(&[peer_hostname.as_str(), "handle_send_block", e.name()])
190-
.inc();
191-
info!("Invalid block from {}: {}", peer, e);
192-
})?;
180+
let (verified_block, reject_txn_votes) =
181+
spawn_blocking(move || block_verifier.verify_and_vote(signed_block, serialized))
182+
.await?
183+
.tap_err(|e| {
184+
self.context
185+
.metrics
186+
.node_metrics
187+
.invalid_blocks
188+
.with_label_values(&[peer_hostname.as_str(), "handle_send_block", e.name()])
189+
.inc();
190+
info!("Invalid block from {}: {}", peer, e);
191+
})?;
193192
let excluded_ancestors = self
194193
.parse_excluded_ancestors(peer, &verified_block, serialized_block.excluded_ancestors)
195194
.tap_err(|e| {

consensus/core/src/commit_observer.rs

Lines changed: 12 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ use crate::{
1717
error::ConsensusResult,
1818
linearizer::Linearizer,
1919
storage::Store,
20+
task::spawn_blocking,
2021
transaction_vote_tracker::TransactionVoteTracker,
2122
};
2223

@@ -73,16 +74,17 @@ impl CommitObserver {
7374
// Recover blocks needed for future commits (and block proposals).
7475
// Some blocks might have been recovered as committed blocks in recover_and_send_commits().
7576
// They will just be ignored.
76-
tokio::runtime::Handle::current()
77-
.spawn_blocking({
78-
let transaction_vote_tracker = observer.transaction_vote_tracker.clone();
79-
let gc_round = observer.dag_state.read().gc_round();
80-
move || {
81-
transaction_vote_tracker.recover_blocks_after_round(gc_round);
82-
}
83-
})
84-
.await
85-
.expect("Spawn blocking should not fail");
77+
if let Err(e) = spawn_blocking({
78+
let transaction_vote_tracker = observer.transaction_vote_tracker.clone();
79+
let gc_round = observer.dag_state.read().gc_round();
80+
move || {
81+
transaction_vote_tracker.recover_blocks_after_round(gc_round);
82+
}
83+
})
84+
.await
85+
{
86+
info!("Skipping block recovery for transaction voting: {e}");
87+
}
8688

8789
observer
8890
}

consensus/core/src/commit_syncer.rs

Lines changed: 17 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,6 @@ use mysten_metrics::spawn_logged_monitored_task;
3939
use parking_lot::RwLock;
4040
use rand::{prelude::SliceRandom as _, rngs::ThreadRng};
4141
use tokio::{
42-
runtime::Handle,
4342
sync::oneshot,
4443
task::{JoinHandle, JoinSet},
4544
time::{MissedTickBehavior, sleep},
@@ -63,7 +62,7 @@ use crate::{
6362
peers_pool::PeersPool,
6463
round_tracker::RoundTracker,
6564
stake_aggregator::{QuorumThreshold, StakeAggregator},
66-
task::{join_and_propagate_panic, shutdown_join_set},
65+
task::{join_and_propagate_panic, shutdown_join_set, spawn_blocking},
6766
transaction_vote_tracker::TransactionVoteTracker,
6867
};
6968

@@ -570,24 +569,22 @@ where
570569
// 2. Verify the response contains blocks that can certify the last returned commit,
571570
// and the returned commits are chained by digests, so earlier commits are certified
572571
// as well.
573-
let (commits, commit_certifying_blocks_and_votes) = Handle::current()
574-
.spawn_blocking({
575-
let context = inner.context.clone();
576-
let block_verifier = inner.block_verifier.clone();
577-
let peer = target_peer.clone();
578-
move || {
579-
Inner::<VC, OC>::verify_commits(
580-
&context,
581-
block_verifier.as_ref(),
582-
peer,
583-
commit_range,
584-
serialized_commits,
585-
serialized_blocks,
586-
)
587-
}
588-
})
589-
.await
590-
.expect("Spawn blocking should not fail")?;
572+
let (commits, commit_certifying_blocks_and_votes) = spawn_blocking({
573+
let context = inner.context.clone();
574+
let block_verifier = inner.block_verifier.clone();
575+
let peer = target_peer.clone();
576+
move || {
577+
Inner::<VC, OC>::verify_commits(
578+
&context,
579+
block_verifier.as_ref(),
580+
peer,
581+
commit_range,
582+
serialized_commits,
583+
serialized_blocks,
584+
)
585+
}
586+
})
587+
.await??;
591588

592589
// Only the vote tracker needs the reject votes, so move them into it without cloning.
593590
// Cheap clones of the blocks are enough for the rest of the fetch handling.

consensus/core/src/synchronizer.rs

Lines changed: 8 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,6 @@ use rand::{prelude::SliceRandom as _, rngs::ThreadRng};
2222
use sui_macros::fail_point_async;
2323
use tap::TapFallible;
2424
use tokio::{
25-
runtime::Handle,
2625
sync::{mpsc::error::TrySendError, oneshot},
2726
task::JoinSet,
2827
time::{Instant, sleep, sleep_until, timeout},
@@ -41,7 +40,7 @@ use crate::{
4140
network::{ObserverNetworkClient, PeerId, SynchronizerClient, ValidatorNetworkClient},
4241
peers_pool::PeersPool,
4342
round_tracker::RoundTracker,
44-
task::shutdown_join_set,
43+
task::{shutdown_join_set, spawn_blocking},
4544
};
4645
use crate::{core_thread::CoreThreadDispatcher, transaction_vote_tracker::TransactionVoteTracker};
4746

@@ -592,15 +591,13 @@ where
592591
serialized_blocks.truncate(context.parameters.max_blocks_per_sync);
593592

594593
// Verify all the fetched blocks
595-
let (blocks, voted_blocks) = Handle::current()
596-
.spawn_blocking({
597-
let block_verifier = block_verifier.clone();
598-
let context = context.clone();
599-
let peer = peer.clone();
600-
move || Self::verify_blocks(serialized_blocks, block_verifier, &context, peer)
601-
})
602-
.await
603-
.expect("Spawn blocking should not fail")?;
594+
let (blocks, voted_blocks) = spawn_blocking({
595+
let block_verifier = block_verifier.clone();
596+
let context = context.clone();
597+
let peer = peer.clone();
598+
move || Self::verify_blocks(serialized_blocks, block_verifier, &context, peer)
599+
})
600+
.await??;
604601

605602
if context.protocol_config.transaction_voting_enabled() {
606603
transaction_vote_tracker.add_voted_blocks(voted_blocks);

consensus/core/src/task.rs

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44
use futures::FutureExt as _;
55
use tokio::task::{JoinHandle, JoinSet};
66

7+
use crate::error::{ConsensusError, ConsensusResult};
8+
79
/// Awaits the task and propagates its panic if it panicked. Task cancellation is ignored.
810
pub(crate) async fn join_and_propagate_panic<T>(task: JoinHandle<T>) {
911
if let Err(error) = task.await
@@ -47,3 +49,58 @@ pub(crate) fn reap_finished_task(task: &mut JoinHandle<()>) -> bool {
4749
}
4850
true
4951
}
52+
53+
/// Runs the closure on the blocking pool, like tokio::task::spawn_blocking, but resolves
54+
/// to ConsensusError::Shutdown instead of a JoinError when the task is cancelled by
55+
/// runtime shutdown. Panics from the closure are resumed on the calling thread.
56+
pub(crate) async fn spawn_blocking<F, T>(f: F) -> ConsensusResult<T>
57+
where
58+
F: FnOnce() -> T + Send + 'static,
59+
T: Send + 'static,
60+
{
61+
tokio::task::spawn_blocking(f)
62+
.await
63+
.map_err(spawn_blocking_join_error)
64+
}
65+
66+
/// Converts a JoinError from a spawn_blocking task into a ConsensusError.
67+
/// Panics from the blocking task are resumed on the calling thread. Otherwise the task
68+
/// was cancelled, which only happens when the runtime is shutting down.
69+
fn spawn_blocking_join_error(e: tokio::task::JoinError) -> ConsensusError {
70+
if e.is_panic() {
71+
std::panic::resume_unwind(e.into_panic());
72+
}
73+
ConsensusError::Shutdown
74+
}
75+
76+
#[cfg(test)]
77+
mod test {
78+
use super::*;
79+
80+
/// Reproduces the production scenario: spawn_blocking racing with runtime shutdown
81+
/// gets its task cancelled instead of run, so its JoinHandle resolves to
82+
/// JoinError::Cancelled.
83+
#[tokio::test]
84+
async fn test_spawn_blocking_join_error_on_runtime_shutdown() {
85+
let runtime = tokio::runtime::Builder::new_multi_thread()
86+
.worker_threads(1)
87+
.build()
88+
.unwrap();
89+
let handle = runtime.handle().clone();
90+
runtime.shutdown_background();
91+
92+
let cancelled = handle.spawn_blocking(|| ());
93+
let join_error = cancelled.await.unwrap_err();
94+
assert!(join_error.is_cancelled());
95+
assert!(matches!(
96+
spawn_blocking_join_error(join_error),
97+
ConsensusError::Shutdown
98+
));
99+
}
100+
101+
#[tokio::test]
102+
#[should_panic(expected = "boom")]
103+
async fn test_spawn_blocking_resumes_panic() {
104+
let _ = spawn_blocking(|| panic!("boom")).await;
105+
}
106+
}

0 commit comments

Comments
 (0)