Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 52 additions & 0 deletions node/src/miner_server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,16 @@ impl MinerServer {
}
}

/// Clear the stored job so miners connecting while mining is paused (major
/// sync, no peers) don't receive stale work on connect. Already-connected
/// miners keep grinding their last job — the protocol has no cancel
/// message — until the next broadcast supersedes it.
pub async fn clear_current_job(&self) {
if self.current_job.write().await.take().is_some() {
log::debug!("Cleared pending miner job while mining is paused");
}
}

/// Wait for a mining result with a timeout.
pub async fn recv_result_timeout(&self, timeout: Duration) -> Option<MiningResult> {
let mut rx = self.result_rx.lock().await;
Expand Down Expand Up @@ -931,6 +941,48 @@ mod tests {
assert_eq!(drops, 0, "counter must be reset by the last successful forward");
}

fn test_server() -> MinerServer {
let (result_tx, result_rx) = mpsc::channel::<MiningResult>(64);
MinerServer {
miners: Arc::new(RwLock::new(HashMap::new())),
result_rx: tokio::sync::Mutex::new(result_rx),
result_tx,
current_job: Arc::new(RwLock::new(None)),
next_miner_id: AtomicU64::new(1),
auth_token: "token".to_string(),
unauth_slots: Arc::new(Semaphore::new(MAX_UNAUTHENTICATED_CONNECTIONS)),
}
}

fn dummy_job(job_id: &str) -> MiningRequest {
MiningRequest {
job_id: job_id.to_string(),
mining_hash: "00".repeat(32),
difficulty: "1".to_string(),
}
}

/// A job broadcast before a sync/offline pause must not be handed to miners
/// that connect during the pause: with no cancel message in the protocol
/// they would grind the stale job for the entire sync.
#[tokio::test]
async fn clearing_the_current_job_stops_serving_it_to_new_miners() {
let server = test_server();
server.broadcast_job(dummy_job("1")).await;
assert!(server.get_current_job().await.is_some());

server.clear_current_job().await;
assert!(
server.get_current_job().await.is_none(),
"miners connecting during a pause must not receive the pre-pause job"
);

// Idempotent, and the next broadcast serves fresh work again.
server.clear_current_job().await;
server.broadcast_job(dummy_job("2")).await;
assert_eq!(server.get_current_job().await.unwrap().job_id, "2");
}

fn temp_token_path(name: &str) -> PathBuf {
static COUNTER: AtomicU64 = AtomicU64::new(0);
let n = COUNTER.fetch_add(1, AtomicOrdering::Relaxed);
Expand Down
50 changes: 17 additions & 33 deletions node/src/service.rs
Original file line number Diff line number Diff line change
Expand Up @@ -346,56 +346,40 @@ async fn mining_loop(
let mut mining_start_time = std::time::Instant::now();
let mut job_counter: u64 = 0;

// Track when we first detected offline status for grace period
let mut offline_since: Option<std::time::Instant> = None;
const OFFLINE_GRACE_PERIOD: Duration = Duration::from_secs(30);

loop {
if cancellation_token.is_cancelled() {
log::info!("⛏️ QPoW Mining task shutting down gracefully");
break;
}

// Don't mine if we're still syncing
// Don't mine if we're still syncing. Also drop the stored job so miners
// connecting during the sync don't receive stale work on connect —
// the protocol has no cancel message, so they would grind it until the
// post-sync broadcast supersedes it.
if sync_service.is_major_syncing() {
log::debug!(target: "pow", "Mining paused: node is still syncing with network");
if let Some(ref server) = miner_server {
server.clear_current_job().await;
}
tokio::select! {
_ = tokio::time::sleep(Duration::from_secs(5)) => {}
_ = cancellation_token.cancelled() => continue
}
continue;
}

// Don't mine if we have no peers (unless --dev or --force-authoring)
// Use a grace period to handle brief network hiccups
// Don't mine if we have no peers (unless --dev or --force-authoring).
// This must pause immediately, without a grace period: at startup
// `is_major_syncing()` is still false before the first peers connect,
// so any grace window hands external miners a job built on a stale
// local best block, which they then grind for the entire sync.
if !allow_mining_without_peers && sync_service.is_offline() {
let now = std::time::Instant::now();
match offline_since {
None => {
// First time detecting offline, start grace period
offline_since = Some(now);
log::debug!(target: "pow", "No peers detected, starting {}s grace period before pausing mining", OFFLINE_GRACE_PERIOD.as_secs());
},
Some(since) if now.duration_since(since) >= OFFLINE_GRACE_PERIOD => {
// Grace period exceeded, pause mining
log::warn!(target: "pow", "Mining paused: no connected peers for {}s (node is offline)", OFFLINE_GRACE_PERIOD.as_secs());
tokio::select! {
_ = tokio::time::sleep(Duration::from_secs(5)) => {}
_ = cancellation_token.cancelled() => continue
}
continue;
},
Some(_) => {
// Still within grace period, continue mining but log
log::debug!(target: "pow", "No peers but still within grace period, continuing mining");
},
}
} else {
// We have peers (or are in dev mode), reset offline tracking
if offline_since.is_some() {
log::info!(target: "pow", "Peers reconnected, resuming normal mining");
log::info!(target: "pow", "Mining paused: waiting for peers");
tokio::select! {
_ = tokio::time::sleep(Duration::from_secs(5)) => {}
_ = cancellation_token.cancelled() => continue
}
offline_since = None;
continue;
}

// Wait for mining metadata to be available. We are past the sync check,
Expand Down
Loading