mirror of
https://github.com/logos-co/scaffold.git
synced 2026-08-27 12:41:13 +00:00
fix(localnet): confirm sequencer exit before deleting state file (#143)
This commit is contained in:
@@ -271,7 +271,38 @@ fn cmd_localnet_stop(state_path: &Path, localnet_port: u16) -> DynResult<()> {
|
||||
if let Some(pid) = report.tracked_pid {
|
||||
if report.tracked_running {
|
||||
println!("$ kill {pid} # sequencer");
|
||||
let _ = Command::new("kill").arg(pid.to_string()).status();
|
||||
let kill_output = Command::new("kill")
|
||||
.arg(pid.to_string())
|
||||
.output()
|
||||
.context("failed to spawn `kill`")?;
|
||||
let kill_succeeded = kill_output.status.success();
|
||||
|
||||
// Wait for the process to actually exit, regardless of `kill`'s
|
||||
// exit code: TERM may have been delivered even if `kill` returned
|
||||
// non-zero (race with reaping). If the process is still alive
|
||||
// after the deadline, do not remove the state file — we still
|
||||
// legitimately track this pid.
|
||||
let pid_timeout = Duration::from_secs(5);
|
||||
if !wait_for_pid_exit(pid, pid_timeout) {
|
||||
if !kill_succeeded {
|
||||
let stderr = String::from_utf8_lossy(&kill_output.stderr)
|
||||
.trim()
|
||||
.to_string();
|
||||
return Err(LocalnetError::StopKillFailed { pid, stderr }.into());
|
||||
}
|
||||
return Err(LocalnetError::StopTimeout {
|
||||
pid,
|
||||
timeout_sec: pid_timeout.as_secs(),
|
||||
}
|
||||
.into());
|
||||
}
|
||||
|
||||
// Process exited; wait briefly for the bound socket to be released
|
||||
// so an immediate `localnet start` doesn't race the still-bound
|
||||
// port. A timeout here is not fatal — the port may be held by a
|
||||
// foreign listener that survived our sequencer, and `localnet
|
||||
// start` will surface that with a more specific error.
|
||||
let _ = wait_for_port_free(&localnet_addr, Duration::from_secs(5));
|
||||
} else {
|
||||
println!("sequencer state is stale (pid={pid} not running)");
|
||||
}
|
||||
@@ -574,6 +605,21 @@ fn remove_file_if_exists(path: &Path, label: &str) -> DynResult<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Poll `pid` until it is no longer running (exited or zombie), or `timeout`
|
||||
/// elapses. Returns `true` if the process exited within the deadline.
|
||||
fn wait_for_pid_exit(pid: u32, timeout: Duration) -> bool {
|
||||
let deadline = Instant::now() + timeout;
|
||||
loop {
|
||||
if !pid_running(pid) {
|
||||
return true;
|
||||
}
|
||||
if Instant::now() >= deadline {
|
||||
return false;
|
||||
}
|
||||
thread::sleep(Duration::from_millis(50));
|
||||
}
|
||||
}
|
||||
|
||||
/// Poll `localnet_addr` until no listener is accepting, or `timeout` elapses.
|
||||
fn wait_for_port_free(localnet_addr: &str, timeout: Duration) -> Result<(), ()> {
|
||||
let deadline = Instant::now() + timeout;
|
||||
@@ -629,7 +675,7 @@ mod tests {
|
||||
use std::net::TcpListener;
|
||||
use std::time::Duration;
|
||||
|
||||
use super::{reset_cleanup, verify_block_production, wait_for_port_free};
|
||||
use super::{reset_cleanup, verify_block_production, wait_for_pid_exit, wait_for_port_free};
|
||||
use crate::commands::wallet_support::wallet_state_path;
|
||||
use crate::error::ResetError;
|
||||
use crate::model::{
|
||||
@@ -762,6 +808,38 @@ mod tests {
|
||||
assert!(err.is_err(), "expected timeout while listener was open");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wait_for_pid_exit_returns_true_after_process_dies() {
|
||||
// Spawn a long sleep, then SIGKILL it. wait_for_pid_exit must observe
|
||||
// the exit within the timeout — this is the success path that
|
||||
// `cmd_localnet_stop` relies on before deleting the state file.
|
||||
let mut child = std::process::Command::new("sleep")
|
||||
.arg("10")
|
||||
.spawn()
|
||||
.expect("spawn sleep");
|
||||
let pid = child.id();
|
||||
child.kill().expect("kill sleep");
|
||||
let exited = wait_for_pid_exit(pid, Duration::from_secs(2));
|
||||
let _ = child.wait();
|
||||
assert!(exited, "expected pid {pid} to exit within 2s after SIGKILL");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wait_for_pid_exit_returns_false_when_pid_stays_alive() {
|
||||
// While the child is still running, wait_for_pid_exit must time out
|
||||
// rather than report success — this is the failure path that
|
||||
// `cmd_localnet_stop` relies on to preserve the state file.
|
||||
let mut child = std::process::Command::new("sleep")
|
||||
.arg("10")
|
||||
.spawn()
|
||||
.expect("spawn sleep");
|
||||
let pid = child.id();
|
||||
let exited = wait_for_pid_exit(pid, Duration::from_millis(200));
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
assert!(!exited, "expected timeout while pid {pid} was still alive");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn verify_block_production_times_out_with_bounded_timeout() {
|
||||
// Poll a port nothing is listening on; verification should exit after
|
||||
|
||||
@@ -14,6 +14,19 @@ pub(crate) enum LocalnetError {
|
||||
pid: u32,
|
||||
log_tail: String,
|
||||
},
|
||||
|
||||
#[error(
|
||||
"failed to send TERM to sequencer (pid={pid}): {stderr}\n\
|
||||
localnet state file preserved; resolve the kill failure manually and retry."
|
||||
)]
|
||||
StopKillFailed { pid: u32, stderr: String },
|
||||
|
||||
#[error(
|
||||
"sequencer did not exit within {timeout_sec}s of TERM (pid={pid}).\n\
|
||||
localnet state file preserved; the process may be ignoring TERM. \
|
||||
Try `kill -9 {pid}` and retry `logos-scaffold localnet stop`."
|
||||
)]
|
||||
StopTimeout { pid: u32, timeout_sec: u64 },
|
||||
}
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
|
||||
Reference in New Issue
Block a user