Enforce a single daemon per geth home
This commit is contained in:
parent
f61cb44dad
commit
ed007a0632
6 changed files with 90 additions and 5 deletions
|
|
@ -1721,6 +1721,8 @@ fn json_error_code(detail: &str) -> &'static str {
|
||||||
let lower = detail.to_ascii_lowercase();
|
let lower = detail.to_ascii_lowercase();
|
||||||
if lower.contains("connect to daemon") || lower.contains("connection refused") {
|
if lower.contains("connect to daemon") || lower.contains("connection refused") {
|
||||||
"daemon_unavailable"
|
"daemon_unavailable"
|
||||||
|
} else if lower.contains("daemon is already running") {
|
||||||
|
"daemon_already_running"
|
||||||
} else if lower.contains("unauthorized") || lower.contains("missing grant") {
|
} else if lower.contains("unauthorized") || lower.contains("missing grant") {
|
||||||
"unauthorized"
|
"unauthorized"
|
||||||
} else if lower.contains("peer candidate not found") {
|
} else if lower.contains("peer candidate not found") {
|
||||||
|
|
|
||||||
|
|
@ -8,11 +8,15 @@ use geth_config::{GethConfig, GethPaths, RelayMode};
|
||||||
use geth_iroh::{EndpointStatus, GethIrohConfig, GethIrohEndpoint, GethRelayMode};
|
use geth_iroh::{EndpointStatus, GethIrohConfig, GethIrohEndpoint, GethRelayMode};
|
||||||
use geth_store::Store;
|
use geth_store::Store;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
use tokio::net::UnixListener;
|
use tokio::net::{UnixListener, UnixStream};
|
||||||
use tokio::task::JoinHandle;
|
use tokio::task::JoinHandle;
|
||||||
|
|
||||||
pub async fn run_daemon(paths: GethPaths) -> Result<(), NodeError> {
|
pub async fn run_daemon(paths: GethPaths) -> Result<(), NodeError> {
|
||||||
let mut node = init_node(&paths)?;
|
let mut node = init_node(&paths)?;
|
||||||
|
let listener = bind_control_socket(&paths).await?;
|
||||||
|
let _socket_guard = ControlSocketGuard {
|
||||||
|
paths: paths.clone(),
|
||||||
|
};
|
||||||
let config = GethConfig::load(&node.paths.config_file())?;
|
let config = GethConfig::load(&node.paths.config_file())?;
|
||||||
let iroh_endpoint = start_daemon_iroh_endpoint(&mut node).await?;
|
let iroh_endpoint = start_daemon_iroh_endpoint(&mut node).await?;
|
||||||
let mut background_tasks = Vec::new();
|
let mut background_tasks = Vec::new();
|
||||||
|
|
@ -28,8 +32,6 @@ pub async fn run_daemon(paths: GethPaths) -> Result<(), NodeError> {
|
||||||
|
|
||||||
let serve_result = async {
|
let serve_result = async {
|
||||||
let _peer_card_lan_discovery = start_peer_card_lan_discovery(&node).await;
|
let _peer_card_lan_discovery = start_peer_card_lan_discovery(&node).await;
|
||||||
remove_control_socket(&paths)?;
|
|
||||||
let listener = UnixListener::bind(paths.socket_path())?;
|
|
||||||
tracing::info!(socket = %paths.socket_path().display(), "geth daemon listening");
|
tracing::info!(socket = %paths.socket_path().display(), "geth daemon listening");
|
||||||
serve_local_control(node.clone(), listener).await
|
serve_local_control(node.clone(), listener).await
|
||||||
}
|
}
|
||||||
|
|
@ -40,10 +42,38 @@ pub async fn run_daemon(paths: GethPaths) -> Result<(), NodeError> {
|
||||||
let _ = task.await;
|
let _ = task.await;
|
||||||
}
|
}
|
||||||
let shutdown_result = shutdown_iroh_runtime(&node, iroh_endpoint).await;
|
let shutdown_result = shutdown_iroh_runtime(&node, iroh_endpoint).await;
|
||||||
let socket_cleanup_result = remove_control_socket(&paths);
|
|
||||||
serve_result?;
|
serve_result?;
|
||||||
shutdown_result?;
|
shutdown_result?;
|
||||||
socket_cleanup_result
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ControlSocketGuard {
|
||||||
|
paths: GethPaths,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for ControlSocketGuard {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
if let Err(error) = remove_control_socket(&self.paths) {
|
||||||
|
tracing::warn!(%error, "failed to clean up daemon control socket");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn bind_control_socket(paths: &GethPaths) -> Result<UnixListener, NodeError> {
|
||||||
|
let socket_path = paths.socket_path();
|
||||||
|
if socket_path.exists() {
|
||||||
|
match UnixStream::connect(&socket_path).await {
|
||||||
|
Ok(_) => return Err(NodeError::DaemonAlreadyRunning(paths.home().to_path_buf())),
|
||||||
|
Err(_) => remove_control_socket(paths)?,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
match UnixListener::bind(&socket_path) {
|
||||||
|
Ok(listener) => Ok(listener),
|
||||||
|
Err(error) if error.kind() == std::io::ErrorKind::AddrInUse => {
|
||||||
|
Err(NodeError::DaemonAlreadyRunning(paths.home().to_path_buf()))
|
||||||
|
}
|
||||||
|
Err(error) => Err(error.into()),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn remove_control_socket(paths: &GethPaths) -> Result<(), NodeError> {
|
fn remove_control_socket(paths: &GethPaths) -> Result<(), NodeError> {
|
||||||
|
|
@ -307,4 +337,34 @@ mod tests {
|
||||||
.is_none()
|
.is_none()
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn control_socket_binding_rejects_a_second_daemon_and_recovers_stale_paths() {
|
||||||
|
let dir = tempfile::tempdir().expect("tempdir");
|
||||||
|
let paths = GethPaths::from_home(dir.path());
|
||||||
|
paths.ensure_base_dirs().expect("base dirs");
|
||||||
|
let probe = paths.run_dir().join("probe.sock");
|
||||||
|
match std::os::unix::net::UnixListener::bind(&probe) {
|
||||||
|
Ok(listener) => {
|
||||||
|
drop(listener);
|
||||||
|
std::fs::remove_file(probe).expect("remove socket probe");
|
||||||
|
}
|
||||||
|
Err(error) => {
|
||||||
|
eprintln!("skipping live socket assertion; Unix sockets unavailable: {error}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
std::fs::write(paths.socket_path(), b"stale").expect("stale socket fixture");
|
||||||
|
|
||||||
|
let listener = bind_control_socket(&paths)
|
||||||
|
.await
|
||||||
|
.expect("replace stale socket");
|
||||||
|
let error = bind_control_socket(&paths)
|
||||||
|
.await
|
||||||
|
.expect_err("second daemon must be rejected");
|
||||||
|
|
||||||
|
assert!(matches!(error, NodeError::DaemonAlreadyRunning(_)));
|
||||||
|
drop(listener);
|
||||||
|
remove_control_socket(&paths).expect("cleanup socket");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -195,6 +195,8 @@ pub enum NodeError {
|
||||||
OverlayRuntime(String),
|
OverlayRuntime(String),
|
||||||
#[error("unsupported platform: {0}")]
|
#[error("unsupported platform: {0}")]
|
||||||
UnsupportedPlatform(String),
|
UnsupportedPlatform(String),
|
||||||
|
#[error("geth daemon is already running for home: {0}")]
|
||||||
|
DaemonAlreadyRunning(PathBuf),
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug)]
|
||||||
|
|
@ -11141,6 +11143,12 @@ mod tests {
|
||||||
local_control::node_error_code(&NodeError::IrohEndpointUnavailable),
|
local_control::node_error_code(&NodeError::IrohEndpointUnavailable),
|
||||||
"iroh_endpoint_unavailable"
|
"iroh_endpoint_unavailable"
|
||||||
);
|
);
|
||||||
|
assert_eq!(
|
||||||
|
local_control::node_error_code(&NodeError::DaemonAlreadyRunning(PathBuf::from(
|
||||||
|
"/tmp/geth"
|
||||||
|
))),
|
||||||
|
"daemon_already_running"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, PartialEq, Eq)]
|
#[derive(Debug, PartialEq, Eq)]
|
||||||
|
|
|
||||||
|
|
@ -516,6 +516,7 @@ pub(crate) fn node_error_code(error: &NodeError) -> &'static str {
|
||||||
NodeError::Unauthorized(_) => "unauthorized",
|
NodeError::Unauthorized(_) => "unauthorized",
|
||||||
NodeError::PeerNotFound(_) => "peer_not_found",
|
NodeError::PeerNotFound(_) => "peer_not_found",
|
||||||
NodeError::IrohEndpointUnavailable => "iroh_endpoint_unavailable",
|
NodeError::IrohEndpointUnavailable => "iroh_endpoint_unavailable",
|
||||||
|
NodeError::DaemonAlreadyRunning(_) => "daemon_already_running",
|
||||||
NodeError::KvNotFound(_) => "kv_not_found",
|
NodeError::KvNotFound(_) => "kv_not_found",
|
||||||
NodeError::DbNotFound(_) => "db_not_found",
|
NodeError::DbNotFound(_) => "db_not_found",
|
||||||
NodeError::DocumentNotFound(_) => "document_not_found",
|
NodeError::DocumentNotFound(_) => "document_not_found",
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,11 @@ registries for pubsub, pipes, and overlays live behind narrow mutex-protected
|
||||||
structs in `runtime.rs`.
|
structs in `runtime.rs`.
|
||||||
|
|
||||||
Daemon shutdown is an owned lifecycle transition, not process-exit cleanup.
|
Daemon shutdown is an owned lifecycle transition, not process-exit cleanup.
|
||||||
|
Before starting Iroh or background modules, the daemon binds the selected
|
||||||
|
home's local control endpoint. A live endpoint makes a second daemon fail with
|
||||||
|
`daemon_already_running`; only an endpoint that cannot be connected is treated
|
||||||
|
as stale and replaced. This keeps one daemon authoritative for each geth home
|
||||||
|
and prevents a second process from unlinking the first daemon's control path.
|
||||||
After local control stops accepting requests, `daemon.rs` cancels the Iroh
|
After local control stops accepting requests, `daemon.rs` cancels the Iroh
|
||||||
accept and live-sync tasks, releases native docs/gossip/blob handles, calls
|
accept and live-sync tasks, releases native docs/gossip/blob handles, calls
|
||||||
`Endpoint::close().await`, and removes the local control socket. The same
|
`Endpoint::close().await`, and removes the local control socket. The same
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,15 @@ For deployment-readiness work that cuts across feature areas, see
|
||||||
|
|
||||||
## Operator Usability
|
## Operator Usability
|
||||||
|
|
||||||
|
- `[x]` Enforce one daemon per geth home.
|
||||||
|
Acceptance criteria:
|
||||||
|
- `[x]` Daemon startup claims the local control endpoint before starting
|
||||||
|
network and background modules.
|
||||||
|
- `[x]` A live endpoint rejects another daemon with the stable
|
||||||
|
`daemon_already_running` code instead of unlinking the first socket.
|
||||||
|
- `[x]` Stale, unreachable socket paths are recovered automatically.
|
||||||
|
- `[x]` Tests cover stale recovery and live second-daemon rejection.
|
||||||
|
|
||||||
- `[x]` Make startup modes and the daemon lifecycle discoverable.
|
- `[x]` Make startup modes and the daemon lifecycle discoverable.
|
||||||
Acceptance criteria:
|
Acceptance criteria:
|
||||||
- `[x]` Base and nested CLI help explain every command family instead of
|
- `[x]` Base and nested CLI help explain every command family instead of
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue