Skip to content

Commit faa65ec

Browse files
committed
fix: make three-hop put test deterministic
1 parent d3ce033 commit faa65ec

File tree

4 files changed

+201
-9
lines changed

4 files changed

+201
-9
lines changed

crates/core/tests/operations.rs

Lines changed: 51 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1909,9 +1909,31 @@ async fn test_delegate_request(ctx: &mut TestContext) -> TestResult {
19091909
/// Expected flow:
19101910
/// 1. peer-a sends PUT → routes through gateway → stored on peer-c
19111911
/// 2. peer-c sends PUT response → routes back through gateway → received by peer-a
1912+
const THREE_HOP_TEST_CONTRACT: &str = "test-contract-integration";
1913+
1914+
static THREE_HOP_CONTRACT: LazyLock<(ContractContainer, freenet::dev_tool::Location)> =
1915+
LazyLock::new(|| {
1916+
let contract =
1917+
test_utils::load_contract(THREE_HOP_TEST_CONTRACT, vec![].into()).expect("contract");
1918+
let location = freenet::dev_tool::Location::from(&contract.key());
1919+
(contract, location)
1920+
});
1921+
1922+
fn three_hop_node_locations() -> Vec<f64> {
1923+
let (_, contract_location) = &*THREE_HOP_CONTRACT;
1924+
let gateway = freenet::dev_tool::Location::new_rounded(contract_location.as_f64() + 0.2);
1925+
let peer_a = freenet::dev_tool::Location::new_rounded(contract_location.as_f64() + 0.5);
1926+
vec![
1927+
gateway.as_f64(),
1928+
peer_a.as_f64(),
1929+
contract_location.as_f64(),
1930+
]
1931+
}
1932+
19121933
#[freenet_test(
19131934
nodes = ["gateway", "peer-a", "peer-c"],
19141935
gateways = ["gateway"],
1936+
node_locations_fn = three_hop_node_locations,
19151937
auto_connect_peers = true,
19161938
timeout_secs = 240,
19171939
startup_wait_secs = 15,
@@ -1922,10 +1944,12 @@ async fn test_delegate_request(ctx: &mut TestContext) -> TestResult {
19221944
async fn test_put_contract_three_hop_returns_response(ctx: &mut TestContext) -> TestResult {
19231945
use freenet::dev_tool::Location;
19241946

1925-
const TEST_CONTRACT: &str = "test-contract-integration";
1926-
let contract = test_utils::load_contract(TEST_CONTRACT, vec![].into())?;
1947+
let (contract, contract_location) = {
1948+
let (contract, location) = &*THREE_HOP_CONTRACT;
1949+
(contract.clone(), *location)
1950+
};
19271951
let contract_key = contract.key();
1928-
let contract_location = Location::from(&contract_key);
1952+
let node_locations = three_hop_node_locations();
19291953

19301954
let initial_state = test_utils::create_empty_todo_list();
19311955
let wrapped_state = WrappedState::from(initial_state);
@@ -1935,16 +1959,36 @@ async fn test_put_contract_three_hop_returns_response(ctx: &mut TestContext) ->
19351959
let peer_a = ctx.node("peer-a")?;
19361960
let peer_c = ctx.node("peer-c")?;
19371961

1938-
// Note: We cannot modify node locations after they're created with the macro,
1939-
// so this test will use random locations. The original test had specific location
1940-
// requirements to ensure proper three-hop routing. For now, we'll proceed with
1941-
// the test and it should still validate PUT response routing.
1962+
assert_eq!(gateway.location, node_locations[0]);
1963+
assert_eq!(peer_a.location, node_locations[1]);
1964+
assert_eq!(peer_c.location, node_locations[2]);
19421965

19431966
tracing::info!("Node A data dir: {:?}", peer_a.temp_dir_path);
19441967
tracing::info!("Gateway node data dir: {:?}", gateway.temp_dir_path);
19451968
tracing::info!("Node C data dir: {:?}", peer_c.temp_dir_path);
19461969
tracing::info!("Contract location: {}", contract_location.as_f64());
19471970

1971+
let gateway_distance = Location::new(gateway.location).distance(contract_location);
1972+
let peer_a_distance = Location::new(peer_a.location).distance(contract_location);
1973+
let peer_c_distance = Location::new(peer_c.location).distance(contract_location);
1974+
1975+
// Ensure the contract should naturally route to peer-c to create the 3-hop path:
1976+
// peer-a (client) -> gateway -> peer-c (closest to contract).
1977+
assert!(
1978+
peer_c_distance.as_f64() < gateway_distance.as_f64(),
1979+
"peer-c must be closer to contract than the gateway for three-hop routing"
1980+
);
1981+
assert!(
1982+
peer_c_distance.as_f64() < peer_a_distance.as_f64(),
1983+
"peer-c must be closest node to the contract location"
1984+
);
1985+
tracing::info!(
1986+
"Distances to contract - gateway: {}, peer-a: {}, peer-c: {}",
1987+
gateway_distance.as_f64(),
1988+
peer_a_distance.as_f64(),
1989+
peer_c_distance.as_f64()
1990+
);
1991+
19481992
// Connect to peer A's WebSocket API
19491993
let uri_a = format!(
19501994
"ws://127.0.0.1:{}/v1/contract/command?encodingProtocol=native",

crates/freenet-macros/README.md

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,41 @@ async fn test_multi_gateway(ctx: &mut TestContext) -> TestResult {
8282
}
8383
```
8484

85+
#### `node_locations`
86+
Set explicit ring locations for nodes (order must match the `nodes` list). This is useful for deterministic routing topologies in multi-hop tests.
87+
88+
```rust
89+
#[freenet_test(
90+
nodes = ["gateway", "peer-a", "peer-b"],
91+
node_locations = [0.2, 0.6, 0.85], // gateway, peer-a, peer-b
92+
auto_connect_peers = true
93+
)]
94+
```
95+
96+
**Rules:**
97+
- Provide one numeric value per node.
98+
- Values should be in the range `[0.0, 1.0]`.
99+
- Omit `node_locations` to use random locations (default behavior).
100+
101+
#### `node_locations_fn`
102+
Provide a function that returns `Vec<f64>` (one per node) when locations need to be computed dynamically (for example, based on a contract’s ring location).
103+
104+
```rust
105+
fn my_locations() -> Vec<f64> {
106+
// Must return one entry per node in the same order.
107+
vec![0.1, 0.6, 0.9]
108+
}
109+
110+
#[freenet_test(
111+
nodes = ["gateway", "peer-a", "peer-b"],
112+
node_locations_fn = my_locations,
113+
auto_connect_peers = true
114+
)]
115+
```
116+
117+
- The function must return exactly as many values as there are nodes; otherwise the test fails early.
118+
- `node_locations` and `node_locations_fn` are mutually exclusive.
119+
85120
#### `auto_connect_peers`
86121
Automatically configure all peer nodes to connect to all gateway nodes.
87122

crates/freenet-macros/src/codegen.rs

Lines changed: 48 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,48 @@ use proc_macro2::TokenStream;
55
use quote::{format_ident, quote};
66
use syn::{ItemFn, LitInt, Result};
77

8+
/// Get the configured location for a node or fall back to randomness.
9+
fn node_location(idx: usize) -> TokenStream {
10+
let idx_lit = syn::Index::from(idx);
11+
quote! {{
12+
if let Some(ref locs) = __node_locations {
13+
locs[#idx_lit]
14+
} else {
15+
rand::Rng::random(&mut rand::rng())
16+
}
17+
}}
18+
}
19+
20+
/// Generate node location initialization (literal list or function).
21+
fn generate_node_locations_init(args: &FreenetTestArgs) -> TokenStream {
22+
let node_count = args.nodes.len();
23+
24+
if let Some(ref fn_path) = args.node_locations_fn {
25+
quote! {
26+
let __node_locations: Option<Vec<f64>> = {
27+
let locs = #fn_path();
28+
if locs.len() != #node_count {
29+
return Err(anyhow::anyhow!(
30+
"node_locations_fn returned {} locations, expected {}",
31+
locs.len(),
32+
#node_count
33+
));
34+
}
35+
Some(locs)
36+
};
37+
}
38+
} else if let Some(ref locations) = args.node_locations {
39+
let values: Vec<_> = locations.iter().map(|loc| quote! { #loc }).collect();
40+
quote! {
41+
let __node_locations: Option<Vec<f64>> = Some(vec![#(#values),*]);
42+
}
43+
} else {
44+
quote! {
45+
let __node_locations: Option<Vec<f64>> = None;
46+
}
47+
}
48+
}
49+
850
/// Helper to determine if a node is a gateway
951
fn is_gateway(args: &FreenetTestArgs, node_label: &str, node_idx: usize) -> bool {
1052
if let Some(ref gateways) = args.gateways {
@@ -27,6 +69,7 @@ pub fn generate_test_code(args: FreenetTestArgs, input_fn: ItemFn) -> Result<Tok
2769

2870
// Generate node setup code
2971
let node_setup = generate_node_setup(&args);
72+
let node_locations_init = generate_node_locations_init(&args);
3073

3174
// Extract values before configs are moved
3275
let value_extraction = generate_value_extraction(&args);
@@ -71,6 +114,7 @@ pub fn generate_test_code(args: FreenetTestArgs, input_fn: ItemFn) -> Result<Tok
71114
tracing::info!("Starting test: {}", stringify!(#test_fn_name));
72115

73116
// 2. Create node configurations
117+
#node_locations_init
74118
#node_setup
75119

76120
// 3. Extract values before configs are moved
@@ -117,6 +161,7 @@ fn generate_node_setup(args: &FreenetTestArgs) -> TokenStream {
117161

118162
if is_gw {
119163
// Gateway node configuration
164+
let location_expr = node_location(idx);
120165
setup_code.push(quote! {
121166
let (#config_var, #temp_var) = {
122167
let temp_dir = tempfile::tempdir()?;
@@ -128,7 +173,7 @@ fn generate_node_setup(args: &FreenetTestArgs) -> TokenStream {
128173
let network_port = freenet::test_utils::reserve_local_port()?;
129174
let ws_port = freenet::test_utils::reserve_local_port()?;
130175

131-
let location: f64 = rand::Rng::random(&mut rand::rng());
176+
let location: f64 = #location_expr;
132177

133178
let config = freenet::config::ConfigArgs {
134179
ws_api: freenet::config::WebsocketApiArgs {
@@ -193,6 +238,7 @@ fn generate_node_setup(args: &FreenetTestArgs) -> TokenStream {
193238
let is_gw = is_gateway(args, node_label, idx);
194239

195240
if !is_gw {
241+
let location_expr = node_location(idx);
196242
// Collect gateway info variables to serialize
197243
let gateways_config = if args.auto_connect_peers {
198244
// Collect all gateway_info_X variables
@@ -238,7 +284,7 @@ fn generate_node_setup(args: &FreenetTestArgs) -> TokenStream {
238284
let network_port = freenet::test_utils::reserve_local_port()?;
239285
let ws_port = freenet::test_utils::reserve_local_port()?;
240286

241-
let location: f64 = rand::Rng::random(&mut rand::rng());
287+
let location: f64 = #location_expr;
242288

243289
let config = freenet::config::ConfigArgs {
244290
ws_api: freenet::config::WebsocketApiArgs {

crates/freenet-macros/src/parser.rs

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,10 @@ pub struct FreenetTestArgs {
77
pub nodes: Vec<String>,
88
/// Which nodes are gateways (if not specified, first node is gateway)
99
pub gateways: Option<Vec<String>>,
10+
/// Optional explicit node locations (same order as nodes)
11+
pub node_locations: Option<Vec<f64>>,
12+
/// Optional function path that returns node locations (same order as nodes)
13+
pub node_locations_fn: Option<syn::ExprPath>,
1014
/// Whether peers should auto-connect to gateways
1115
pub auto_connect_peers: bool,
1216
/// Test timeout in seconds
@@ -40,6 +44,8 @@ impl syn::parse::Parse for FreenetTestArgs {
4044
fn parse(input: syn::parse::ParseStream) -> syn::Result<Self> {
4145
let mut nodes = None;
4246
let mut gateways = None;
47+
let mut node_locations = None;
48+
let mut node_locations_fn = None;
4349
let mut auto_connect_peers = false;
4450
let mut timeout_secs = 180;
4551
let mut startup_wait_secs = 15;
@@ -79,6 +85,47 @@ impl syn::parse::Parse for FreenetTestArgs {
7985

8086
nodes = Some(node_list);
8187
}
88+
"node_locations" => {
89+
// Parse array literal of floats: [0.1, 0.5, 0.9]
90+
let content;
91+
syn::bracketed!(content in input);
92+
93+
let mut locations = Vec::new();
94+
while !content.is_empty() {
95+
let lit: syn::Lit = content.parse()?;
96+
let value = match lit {
97+
syn::Lit::Float(f) => f.base10_parse::<f64>()?,
98+
syn::Lit::Int(i) => i.base10_parse::<f64>()?,
99+
other => {
100+
return Err(syn::Error::new(
101+
other.span(),
102+
"node_locations must be numeric literals",
103+
))
104+
}
105+
};
106+
107+
locations.push(value);
108+
109+
// Handle optional trailing comma
110+
if content.peek(syn::Token![,]) {
111+
content.parse::<syn::Token![,]>()?;
112+
}
113+
}
114+
115+
if locations.is_empty() {
116+
return Err(syn::Error::new(
117+
key.span(),
118+
"node_locations array cannot be empty if specified",
119+
));
120+
}
121+
122+
node_locations = Some(locations);
123+
}
124+
"node_locations_fn" => {
125+
// Parse a path to a function returning Vec<f64>
126+
let path: syn::ExprPath = input.parse()?;
127+
node_locations_fn = Some(path);
128+
}
82129
"gateways" => {
83130
// Parse array literal: ["gateway-1", "gateway-2", ...]
84131
let content;
@@ -191,9 +238,29 @@ impl syn::parse::Parse for FreenetTestArgs {
191238
}
192239
}
193240

241+
// Validate node_locations if provided
242+
if let Some(ref locations) = node_locations {
243+
if locations.len() != nodes.len() {
244+
return Err(input.error(format!(
245+
"node_locations length ({}) must match nodes length ({})",
246+
locations.len(),
247+
nodes.len()
248+
)));
249+
}
250+
}
251+
252+
// Only one of node_locations or node_locations_fn may be provided
253+
if node_locations.is_some() && node_locations_fn.is_some() {
254+
return Err(
255+
input.error("Specify only one of node_locations or node_locations_fn (not both)")
256+
);
257+
}
258+
194259
Ok(FreenetTestArgs {
195260
nodes,
196261
gateways,
262+
node_locations,
263+
node_locations_fn,
197264
auto_connect_peers,
198265
timeout_secs,
199266
startup_wait_secs,

0 commit comments

Comments
 (0)