1use anyhow::{anyhow, Result};
5use std::time::Duration;
6
7#[derive(Debug, Clone, Copy, PartialEq, Eq)]
8pub struct LocalCoreInfo {
9 pub stacks_tip_height: u64,
10 pub burn_block_height: u64,
11}
12
13#[derive(Debug, Clone, Copy, PartialEq, Eq)]
14pub enum RecoveryLevel {
15 StacksOnly,
17 WithBitcoin,
19 Full,
21}
22
23impl RecoveryLevel {
24 pub fn label(self) -> &'static str {
25 match self {
26 Self::StacksOnly => "stacks-node + signer",
27 Self::WithBitcoin => "bitcoin-node + stacks-node + signer",
28 Self::Full => "full devnet stack",
29 }
30 }
31}
32
33pub fn is_devnet_chain_stalled(previous: &LocalCoreInfo, current: &LocalCoreInfo) -> bool {
35 current.stacks_tip_height == previous.stacks_tip_height
36 && current.burn_block_height > previous.burn_block_height
37}
38
39pub fn devnet_project_name() -> Option<String> {
40 let raw = std::fs::read_to_string("contracts/Clarinet.toml").ok()?;
41 for line in raw.lines() {
42 let trimmed = line.trim();
43 if trimmed.starts_with("name = ") {
44 return trimmed
45 .trim_start_matches("name = ")
46 .trim()
47 .trim_matches('"')
48 .to_string()
49 .into();
50 }
51 }
52 None
53}
54
55pub async fn fetch_local_core_info_optional() -> Option<LocalCoreInfo> {
56 let client = reqwest::Client::builder()
57 .timeout(Duration::from_secs(3))
58 .build()
59 .ok()?;
60 let response = client
61 .get("http://localhost:20443/v2/info")
62 .send()
63 .await
64 .ok()?;
65 if !response.status().is_success() {
66 return None;
67 }
68 let json: serde_json::Value = response.json().await.ok()?;
69 Some(LocalCoreInfo {
70 stacks_tip_height: json.get("stacks_tip_height")?.as_u64()?,
71 burn_block_height: json.get("burn_block_height")?.as_u64()?,
72 })
73}
74
75fn docker_restart_container(name: &str) -> bool {
76 let id = docker_container_id(name);
77 let Some(id) = id else {
78 return false;
79 };
80 std::process::Command::new("docker")
81 .args(["restart", &id])
82 .stdout(std::process::Stdio::null())
83 .stderr(std::process::Stdio::null())
84 .status()
85 .map(|s| s.success())
86 .unwrap_or(false)
87}
88
89fn docker_container_id(name: &str) -> Option<String> {
90 std::process::Command::new("docker")
91 .args(["ps", "-q", "--filter", &format!("name={name}")])
92 .output()
93 .ok()
94 .filter(|o| o.status.success())
95 .and_then(|o| String::from_utf8(o.stdout).ok())
96 .map(|s| s.trim().lines().next().unwrap_or("").to_string())
97 .filter(|s| !s.is_empty())
98}
99
100fn docker_stop_container(name: &str) -> bool {
101 let Some(id) = docker_container_id(name) else {
102 return false;
103 };
104 std::process::Command::new("docker")
105 .args(["stop", &id])
106 .stdout(std::process::Stdio::null())
107 .stderr(std::process::Stdio::null())
108 .status()
109 .map(|s| s.success())
110 .unwrap_or(false)
111}
112
113fn docker_start_container(name: &str) -> bool {
114 let id = std::process::Command::new("docker")
115 .args(["ps", "-aq", "--filter", &format!("name={name}")])
116 .output()
117 .ok()
118 .filter(|o| o.status.success())
119 .and_then(|o| String::from_utf8(o.stdout).ok())
120 .map(|s| s.trim().lines().next().unwrap_or("").to_string())
121 .filter(|s| !s.is_empty());
122 let Some(id) = id else {
123 return false;
124 };
125 std::process::Command::new("docker")
126 .args(["start", &id])
127 .stdout(std::process::Stdio::null())
128 .stderr(std::process::Stdio::null())
129 .status()
130 .map(|s| s.success())
131 .unwrap_or(false)
132}
133
134pub fn stop_devnet_containers(project: &str) -> bool {
136 let names = [
137 format!("stacks-signer-0.{project}.devnet"),
138 format!("stacks-node.{project}.devnet"),
139 format!("postgres.{project}.devnet"),
140 format!("bitcoin-node.{project}.devnet"),
141 ];
142 let mut any = false;
143 for name in &names {
144 if docker_stop_container(name) {
145 any = true;
146 }
147 }
148 any
149}
150pub fn try_recover_devnet_chain(project: &str, level: RecoveryLevel) -> bool {
152 let stacks = [
153 format!("stacks-signer-0.{project}.devnet"),
154 format!("stacks-node.{project}.devnet"),
155 ];
156 let bitcoin = format!("bitcoin-node.{project}.devnet");
157 let postgres = format!("postgres.{project}.devnet");
158
159 let mut any = false;
160 match level {
161 RecoveryLevel::StacksOnly => {
162 for name in &stacks {
163 if docker_restart_container(name) {
164 any = true;
165 }
166 }
167 }
168 RecoveryLevel::WithBitcoin => {
169 if docker_restart_container(&bitcoin) {
170 any = true;
171 }
172 std::thread::sleep(Duration::from_secs(5));
173 for name in &stacks {
174 if docker_restart_container(name) {
175 any = true;
176 }
177 }
178 }
179 RecoveryLevel::Full => {
180 for name in [&stacks[0], &stacks[1], &postgres, &bitcoin] {
183 docker_stop_container(name);
184 }
185 std::thread::sleep(Duration::from_secs(3));
186 for name in [&bitcoin, &postgres, &stacks[1], &stacks[0]] {
187 if docker_start_container(name) {
188 any = true;
189 }
190 }
191 }
192 }
193 any
194}
195
196async fn wait_for_stacks_tip_above(min_exclusive: u64, timeout: Duration) -> bool {
197 let deadline = tokio::time::Instant::now() + timeout;
198 while tokio::time::Instant::now() < deadline {
199 if let Some(info) = fetch_local_core_info_optional().await {
200 if info.stacks_tip_height > min_exclusive {
201 return true;
202 }
203 }
204 tokio::time::sleep(Duration::from_secs(3)).await;
205 }
206 false
207}
208
209pub async fn ensure_devnet_chain_mining(context: &str) -> Result<()> {
212 let project = devnet_project_name().ok_or_else(|| {
213 anyhow!(
214 "Cannot recover devnet: missing contracts/Clarinet.toml project name.\n\
215 Run deploy from a scaffold project root with `stacksdapp dev` running."
216 )
217 })?;
218
219 let first = fetch_local_core_info_optional()
220 .await
221 .ok_or_else(|| anyhow!("Local stacks-node at http://localhost:20443 is not responding."))?;
222
223 tokio::time::sleep(Duration::from_secs(8)).await;
225 if let Some(second) = fetch_local_core_info_optional().await {
226 if second.stacks_tip_height > first.stacks_tip_height {
227 return Ok(());
228 }
229 if !is_devnet_chain_stalled(&first, &second)
230 && wait_for_stacks_tip_above(first.stacks_tip_height, Duration::from_secs(45)).await
231 {
232 return Ok(());
233 }
234 return recover_devnet_chain_mining(&project, context, &second).await;
236 }
237
238 recover_devnet_chain_mining(&project, context, &first).await
239}
240
241async fn recover_devnet_chain_mining(
242 project: &str,
243 context: &str,
244 stalled_at: &LocalCoreInfo,
245) -> Result<()> {
246 stacksdapp_shell::println_human_safe(format!(
247 "[deploy] Devnet chain stalled (Stacks #{}, burn {}) before {context}.",
248 stalled_at.stacks_tip_height, stalled_at.burn_block_height
249 ));
250
251 let levels = [
252 RecoveryLevel::StacksOnly,
253 RecoveryLevel::WithBitcoin,
254 RecoveryLevel::Full,
255 ];
256 for (idx, level) in levels.iter().enumerate() {
257 stacksdapp_shell::println_human_safe(format!(
258 "[deploy] Recovering stalled chain (attempt {}/3): {} for {project}...",
259 idx + 1,
260 level.label()
261 ));
262 try_recover_devnet_chain(project, *level);
263 tokio::time::sleep(Duration::from_secs(12)).await;
264 if wait_for_stacks_tip_above(stalled_at.stacks_tip_height, Duration::from_secs(120)).await {
265 stacksdapp_shell::println_human_safe(
266 "[deploy] Devnet chain recovered — Stacks tip is advancing again.",
267 );
268 return Ok(());
269 }
270 }
271
272 Err(anyhow!(
273 "Devnet chain stalled at Stacks #{} (burn {}) and recovery did not restore block production.\n\
274 If `stacksdapp dev` is running, wait for \"[devnet] Clarinet devnet restarted\" then retry.\n\
275 Otherwise run `stacksdapp clean --force`, restart with `stacksdapp dev --auto-deploy`, and deploy while the tip advances.\n\
276 If this repeats, try `bitcoin_controller_block_time = 60_000` in contracts/settings/Devnet.toml.",
277 stalled_at.stacks_tip_height,
278 stalled_at.burn_block_height
279 ))
280}
281
282pub async fn recover_devnet_if_stalled_during_wait(
284 previous: &LocalCoreInfo,
285 project: Option<&str>,
286) -> bool {
287 let Some(current) = fetch_local_core_info_optional().await else {
288 return false;
289 };
290 if !is_devnet_chain_stalled(previous, ¤t) {
291 return false;
292 }
293 let Some(name) = project.map(str::to_string).or_else(devnet_project_name) else {
294 return false;
295 };
296 stacksdapp_shell::println_human_safe(format!(
297 "[deploy] Chain stalled while waiting (Stacks #{}, burn {}) — restarting devnet containers...",
298 current.stacks_tip_height, current.burn_block_height
299 ));
300 try_recover_devnet_chain(&name, RecoveryLevel::WithBitcoin)
301}
302
303#[cfg(test)]
304mod tests {
305 use super::*;
306
307 #[test]
308 fn detects_stall_when_burn_advances_one_block() {
309 let prev = LocalCoreInfo {
310 stacks_tip_height: 71,
311 burn_block_height: 176,
312 };
313 let curr = LocalCoreInfo {
314 stacks_tip_height: 71,
315 burn_block_height: 177,
316 };
317 assert!(is_devnet_chain_stalled(&prev, &curr));
318 }
319
320 #[test]
321 fn not_stalled_when_stacks_advances() {
322 let prev = LocalCoreInfo {
323 stacks_tip_height: 71,
324 burn_block_height: 176,
325 };
326 let curr = LocalCoreInfo {
327 stacks_tip_height: 72,
328 burn_block_height: 177,
329 };
330 assert!(!is_devnet_chain_stalled(&prev, &curr));
331 }
332
333 #[test]
334 fn not_stalled_when_both_unchanged() {
335 let prev = LocalCoreInfo {
336 stacks_tip_height: 71,
337 burn_block_height: 176,
338 };
339 let curr = LocalCoreInfo {
340 stacks_tip_height: 71,
341 burn_block_height: 176,
342 };
343 assert!(!is_devnet_chain_stalled(&prev, &curr));
344 }
345}