Skip to content

Commit ee621a0

Browse files
maxholmanclaude
andcommitted
feat(daemon): accept runtime commands and promote exit nodes to relay
MCP/REST/REPL users can now dynamically connect, listen, and disconnect without restarting the daemon. A NodeCommand channel from Handler to the mode task feeds a tokio::select! loop in auto mode. When both --connect and --listen are specified, the node starts as exit and promotes to relay when the listener accepts a second peer — multi-hop topologies form organically without pre-declaring relay mode. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent d61eb3b commit ee621a0

7 files changed

Lines changed: 1220 additions & 29 deletions

File tree

crates/core/src/control/handler.rs

Lines changed: 86 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
use std::{net::SocketAddr, sync::Arc, time::Instant};
77

88
use arc_swap::ArcSwap;
9-
use tokio::sync::watch;
9+
use tokio::sync::{mpsc, watch};
1010
use wallhack_wire::{
1111
control::{
1212
ControlRequest, ControlResponse, ErrorResponse, PeerInfo, PingResponse, RouteInfo,
@@ -18,7 +18,11 @@ use wallhack_wire::{
1818
use crate::NodeRole;
1919

2020
use super::{
21-
log_buffer::LogBuffer, metrics::SharedMetrics, peers::SharedRegistry, routes::SharedRouteTable,
21+
log_buffer::LogBuffer,
22+
metrics::SharedMetrics,
23+
node_command::{NodeCommand, reply_channel},
24+
peers::SharedRegistry,
25+
routes::SharedRouteTable,
2226
};
2327

2428
/// Mutable runtime state that can change after construction.
@@ -122,6 +126,14 @@ pub struct Handler {
122126
route_updates: tokio::sync::broadcast::Sender<super::routes::RouteUpdate>,
123127
state: SharedNodeState,
124128
start_time: Instant,
129+
/// Sender used by `connect/listen/disconnect` to dispatch commands to the
130+
/// active mode task. `None` if the channel has been dropped (mode does not
131+
/// support dynamic operations).
132+
cmd_tx: mpsc::Sender<NodeCommand>,
133+
/// Receiver extracted once by the mode task before it starts running.
134+
/// Wrapped in `Mutex<Option<…>>` so it can be moved out without requiring
135+
/// `&mut self`.
136+
cmd_rx: std::sync::Mutex<Option<mpsc::Receiver<NodeCommand>>>,
125137
}
126138

127139
impl Handler {
@@ -141,6 +153,7 @@ impl Handler {
141153
) -> Self {
142154
let state = SharedNodeState::new(config.node_role);
143155
let (hint_tx, _) = watch::channel(None);
156+
let (cmd_tx, cmd_rx) = mpsc::channel(8);
144157
Self {
145158
config,
146159
hint_tx,
@@ -151,6 +164,8 @@ impl Handler {
151164
route_updates,
152165
state,
153166
start_time: Instant::now(),
167+
cmd_tx,
168+
cmd_rx: std::sync::Mutex::new(Some(cmd_rx)),
154169
}
155170
}
156171

@@ -170,6 +185,19 @@ impl Handler {
170185
self.hint_tx.subscribe()
171186
}
172187

188+
/// Extracts the [`NodeCommand`] receiver so the mode task can poll it.
189+
///
190+
/// May only be called once. Returns `None` if already taken.
191+
///
192+
/// # Panics
193+
///
194+
/// Panics if the internal mutex is poisoned (indicates a prior panic in
195+
/// another thread while holding the lock, which should never happen in
196+
/// normal operation).
197+
pub fn take_cmd_rx(&self) -> Option<mpsc::Receiver<NodeCommand>> {
198+
self.cmd_rx.lock().expect("cmd_rx mutex poisoned").take()
199+
}
200+
173201
/// Handles a control request and returns a response.
174202
///
175203
/// # Errors
@@ -419,25 +447,70 @@ impl crate::node_api::NodeApi for Handler {
419447
}
420448
}
421449

422-
fn connect(&self, _addr: &str) -> crate::node_api::Result<crate::node_api::ConnectInfo> {
423-
Err(crate::node_api::NodeApiError::NotSupported(
424-
"dynamic connect not yet implemented — specify --connect at startup".into(),
425-
))
450+
fn connect(&self, addr: &str) -> crate::node_api::Result<crate::node_api::ConnectInfo> {
451+
let (reply_tx, reply_rx) = reply_channel();
452+
self.cmd_tx
453+
.try_send(NodeCommand::Connect {
454+
addr: addr.to_string(),
455+
reply: reply_tx,
456+
})
457+
.map_err(|err| match err {
458+
tokio::sync::mpsc::error::TrySendError::Closed(_) => {
459+
crate::node_api::NodeApiError::NotSupported(
460+
"dynamic connect not supported in this mode".into(),
461+
)
462+
}
463+
tokio::sync::mpsc::error::TrySendError::Full(_) => {
464+
crate::node_api::NodeApiError::Internal("command queue full, try again".into())
465+
}
466+
})?;
467+
tokio::task::block_in_place(|| reply_rx.recv()).map_err(|_| {
468+
crate::node_api::NodeApiError::Internal("mode task dropped reply".into())
469+
})?
426470
}
427471

428472
fn listen(
429473
&self,
430-
_addr: std::net::SocketAddr,
474+
addr: std::net::SocketAddr,
431475
) -> crate::node_api::Result<crate::node_api::ListenInfo> {
432-
Err(crate::node_api::NodeApiError::NotSupported(
433-
"dynamic listen not yet implemented — specify --listen at startup".into(),
434-
))
476+
let (reply_tx, reply_rx) = reply_channel();
477+
self.cmd_tx
478+
.try_send(NodeCommand::Listen {
479+
addr,
480+
reply: reply_tx,
481+
})
482+
.map_err(|err| match err {
483+
tokio::sync::mpsc::error::TrySendError::Closed(_) => {
484+
crate::node_api::NodeApiError::NotSupported(
485+
"dynamic listen not supported in this mode".into(),
486+
)
487+
}
488+
tokio::sync::mpsc::error::TrySendError::Full(_) => {
489+
crate::node_api::NodeApiError::Internal("command queue full, try again".into())
490+
}
491+
})?;
492+
tokio::task::block_in_place(|| reply_rx.recv()).map_err(|_| {
493+
crate::node_api::NodeApiError::Internal("mode task dropped reply".into())
494+
})?
435495
}
436496

437497
fn disconnect(&self) -> crate::node_api::Result<()> {
438-
Err(crate::node_api::NodeApiError::NotSupported(
439-
"dynamic disconnect not yet implemented".into(),
440-
))
498+
let (reply_tx, reply_rx) = reply_channel();
499+
self.cmd_tx
500+
.try_send(NodeCommand::Disconnect { reply: reply_tx })
501+
.map_err(|err| match err {
502+
tokio::sync::mpsc::error::TrySendError::Closed(_) => {
503+
crate::node_api::NodeApiError::NotSupported(
504+
"dynamic disconnect not supported in this mode".into(),
505+
)
506+
}
507+
tokio::sync::mpsc::error::TrySendError::Full(_) => {
508+
crate::node_api::NodeApiError::Internal("command queue full, try again".into())
509+
}
510+
})?;
511+
tokio::task::block_in_place(|| reply_rx.recv()).map_err(|_| {
512+
crate::node_api::NodeApiError::Internal("mode task dropped reply".into())
513+
})?
441514
}
442515

443516
fn add_route(

crates/core/src/control/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ pub mod client;
33
pub mod handler;
44
pub mod log_buffer;
55
pub mod metrics;
6+
pub mod node_command;
67
pub mod peers;
78
pub mod routes;
89
#[cfg(feature = "quic")]
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
//! Commands sent from the control API layer to the running mode task.
2+
//!
3+
//! [`NodeCommand`] is the message type used by [`super::handler::Handler`] to
4+
//! request dynamic operations (connect, listen, disconnect) from the active
5+
//! mode task. Replies are delivered via a one-shot channel embedded in each
6+
//! variant.
7+
//!
8+
//! The mode task receives commands from an `mpsc::Receiver<NodeCommand>` and
9+
//! the handler sends them via the paired `mpsc::Sender<NodeCommand>`. For
10+
//! modes that do not support dynamic operations (entry, exit, relay), the
11+
//! receiver end is simply dropped, which causes the handler's send to fail and
12+
//! return a `NotSupported` error.
13+
14+
use std::net::SocketAddr;
15+
16+
use crate::node_api::{ConnectInfo, ListenInfo, NodeApiError};
17+
18+
/// Reply channel for a single command.
19+
///
20+
/// Uses a standard-library sync channel so the handler side (which may be
21+
/// called from a synchronous context) can block waiting for the reply without
22+
/// needing an async runtime handle.
23+
pub type ReplySender<T> = std::sync::mpsc::SyncSender<Result<T, NodeApiError>>;
24+
pub type ReplyReceiver<T> = std::sync::mpsc::Receiver<Result<T, NodeApiError>>;
25+
26+
/// Create a reply channel pair for a node command.
27+
#[must_use]
28+
pub fn reply_channel<T>() -> (ReplySender<T>, ReplyReceiver<T>) {
29+
std::sync::mpsc::sync_channel(1)
30+
}
31+
32+
/// A command sent from the handler/API layer to the mode task.
33+
#[derive(Debug)]
34+
pub enum NodeCommand {
35+
/// Connect to a remote peer at the given address.
36+
Connect {
37+
/// Target address (host, host:port, etc.).
38+
addr: String,
39+
/// Channel for sending the result back to the caller.
40+
reply: ReplySender<ConnectInfo>,
41+
},
42+
/// Start listening for incoming peer connections.
43+
Listen {
44+
/// Address to bind.
45+
addr: SocketAddr,
46+
/// Channel for sending the result back to the caller.
47+
reply: ReplySender<ListenInfo>,
48+
},
49+
/// Disconnect from the currently connected peer.
50+
Disconnect {
51+
/// Channel for sending the result back to the caller.
52+
reply: ReplySender<()>,
53+
},
54+
}

crates/daemon/src/lib.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -164,6 +164,7 @@ pub fn start_node(
164164
log_buffer,
165165
);
166166
let node_state = handler.node_state();
167+
let cmd_rx = handler.take_cmd_rx();
167168
let node_api: Arc<dyn NodeApi> = Arc::new(handler);
168169

169170
let (shutdown_tx, _shutdown_rx) = watch::channel(());
@@ -177,6 +178,7 @@ pub fn start_node(
177178
route_updates: route_update_rx,
178179
route_updates_tx: route_update_tx,
179180
node_state,
181+
cmd_rx,
180182
};
181183
let task = tokio::spawn(async move { mode::run(&config, resources).await.map_err(Into::into) });
182184

0 commit comments

Comments
 (0)