Skip to content

Commit 957990f

Browse files
maxholmanclaude
andcommitted
refactor: eliminate cmd channel, add From/Display for stats conversion
Replace the dedicated mpsc channel (cmd_tx/cmd_rx) between Handler and mode task with a command queue on SharedNodeState — which is already threaded to both sides. Zero new fields in Handler or NodeResources. Add From<Metrics> for proto StatsResponse and Display for StatsResponse so IPC dispatch and MCP output use traits instead of field-by-field mapping. Add From<proto::StatsResponse> for the REST StatsResponse. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 2ba53ad commit 957990f

10 files changed

Lines changed: 300 additions & 335 deletions

File tree

crates/api/src/handlers.rs

Lines changed: 17 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,22 @@ pub struct StatsResponse {
3535
pub total_flows: u64,
3636
}
3737

38+
impl From<wallhack_wire::management::StatsResponse> for StatsResponse {
39+
fn from(s: wallhack_wire::management::StatsResponse) -> Self {
40+
Self {
41+
bytes_in: s.bytes_in,
42+
bytes_out: s.bytes_out,
43+
packets_in: s.packets_in,
44+
packets_out: s.packets_out,
45+
active_connections: s.active_connections,
46+
active_flows: s.active_flows,
47+
packets_dropped: s.packets_dropped,
48+
total_connections: s.total_connections,
49+
total_flows: s.total_flows,
50+
}
51+
}
52+
}
53+
3854
/// Node info response.
3955
#[derive(Debug, Serialize)]
4056
pub struct InfoResponse {
@@ -250,17 +266,7 @@ pub async fn stats(State(state): State<ApiState>) -> Result<Json<StatsResponse>,
250266
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
251267

252268
match resp.response {
253-
Some(management_response::Response::Stats(s)) => Ok(Json(StatsResponse {
254-
bytes_in: s.bytes_in,
255-
bytes_out: s.bytes_out,
256-
packets_in: s.packets_in,
257-
packets_out: s.packets_out,
258-
active_connections: s.active_connections,
259-
active_flows: s.active_flows,
260-
packets_dropped: s.packets_dropped,
261-
total_connections: s.total_connections,
262-
total_flows: s.total_flows,
263-
})),
269+
Some(management_response::Response::Stats(s)) => Ok(Json(StatsResponse::from(s))),
264270
_ => Err(StatusCode::INTERNAL_SERVER_ERROR),
265271
}
266272
}

crates/core/src/control/handler.rs

Lines changed: 128 additions & 90 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,10 @@
55
66
use std::{net::SocketAddr, sync::Arc, time::Instant};
77

8+
use std::collections::VecDeque;
9+
810
use arc_swap::ArcSwap;
9-
use tokio::sync::{mpsc, watch};
11+
use tokio::sync::watch;
1012
use wallhack_wire::{
1113
control::{
1214
ControlRequest, ControlResponse, ErrorResponse, PeerInfo, PingResponse, RouteInfo,
@@ -18,13 +20,48 @@ use wallhack_wire::{
1820
use crate::NodeRole;
1921

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

26+
/// Reply channel for a single command.
27+
///
28+
/// Uses a standard-library sync channel so the handler side (which may be
29+
/// called from a synchronous context) can block waiting for the reply without
30+
/// needing an async runtime handle.
31+
type ReplySender<T> = std::sync::mpsc::SyncSender<Result<T, crate::node_api::NodeApiError>>;
32+
33+
/// Create a reply channel pair for a node command.
34+
fn reply_channel<T>() -> (
35+
ReplySender<T>,
36+
std::sync::mpsc::Receiver<Result<T, crate::node_api::NodeApiError>>,
37+
) {
38+
std::sync::mpsc::sync_channel(1)
39+
}
40+
41+
/// A command sent from the handler/API layer to the mode task.
42+
#[derive(Debug)]
43+
pub enum NodeCommand {
44+
/// Connect to a remote peer at the given address.
45+
Connect {
46+
/// Target address (host, host:port, etc.).
47+
addr: String,
48+
/// Channel for sending the result back to the caller.
49+
reply: ReplySender<crate::node_api::ConnectInfo>,
50+
},
51+
/// Start listening for incoming peer connections.
52+
Listen {
53+
/// Address to bind.
54+
addr: SocketAddr,
55+
/// Channel for sending the result back to the caller.
56+
reply: ReplySender<crate::node_api::ListenInfo>,
57+
},
58+
/// Disconnect from the currently connected peer.
59+
Disconnect {
60+
/// Channel for sending the result back to the caller.
61+
reply: ReplySender<()>,
62+
},
63+
}
64+
2865
/// Mutable runtime state that can change after construction.
2966
///
3067
/// Stored behind `ArcSwap` for wait-free reads (same pattern as `Registry`).
@@ -36,31 +73,53 @@ struct NodeState {
3673
peer_addr: Option<String>,
3774
}
3875

76+
/// Inner state backing [`SharedNodeState`].
77+
struct SharedNodeStateInner {
78+
state: ArcSwap<NodeState>,
79+
commands: std::sync::Mutex<VecDeque<NodeCommand>>,
80+
command_notify: tokio::sync::Notify,
81+
}
82+
3983
/// Shared node state handle, cloneable and cheaply updatable.
4084
///
4185
/// Consumers call [`SharedNodeState::update_role`], [`SharedNodeState::update_capabilities`],
4286
/// etc. after negotiation or listening starts so that `wallhack info`
4387
/// reflects the real state of the daemon.
44-
#[derive(Clone, Debug)]
45-
pub struct SharedNodeState(Arc<ArcSwap<NodeState>>);
88+
///
89+
/// Also hosts the command queue for dynamic connect/listen/disconnect
90+
/// operations, avoiding a dedicated channel between handler and mode task.
91+
#[derive(Clone)]
92+
pub struct SharedNodeState(Arc<SharedNodeStateInner>);
93+
94+
impl std::fmt::Debug for SharedNodeState {
95+
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
96+
f.debug_struct("SharedNodeState")
97+
.field("state", &*self.0.state.load())
98+
.finish()
99+
}
100+
}
46101

47102
impl SharedNodeState {
48103
fn new(role: NodeRole) -> Self {
49-
Self(Arc::new(ArcSwap::from_pointee(NodeState {
50-
role,
51-
capabilities: Capabilities::default(),
52-
listen_addr: None,
53-
peer_addr: None,
54-
})))
104+
Self(Arc::new(SharedNodeStateInner {
105+
state: ArcSwap::from_pointee(NodeState {
106+
role,
107+
capabilities: Capabilities::default(),
108+
listen_addr: None,
109+
peer_addr: None,
110+
}),
111+
commands: std::sync::Mutex::new(VecDeque::new()),
112+
command_notify: tokio::sync::Notify::new(),
113+
}))
55114
}
56115

57116
fn load(&self) -> arc_swap::Guard<Arc<NodeState>> {
58-
self.0.load()
117+
self.0.state.load()
59118
}
60119

61120
/// Update the node role (e.g. after auto-negotiation resolves).
62121
pub fn update_role(&self, role: NodeRole) {
63-
self.0.rcu(|old| {
122+
self.0.state.rcu(|old| {
64123
let mut new = (**old).clone();
65124
new.role = role;
66125
new
@@ -69,7 +128,7 @@ impl SharedNodeState {
69128

70129
/// Update the node's own capabilities.
71130
pub fn update_capabilities(&self, capabilities: Capabilities) {
72-
self.0.rcu(|old| {
131+
self.0.state.rcu(|old| {
73132
let mut new = (**old).clone();
74133
new.capabilities = capabilities;
75134
new
@@ -78,13 +137,47 @@ impl SharedNodeState {
78137

79138
/// Record that the node is now listening on `addr`.
80139
pub fn set_listen_addr(&self, addr: SocketAddr) {
81-
self.0.rcu(|old| {
140+
self.0.state.rcu(|old| {
82141
let mut new = (**old).clone();
83142
new.listen_addr = Some(addr);
84143
new.capabilities.listening = true;
85144
new
86145
});
87146
}
147+
148+
/// Enqueue a command for the mode task and wake it.
149+
///
150+
/// # Panics
151+
///
152+
/// Panics if the command queue mutex is poisoned.
153+
pub fn push_command(&self, cmd: NodeCommand) {
154+
self.0
155+
.commands
156+
.lock()
157+
.expect("command queue mutex poisoned")
158+
.push_back(cmd);
159+
self.0.command_notify.notify_one();
160+
}
161+
162+
/// Drain all pending commands from the queue.
163+
///
164+
/// # Panics
165+
///
166+
/// Panics if the command queue mutex is poisoned.
167+
#[must_use]
168+
pub fn drain_commands(&self) -> Vec<NodeCommand> {
169+
self.0
170+
.commands
171+
.lock()
172+
.expect("command queue mutex poisoned")
173+
.drain(..)
174+
.collect()
175+
}
176+
177+
/// Wait until at least one command is enqueued.
178+
pub async fn notified(&self) {
179+
self.0.command_notify.notified().await;
180+
}
88181
}
89182

90183
/// Configuration for the control handler.
@@ -126,14 +219,6 @@ pub struct Handler {
126219
route_updates: tokio::sync::broadcast::Sender<super::routes::RouteUpdate>,
127220
state: SharedNodeState,
128221
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>>>,
137222
}
138223

139224
impl Handler {
@@ -153,7 +238,6 @@ impl Handler {
153238
) -> Self {
154239
let state = SharedNodeState::new(config.node_role);
155240
let (hint_tx, _) = watch::channel(None);
156-
let (cmd_tx, cmd_rx) = mpsc::channel(8);
157241
Self {
158242
config,
159243
hint_tx,
@@ -164,8 +248,6 @@ impl Handler {
164248
route_updates,
165249
state,
166250
start_time: Instant::now(),
167-
cmd_tx,
168-
cmd_rx: std::sync::Mutex::new(Some(cmd_rx)),
169251
}
170252
}
171253

@@ -185,19 +267,6 @@ impl Handler {
185267
self.hint_tx.subscribe()
186268
}
187269

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-
201270
/// Handles a control request and returns a response.
202271
///
203272
/// # Errors
@@ -448,23 +517,12 @@ impl crate::node_api::NodeApi for Handler {
448517
}
449518

450519
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(|_| {
520+
let (reply_sender, reply_receiver) = reply_channel();
521+
self.state.push_command(NodeCommand::Connect {
522+
addr: addr.to_string(),
523+
reply: reply_sender,
524+
});
525+
tokio::task::block_in_place(|| reply_receiver.recv()).map_err(|_| {
468526
crate::node_api::NodeApiError::Internal("mode task dropped reply".into())
469527
})?
470528
}
@@ -473,42 +531,22 @@ impl crate::node_api::NodeApi for Handler {
473531
&self,
474532
addr: std::net::SocketAddr,
475533
) -> crate::node_api::Result<crate::node_api::ListenInfo> {
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(|_| {
534+
let (reply_sender, reply_receiver) = reply_channel();
535+
self.state.push_command(NodeCommand::Listen {
536+
addr,
537+
reply: reply_sender,
538+
});
539+
tokio::task::block_in_place(|| reply_receiver.recv()).map_err(|_| {
493540
crate::node_api::NodeApiError::Internal("mode task dropped reply".into())
494541
})?
495542
}
496543

497544
fn disconnect(&self) -> crate::node_api::Result<()> {
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(|_| {
545+
let (reply_sender, reply_receiver) = reply_channel();
546+
self.state.push_command(NodeCommand::Disconnect {
547+
reply: reply_sender,
548+
});
549+
tokio::task::block_in_place(|| reply_receiver.recv()).map_err(|_| {
512550
crate::node_api::NodeApiError::Internal("mode task dropped reply".into())
513551
})?
514552
}

crates/core/src/control/mod.rs

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@ pub mod client;
33
pub mod handler;
44
pub mod log_buffer;
55
pub mod metrics;
6-
pub mod node_command;
76
pub mod peers;
87
pub mod routes;
98
#[cfg(feature = "quic")]

0 commit comments

Comments
 (0)