Skip to content

Commit 37f4d18

Browse files
authored
Merge pull request #103 from block65/fix/uat-parity
feat: dynamic connect/listen, relay promotion, stats counters
2 parents 1c46390 + 69124f3 commit 37f4d18

21 files changed

Lines changed: 1354 additions & 398 deletions

File tree

bench/bench.just

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -107,7 +107,7 @@ build-tc:
107107
108108
cargo-build-musl:
109109
echo "Building musl binary (slim)..."
110-
cross build --release --target "{{ musl_target }}" -p wallhack-cli --no-default-features --features slim
110+
cargo build --release --target "{{ musl_target }}" -p wallhack-cli --no-default-features --features slim
111111

112112
clean:
113113
rm -rf "{{ staging_dir }}"

bench/vm/init.sh

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -150,7 +150,7 @@ _run_exit() {
150150

151151
echo "WALLHACK_TS: exit_wallhack_start=$(date +%s%3N)"
152152
# wallhack exit node — connects to entry (retries with backoff)
153-
wallhack daemon ${DEBUG:+"--debug"} exit \
153+
wallhack daemon ${DEBUG:+"--debug"} --role exit \
154154
-c "${ENTRY_ETH}:${WH_PORT}${_TSUFFIX}" \
155155
--name "${PEER_NAME}" \
156156
2>&1 | tee /tmp/wallhack-exit.log &
@@ -304,7 +304,7 @@ _run_entry() {
304304

305305
echo "WALLHACK_TS: entry_wallhack_start=$(date +%s%3N)"
306306
# Start wallhack entry node (listen mode)
307-
wallhack daemon ${DEBUG:+"--debug"} entry \
307+
wallhack daemon ${DEBUG:+"--debug"} --role entry \
308308
-l ":${WH_PORT}${_TSUFFIX}" \
309309
2>&1 | tee /tmp/wallhack-entry.log &
310310

crates/api/src/handlers.rs

Lines changed: 26 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,28 @@ pub struct StatsResponse {
2727
pub packets_out: u64,
2828
pub active_connections: u64,
2929
pub active_flows: u64,
30+
/// Total packets dropped since daemon start.
31+
pub packets_dropped: u64,
32+
/// Total connections opened since daemon start (monotonically increasing).
33+
pub total_connections: u64,
34+
/// Total flows opened since daemon start (monotonically increasing).
35+
pub total_flows: u64,
36+
}
37+
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+
}
3052
}
3153

3254
/// Node info response.
@@ -244,14 +266,10 @@ pub async fn stats(State(state): State<ApiState>) -> Result<Json<StatsResponse>,
244266
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
245267

246268
match resp.response {
247-
Some(management_response::Response::Stats(s)) => Ok(Json(StatsResponse {
248-
bytes_in: s.bytes_in,
249-
bytes_out: s.bytes_out,
250-
packets_in: s.packets_in,
251-
packets_out: s.packets_out,
252-
active_connections: s.active_connections,
253-
active_flows: s.active_flows,
254-
})),
269+
Some(management_response::Response::Stats(s)) => {
270+
let response: StatsResponse = s.into();
271+
Ok(Json(response))
272+
}
255273
_ => Err(StatusCode::INTERNAL_SERVER_ERROR),
256274
}
257275
}

crates/cli/src/output.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,8 @@ pub fn print_response(resp: &ManagementResponse) -> Result<(), CtlError> {
128128
let _ = writeln!(tw, "connections:\t{}", s.active_connections);
129129
let _ = writeln!(tw, "flows:\t{}", s.active_flows);
130130
let _ = writeln!(tw, "dropped:\t{}", s.packets_dropped);
131+
let _ = writeln!(tw, "total connections:\t{}", s.total_connections);
132+
let _ = writeln!(tw, "total flows:\t{}", s.total_flows);
131133
let _ = tw.flush();
132134
}
133135
Some(management_response::Response::Peers(p)) => {

crates/core/src/control/handler.rs

Lines changed: 116 additions & 22 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;
1010
use wallhack_wire::{
1111
control::{
1212
ControlRequest, ControlResponse, ErrorResponse, PeerInfo, PingResponse, RouteInfo,
@@ -21,6 +21,51 @@ use super::{
2121
log_buffer::LogBuffer, metrics::SharedMetrics, peers::SharedRegistry, routes::SharedRouteTable,
2222
};
2323

24+
/// Reply channel for a single command.
25+
///
26+
/// Uses a standard-library sync channel so the handler side (which may be
27+
/// called from a synchronous context) can block waiting for the reply without
28+
/// needing an async runtime handle.
29+
type ReplySender<T> = std::sync::mpsc::SyncSender<Result<T, crate::node_api::NodeApiError>>;
30+
31+
/// Create a reply channel pair for a node command.
32+
fn reply_channel<T>() -> (
33+
ReplySender<T>,
34+
std::sync::mpsc::Receiver<Result<T, crate::node_api::NodeApiError>>,
35+
) {
36+
std::sync::mpsc::sync_channel(1)
37+
}
38+
39+
/// A command sent from the handler/API layer to the mode task via the
40+
/// control watch channel.
41+
#[derive(Debug)]
42+
pub enum NodeCommand {
43+
/// Set or clear the role hint.
44+
Role {
45+
/// `Some` to set a hint, `None` to clear (auto).
46+
hint: Option<RoleHint>,
47+
},
48+
/// Connect to a remote peer at the given address.
49+
Connect {
50+
/// Target address (host, host:port, etc.).
51+
addr: String,
52+
/// Channel for sending the result back to the caller.
53+
reply: ReplySender<crate::node_api::ConnectInfo>,
54+
},
55+
/// Start listening for incoming peer connections.
56+
Listen {
57+
/// Address to bind.
58+
addr: SocketAddr,
59+
/// Channel for sending the result back to the caller.
60+
reply: ReplySender<crate::node_api::ListenInfo>,
61+
},
62+
/// Disconnect from the currently connected peer.
63+
Disconnect {
64+
/// Channel for sending the result back to the caller.
65+
reply: ReplySender<()>,
66+
},
67+
}
68+
2469
/// Mutable runtime state that can change after construction.
2570
///
2671
/// Stored behind `ArcSwap` for wait-free reads (same pattern as `Registry`).
@@ -112,9 +157,12 @@ impl HandlerConfig {
112157
/// on the current state of metrics and configuration.
113158
pub struct Handler {
114159
config: HandlerConfig,
115-
/// Sender for hint changes. The mode task watches the receiver and
116-
/// re-evaluates when a new hint arrives. `None` means no hint is active.
117-
hint_tx: watch::Sender<Option<RoleHint>>,
160+
/// Command channel to the mode task. Carries role changes, connect,
161+
/// listen, and disconnect commands.
162+
command_source: mpsc::Sender<NodeCommand>,
163+
/// Receiver side of the command channel. Extracted once by the daemon
164+
/// before wrapping Handler in `Arc<dyn NodeApi>`.
165+
command_sink: std::sync::Mutex<Option<mpsc::Receiver<NodeCommand>>>,
118166
log_buffer: LogBuffer,
119167
metrics: SharedMetrics,
120168
peers: SharedRegistry,
@@ -140,10 +188,11 @@ impl Handler {
140188
log_buffer: Option<LogBuffer>,
141189
) -> Self {
142190
let state = SharedNodeState::new(config.node_role);
143-
let (hint_tx, _) = watch::channel(None);
191+
let (command_source, command_sink) = mpsc::channel(8);
144192
Self {
145193
config,
146-
hint_tx,
194+
command_source,
195+
command_sink: std::sync::Mutex::new(Some(command_sink)),
147196
log_buffer: log_buffer.unwrap_or_default(),
148197
metrics,
149198
peers,
@@ -164,10 +213,19 @@ impl Handler {
164213
self.state.clone()
165214
}
166215

167-
/// Returns a receiver that fires when the runtime hint changes.
216+
/// Extracts the command receiver. Called once by the daemon before
217+
/// wrapping Handler in `Arc<dyn NodeApi>`.
218+
///
219+
/// # Panics
220+
///
221+
/// Panics if the mutex is poisoned or if called more than once.
168222
#[must_use]
169-
pub fn hint_rx(&self) -> watch::Receiver<Option<RoleHint>> {
170-
self.hint_tx.subscribe()
223+
pub fn command_sink(&self) -> mpsc::Receiver<NodeCommand> {
224+
self.command_sink
225+
.lock()
226+
.expect("command_sink mutex poisoned")
227+
.take()
228+
.expect("command_sink already taken")
171229
}
172230

173231
/// Handles a control request and returns a response.
@@ -419,25 +477,57 @@ impl crate::node_api::NodeApi for Handler {
419477
}
420478
}
421479

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-
))
480+
fn connect(&self, addr: &str) -> crate::node_api::Result<crate::node_api::ConnectInfo> {
481+
let (reply_sender, reply_receiver) = reply_channel();
482+
self.command_source
483+
.try_send(NodeCommand::Connect {
484+
addr: addr.to_string(),
485+
reply: reply_sender,
486+
})
487+
.map_err(|_| {
488+
crate::node_api::NodeApiError::NotSupported(
489+
"dynamic connect not supported in this mode".into(),
490+
)
491+
})?;
492+
tokio::task::block_in_place(|| reply_receiver.recv()).map_err(|_| {
493+
crate::node_api::NodeApiError::Internal("mode task dropped reply".into())
494+
})?
426495
}
427496

428497
fn listen(
429498
&self,
430-
_addr: std::net::SocketAddr,
499+
addr: std::net::SocketAddr,
431500
) -> 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-
))
501+
let (reply_sender, reply_receiver) = reply_channel();
502+
self.command_source
503+
.try_send(NodeCommand::Listen {
504+
addr,
505+
reply: reply_sender,
506+
})
507+
.map_err(|_| {
508+
crate::node_api::NodeApiError::NotSupported(
509+
"dynamic listen not supported in this mode".into(),
510+
)
511+
})?;
512+
tokio::task::block_in_place(|| reply_receiver.recv()).map_err(|_| {
513+
crate::node_api::NodeApiError::Internal("mode task dropped reply".into())
514+
})?
435515
}
436516

437517
fn disconnect(&self) -> crate::node_api::Result<()> {
438-
Err(crate::node_api::NodeApiError::NotSupported(
439-
"dynamic disconnect not yet implemented".into(),
440-
))
518+
let (reply_sender, reply_receiver) = reply_channel();
519+
self.command_source
520+
.try_send(NodeCommand::Disconnect {
521+
reply: reply_sender,
522+
})
523+
.map_err(|_| {
524+
crate::node_api::NodeApiError::NotSupported(
525+
"dynamic disconnect not supported in this mode".into(),
526+
)
527+
})?;
528+
tokio::task::block_in_place(|| reply_receiver.recv()).map_err(|_| {
529+
crate::node_api::NodeApiError::Internal("mode task dropped reply".into())
530+
})?
441531
}
442532

443533
fn add_route(
@@ -509,12 +599,16 @@ impl crate::node_api::NodeApi for Handler {
509599
}
510600

511601
fn hint_set(&self, hint: RoleHint) -> crate::node_api::Result<()> {
512-
self.hint_tx.send_replace(Some(hint));
602+
let _ = self
603+
.command_source
604+
.try_send(NodeCommand::Role { hint: Some(hint) });
513605
Ok(())
514606
}
515607

516608
fn hint_set_auto(&self) -> crate::node_api::Result<()> {
517-
self.hint_tx.send_replace(None);
609+
let _ = self
610+
.command_source
611+
.try_send(NodeCommand::Role { hint: None });
518612
Ok(())
519613
}
520614

crates/core/src/control/metrics.rs

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,10 @@ pub struct Metrics {
1414
active_connections: AtomicU64,
1515
active_flows: AtomicU64,
1616
packets_dropped: AtomicU64,
17+
/// Monotonically increasing count of all connections ever opened.
18+
total_connections: AtomicU64,
19+
/// Monotonically increasing count of all flows ever opened.
20+
total_flows: AtomicU64,
1721
}
1822

1923
impl Metrics {
@@ -31,6 +35,8 @@ impl Metrics {
3135
active_connections: self.active_connections.load(Ordering::Relaxed),
3236
active_flows: self.active_flows.load(Ordering::Relaxed),
3337
packets_dropped: self.packets_dropped.load(Ordering::Relaxed),
38+
total_connections: self.total_connections.load(Ordering::Relaxed),
39+
total_flows: self.total_flows.load(Ordering::Relaxed),
3440
}
3541
}
3642

@@ -52,6 +58,7 @@ impl Metrics {
5258

5359
pub fn inc_active_connections(&self) {
5460
self.active_connections.fetch_add(1, Ordering::Relaxed);
61+
self.total_connections.fetch_add(1, Ordering::Relaxed);
5562
}
5663

5764
pub fn dec_active_connections(&self) {
@@ -64,6 +71,7 @@ impl Metrics {
6471

6572
pub fn inc_active_flows(&self) {
6673
self.active_flows.fetch_add(1, Ordering::Relaxed);
74+
self.total_flows.fetch_add(1, Ordering::Relaxed);
6775
}
6876

6977
pub fn dec_active_flows(&self) {
@@ -72,3 +80,59 @@ impl Metrics {
7280
}
7381

7482
pub type SharedMetrics = Arc<Metrics>;
83+
84+
#[cfg(test)]
85+
mod tests {
86+
use super::Metrics;
87+
88+
#[test]
89+
fn total_connections_increments_and_does_not_decrement() {
90+
let metrics = Metrics::new();
91+
92+
metrics.inc_active_connections();
93+
assert_eq!(metrics.snapshot().total_connections, 1);
94+
assert_eq!(metrics.snapshot().active_connections, 1);
95+
96+
metrics.dec_active_connections();
97+
// active_connections decrements, total_connections must not
98+
assert_eq!(metrics.snapshot().total_connections, 1);
99+
assert_eq!(metrics.snapshot().active_connections, 0);
100+
}
101+
102+
#[test]
103+
fn total_flows_increments_and_does_not_decrement() {
104+
let metrics = Metrics::new();
105+
106+
metrics.inc_active_flows();
107+
assert_eq!(metrics.snapshot().total_flows, 1);
108+
assert_eq!(metrics.snapshot().active_flows, 1);
109+
110+
metrics.dec_active_flows();
111+
// active_flows decrements, total_flows must not
112+
assert_eq!(metrics.snapshot().total_flows, 1);
113+
assert_eq!(metrics.snapshot().active_flows, 0);
114+
}
115+
116+
#[test]
117+
fn cumulative_counters_survive_connection_churn() {
118+
let metrics = Metrics::new();
119+
120+
// Simulate 5 connection open/close cycles
121+
for _ in 0..5 {
122+
metrics.inc_active_connections();
123+
metrics.dec_active_connections();
124+
}
125+
126+
assert_eq!(metrics.snapshot().total_connections, 5);
127+
assert_eq!(metrics.snapshot().active_connections, 0);
128+
129+
// Simulate 3 flow open/close cycles
130+
for _ in 0..3 {
131+
metrics.inc_active_flows();
132+
metrics.dec_active_flows();
133+
}
134+
135+
assert_eq!(metrics.snapshot().total_flows, 3);
136+
assert_eq!(metrics.snapshot().active_flows, 0);
137+
}
138+
}

0 commit comments

Comments
 (0)