Skip to content

Commit 6e119f3

Browse files
author
Threepwood-7
committed
RUST-PAR-017: release reask leases by the TCP key, not the UDP endpoint
DL-11: ReaskEvent::SourceReleased carried the peer's UDP routing endpoint (ip, udp_port), but the consumer release_direct_download_source_leases (crates/emulebb-core/src/lib.rs) matches it against TCP-endpoint-keyed sets: active_download_peer_endpoints and the download source registry leases are keyed by source_endpoint_key = (ip, tcp_port), and the detach-exclusion path retains the lease under that same TCP key. Since a peer's UDP port normally differs from its TCP port (4672 vs 4662), every release raised on the reask drop paths (FNF DropSource, low-rank re-engage, Remove command, RetryTcp timeout) was a silent no-op: the lease leaked, acquire_* deferred the endpoint forever, and the source could never be re-engaged until disconnect_ed2k's full lease reset. Fix: carry core's lease key through the detach registration and address all release events by it. - ReaskDetachArgs gains tcp_port (crates/emulebb-ed2k/src/ed2k_client_udp/ runtime.rs); both producers set it (the TCP session detach passes the connected socket's remote port in ed2k_tcp/download/session/reask_detach.rs, the Kad buddy path passes source.tcp_port in crates/emulebb-core/src/ed2k_buddy_reask.rs). - ReaskSource stores lease_endpoint = (ip, tcp_port) (ed2k_client_udp/ state.rs); ReaskService::remove_source returns it and RoutedReply carries it (service.rs, source_set.rs), so every SourceReleased on the four drop paths now addresses the lease core actually holds. - SourceDead is unchanged: it keeps the UDP endpoint and core's (ip, file) registry resolver, which keys on the IP only. - NNP keying audited and left as is: registry NNP holds are (ip, tcp_port) in the registry keyspace, and the MarkNoNeededParts loop command is (ip, udp_port) in the loop keyspace - both consistent. Tests pin the contract with distinct UDP/TCP ports on every path (runtime, service, detach-args producers), plus a core test asserting a UDP endpoint never matches the TCP-keyed lease sets while source_endpoint_key frees them. Pure internal bookkeeping; no wire behavior changes.
1 parent 814a489 commit 6e119f3

10 files changed

Lines changed: 217 additions & 35 deletions

File tree

crates/emulebb-core/src/ed2k_buddy_reask.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,10 @@ pub(crate) fn detach_kad_buddy_sources_for_reask(
6262
let registered = reask_handle.register_kad_buddy_source(ReaskDetachArgs {
6363
file_hash,
6464
endpoint,
65+
// Core's lease key port (source_endpoint_key). A buddy source holds
66+
// no direct-download lease, but its release events must still be
67+
// addressed consistently by the TCP key.
68+
tcp_port: source.tcp_port,
6569
udp_version: ED2K_DEFAULT_UDP_VERSION,
6670
// Kad buddy sources have no direct TCP file request to timestamp, so
6771
// the first buddy-relayed UDP reask is due immediately.
@@ -155,6 +159,7 @@ mod tests {
155159
assert_eq!(args.file_hash, file_hash);
156160
assert!(args.low_id);
157161
assert_eq!(args.endpoint, (Ipv4Addr::new(192, 0, 2, 77), 4672));
162+
assert_eq!(args.tcp_port, 4662);
158163
assert_eq!(args.buddy_id, Some([0x5a; 16]));
159164
assert_eq!(
160165
args.buddy_endpoint,

crates/emulebb-core/src/ed2k_net_drivers.rs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -296,7 +296,9 @@ async fn handle_reask_event(
296296
// download cycle — or the SourceReady that follows — can re-acquire
297297
// and reconnect this endpoint over TCP. Without this the endpoint
298298
// stays leased forever and acquire_direct_download_source_leases
299-
// defers it, leaking the lease and killing re-engage.
299+
// defers it, leaking the lease and killing re-engage. `endpoint` is
300+
// the source's TCP endpoint (the loop carries core's lease key,
301+
// RUST-PAR-017 DL-11), so it matches these TCP-keyed sets directly.
300302
core.release_direct_download_source_leases(&[endpoint])
301303
.await;
302304
}

crates/emulebb-core/src/lib.rs

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11249,6 +11249,68 @@ mod tests {
1124911249
assert!(re_retry_delay.is_some());
1125011250
}
1125111251

11252+
#[tokio::test]
11253+
async fn lease_release_is_tcp_keyed_so_a_udp_endpoint_never_matches() {
11254+
// RUST-PAR-017 DL-11: core's lease sets (active_download_peer_endpoints +
11255+
// the registry leased peers) are keyed by (ip, tcp_port), while the UDP
11256+
// reask loop routes sources by (ip, udp_port). A SourceReleased carrying
11257+
// the UDP endpoint therefore releases NOTHING — the lease leaks and the
11258+
// source can never be re-engaged. This pins the constraint that forced
11259+
// the loop to carry the TCP lease key in its release events.
11260+
let core = EmulebbCore::new_in_memory("test", FileIndex::in_memory().unwrap()).unwrap();
11261+
let file_hash = Ed2kHash::from_bytes([0x5b; 16]).to_string();
11262+
let ip = Ipv4Addr::new(192, 0, 2, 60);
11263+
let tcp_port = 4662u16;
11264+
let udp_port = 4672u16;
11265+
let source = direct_test_source(Ed2kHash::from_bytes([0x5b; 16]), ip, tcp_port);
11266+
{
11267+
let mut state = core.state.lock().await;
11268+
state.download_source_registry.add_candidate(
11269+
Instant::now(),
11270+
DownloadSourceCandidate {
11271+
file_hash: file_hash.clone(),
11272+
file_priority: 5,
11273+
needed_parts: 4,
11274+
rare_parts: 0,
11275+
source: source.clone(),
11276+
last_seen: Instant::now(),
11277+
},
11278+
);
11279+
}
11280+
let (engaged, _, _) = core
11281+
.acquire_direct_download_source_leases(&file_hash, std::slice::from_ref(&source))
11282+
.await;
11283+
assert_eq!(engaged, vec![source.clone()]);
11284+
11285+
// Releasing by the peer's UDP endpoint (what the reask loop routes on)
11286+
// must not free the TCP-keyed lease — the endpoints live in different
11287+
// keyspaces, so this is a no-op by construction.
11288+
core.release_direct_download_source_leases(&[(ip, udp_port)])
11289+
.await;
11290+
{
11291+
let state = core.state.lock().await;
11292+
assert_eq!(
11293+
state.active_download_peer_endpoints.len(),
11294+
1,
11295+
"a UDP endpoint must not match the TCP-keyed active set"
11296+
);
11297+
assert_eq!(
11298+
state.download_source_registry.leased_peer_count(),
11299+
1,
11300+
"a UDP endpoint must not match the TCP-keyed registry lease"
11301+
);
11302+
}
11303+
11304+
// Releasing by the TCP lease key (what SourceReleased now carries) frees it.
11305+
core.release_direct_download_source_leases(&[source_endpoint_key(&source)])
11306+
.await;
11307+
{
11308+
let state = core.state.lock().await;
11309+
assert!(state.active_download_peer_endpoints.is_empty());
11310+
assert_eq!(state.download_source_registry.leased_peer_count(), 0);
11311+
}
11312+
}
11313+
1125211314
#[tokio::test]
1125311315
async fn run_attempt_stops_immediately_when_pre_cancelled() {
1125411316
// The requery loop checks the per-hash cancel token at the top of each

crates/emulebb-ed2k/src/ed2k_client_udp/runtime.rs

Lines changed: 38 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,12 @@ pub type ReaskCommandReceiver = mpsc::Receiver<ReaskCommand>;
9090
pub struct ReaskDetachArgs {
9191
pub file_hash: Ed2kHash,
9292
pub endpoint: (Ipv4Addr, u16),
93+
/// The source's eD2k **TCP** port. Core's lease bookkeeping
94+
/// (`active_download_peer_endpoints` + the download source registry) is keyed
95+
/// by `(ip, tcp_port)` while `endpoint` is the UDP routing key, so the loop
96+
/// must address `SourceReleased` events by `(endpoint.ip, tcp_port)` or the
97+
/// release never matches and the lease leaks (RUST-PAR-017 DL-11).
98+
pub tcp_port: u16,
9399
pub udp_version: u8,
94100
/// Delay before the first detached UDP reask is due.
95101
///
@@ -179,6 +185,10 @@ pub enum ReaskEvent {
179185
/// registry. The loop raises this whenever it drops a detached source, so core
180186
/// frees the lease and a later cycle (or the following `SourceReady`) reconnects
181187
/// it over TCP. Must precede the `SourceReady` for the same source.
188+
///
189+
/// `endpoint` is the source's **TCP** endpoint (`ReaskDetachArgs::tcp_port`) —
190+
/// the key core leased it under — NOT the UDP endpoint the loop routes on;
191+
/// core matches it directly against its TCP-keyed lease sets.
182192
SourceReleased { endpoint: (Ipv4Addr, u16) },
183193
/// A detached source UDP-answered `OP_FILENOTFOUND` for `file_hash` (oracle
184194
/// `CUpDownClient::UDPReaskFNF`, `DownloadClient.cpp:1774-1795`): besides the
@@ -334,23 +344,32 @@ fn routed_reply_events(
334344
action: ReaskAction,
335345
file_hash: Ed2kHash,
336346
endpoint: (Ipv4Addr, u16),
347+
lease_endpoint: (Ipv4Addr, u16),
337348
) -> Vec<ReaskEvent> {
338349
match action {
339350
// Slot imminent: release the lease, then ask core to reconnect over TCP.
351+
// The release carries the TCP lease key, not the UDP routing endpoint
352+
// (core's lease sets are TCP-keyed; RUST-PAR-017 DL-11).
340353
ReaskAction::UpdatedRank(rank) if rank <= REENGAGE_RANK_THRESHOLD => vec![
341-
ReaskEvent::SourceReleased { endpoint },
354+
ReaskEvent::SourceReleased {
355+
endpoint: lease_endpoint,
356+
},
342357
ReaskEvent::SourceReady { file_hash },
343358
],
344359
// Uploader no longer has the file (OP_FILENOTFOUND): dead-list it first
345360
// (oracle UDPReaskFNF AddDeadSource, DownloadClient.cpp:1781), then free
346361
// its lease. Order matters: the dead-list gate must be in place before
347-
// the released source becomes re-acquirable.
362+
// the released source becomes re-acquirable. SourceDead keeps the UDP
363+
// endpoint (core's FNF resolver keys on the IP only); the release again
364+
// carries the TCP lease key.
348365
ReaskAction::DropSource => vec![
349366
ReaskEvent::SourceDead {
350367
file_hash,
351368
endpoint,
352369
},
353-
ReaskEvent::SourceReleased { endpoint },
370+
ReaskEvent::SourceReleased {
371+
endpoint: lease_endpoint,
372+
},
354373
],
355374
// Still queued / transient: keep the source on UDP reask, lease unchanged.
356375
_ => Vec::new(),
@@ -411,6 +430,7 @@ async fn handle_inbound_datagram(
411430
ReaskInboundOutcome::RoutedReply {
412431
file_hash,
413432
endpoint,
433+
lease_endpoint,
414434
action,
415435
} => {
416436
trace!("ed2k udp reask: routed reply from {from}: {action:?} (file {file_hash})");
@@ -419,7 +439,7 @@ async fn handle_inbound_datagram(
419439
service.remove_source(endpoint.0, endpoint.1);
420440
}
421441
// Emit loop->core events (lease release ordered before any re-engage).
422-
for event in routed_reply_events(action, file_hash, endpoint) {
442+
for event in routed_reply_events(action, file_hash, endpoint, lease_endpoint) {
423443
if let ReaskEvent::SourceReady { file_hash } = &event {
424444
trace!("ed2k udp reask: re-engage SourceReady for {file_hash}");
425445
}
@@ -490,6 +510,7 @@ fn apply_reask_command(
490510
let ReaskDetachArgs {
491511
file_hash,
492512
endpoint,
513+
tcp_port,
493514
udp_version,
494515
initial_reask_delay,
495516
user_hash,
@@ -499,7 +520,8 @@ fn apply_reask_command(
499520
buddy_id,
500521
} = args;
501522
let now = Instant::now();
502-
let mut source = ReaskSource::new(endpoint, file_hash, udp_version, now);
523+
let mut source = ReaskSource::new(endpoint, file_hash, udp_version, now)
524+
.with_lease_endpoint((endpoint.0, tcp_port));
503525
source.next_reask = now + initial_reask_delay;
504526
if let Some(hash) = user_hash {
505527
source = source.with_obfuscation(hash, should_crypt);
@@ -513,9 +535,13 @@ fn apply_reask_command(
513535
}
514536
ReaskCommand::Remove { endpoint } => {
515537
// Only release the lease if this endpoint was a detached source we held;
516-
// a Remove for an unknown endpoint must not free a lease core never gave us.
517-
if service.remove_source(endpoint.0, endpoint.1) {
518-
let _ = events.send(ReaskEvent::SourceReleased { endpoint });
538+
// a Remove for an unknown endpoint must not free a lease core never gave
539+
// us. The release carries the source's TCP lease key, not the UDP
540+
// routing endpoint (core's lease sets are TCP-keyed).
541+
if let Some(lease_endpoint) = service.remove_source(endpoint.0, endpoint.1) {
542+
let _ = events.send(ReaskEvent::SourceReleased {
543+
endpoint: lease_endpoint,
544+
});
519545
}
520546
}
521547
ReaskCommand::MarkNoNeededParts { endpoint } => {
@@ -640,8 +666,10 @@ async fn drive_reask_tick(
640666
&& let SocketAddr::V4(v4) = addr
641667
{
642668
let endpoint = (*v4.ip(), v4.port());
643-
if service.remove_source(endpoint.0, endpoint.1) {
644-
let _ = events.send(ReaskEvent::SourceReleased { endpoint });
669+
if let Some(lease_endpoint) = service.remove_source(endpoint.0, endpoint.1) {
670+
let _ = events.send(ReaskEvent::SourceReleased {
671+
endpoint: lease_endpoint,
672+
});
645673
}
646674
}
647675
}

crates/emulebb-ed2k/src/ed2k_client_udp/runtime/tests.rs

Lines changed: 40 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -12,13 +12,18 @@ fn register_command_adds_a_source_and_remove_drops_it() {
1212
let mut svc = service();
1313
let (events, mut rx) = reask_event_channel();
1414
let file_hash = Ed2kHash::from_bytes([0xAB; 16]);
15+
// Distinct UDP routing port (4672) vs TCP lease port (4662): core's lease
16+
// sets are keyed by (ip, tcp_port), so a SourceReleased carrying the UDP
17+
// endpoint would never match and the lease would leak (RUST-PAR-017 DL-11).
1518
let endpoint = (Ipv4Addr::new(198, 51, 100, 7), 4672);
19+
let lease_endpoint = (Ipv4Addr::new(198, 51, 100, 7), 4662);
1620
apply_reask_command(
1721
&mut svc,
1822
&events,
1923
ReaskCommand::Register(ReaskDetachArgs {
2024
file_hash,
2125
endpoint,
26+
tcp_port: lease_endpoint.1,
2227
udp_version: 4,
2328
initial_reask_delay: Duration::ZERO,
2429
user_hash: Some([0x55; 16]),
@@ -36,7 +41,10 @@ fn register_command_adds_a_source_and_remove_drops_it() {
3641
assert_eq!(svc.source_count(), 0);
3742
assert!(svc.registered_file_hashes().is_empty());
3843
match rx.try_recv().expect("a SourceReleased event") {
39-
ReaskEvent::SourceReleased { endpoint: released } => assert_eq!(released, endpoint),
44+
ReaskEvent::SourceReleased { endpoint: released } => assert_eq!(
45+
released, lease_endpoint,
46+
"the release must carry the TCP lease key, not the UDP routing endpoint"
47+
),
4048
other => panic!("expected SourceReleased, got {other:?}"),
4149
}
4250
}
@@ -53,6 +61,7 @@ fn register_command_respects_initial_reask_delay() {
5361
ReaskCommand::Register(ReaskDetachArgs {
5462
file_hash,
5563
endpoint,
64+
tcp_port: 4662,
5665
udp_version: 4,
5766
initial_reask_delay: crate::ed2k_client_udp::FILE_REASK_TIME,
5867
user_hash: None,
@@ -106,14 +115,19 @@ fn remove_command_for_unknown_endpoint_releases_no_lease() {
106115
fn reengage_releases_lease_before_signalling_source_ready() {
107116
let file_hash = Ed2kHash::from_bytes([0x33; 16]);
108117
let endpoint = (Ipv4Addr::new(198, 51, 100, 9), 4672);
118+
let lease_endpoint = (Ipv4Addr::new(198, 51, 100, 9), 4662);
109119
let events = routed_reply_events(
110120
ReaskAction::UpdatedRank(REENGAGE_RANK_THRESHOLD),
111121
file_hash,
112122
endpoint,
123+
lease_endpoint,
113124
);
114125
assert_eq!(events.len(), 2);
115126
match &events[0] {
116-
ReaskEvent::SourceReleased { endpoint: released } => assert_eq!(*released, endpoint),
127+
ReaskEvent::SourceReleased { endpoint: released } => assert_eq!(
128+
*released, lease_endpoint,
129+
"the release must carry the TCP lease key, not the UDP routing endpoint"
130+
),
117131
other => panic!("expected SourceReleased first, got {other:?}"),
118132
}
119133
match &events[1] {
@@ -128,6 +142,7 @@ fn deep_rank_keeps_source_and_releases_no_lease() {
128142
ReaskAction::UpdatedRank(REENGAGE_RANK_THRESHOLD + 1),
129143
Ed2kHash::from_bytes([0x44; 16]),
130144
(Ipv4Addr::new(198, 51, 100, 10), 4672),
145+
(Ipv4Addr::new(198, 51, 100, 10), 4662),
131146
);
132147
assert!(
133148
events.is_empty(),
@@ -139,10 +154,13 @@ fn deep_rank_keeps_source_and_releases_no_lease() {
139154
fn dropped_source_is_dead_listed_then_releases_its_lease() {
140155
// UDP FNF (oracle UDPReaskFNF, DownloadClient.cpp:1774-1795): the source is
141156
// dead-listed BEFORE its lease is released, so the 45-minute block gates
142-
// re-acquisition the moment the endpoint becomes free again.
157+
// re-acquisition the moment the endpoint becomes free again. SourceDead
158+
// carries the UDP endpoint (core resolves by IP); SourceReleased carries
159+
// the TCP lease key core's sets match against (RUST-PAR-017 DL-11).
143160
let endpoint = (Ipv4Addr::new(198, 51, 100, 11), 4672);
161+
let lease_endpoint = (Ipv4Addr::new(198, 51, 100, 11), 4662);
144162
let hash = Ed2kHash::from_bytes([0x55; 16]);
145-
let events = routed_reply_events(ReaskAction::DropSource, hash, endpoint);
163+
let events = routed_reply_events(ReaskAction::DropSource, hash, endpoint, lease_endpoint);
146164
assert_eq!(events.len(), 2);
147165
match &events[0] {
148166
ReaskEvent::SourceDead {
@@ -155,7 +173,10 @@ fn dropped_source_is_dead_listed_then_releases_its_lease() {
155173
other => panic!("expected SourceDead, got {other:?}"),
156174
}
157175
match &events[1] {
158-
ReaskEvent::SourceReleased { endpoint: released } => assert_eq!(*released, endpoint),
176+
ReaskEvent::SourceReleased { endpoint: released } => assert_eq!(
177+
*released, lease_endpoint,
178+
"the release must carry the TCP lease key, not the UDP routing endpoint"
179+
),
159180
other => panic!("expected SourceReleased, got {other:?}"),
160181
}
161182
}
@@ -166,12 +187,14 @@ fn retry_tcp_timeout_releases_held_lease() {
166187
let (events, mut rx) = reask_event_channel();
167188
let file_hash = Ed2kHash::from_bytes([0x66; 16]);
168189
let endpoint = (Ipv4Addr::new(198, 51, 100, 12), 4672);
190+
let lease_endpoint = (Ipv4Addr::new(198, 51, 100, 12), 4662);
169191
apply_reask_command(
170192
&mut svc,
171193
&events,
172194
ReaskCommand::Register(ReaskDetachArgs {
173195
file_hash,
174196
endpoint,
197+
tcp_port: lease_endpoint.1,
175198
udp_version: 4,
176199
initial_reask_delay: Duration::ZERO,
177200
user_hash: None,
@@ -181,13 +204,19 @@ fn retry_tcp_timeout_releases_held_lease() {
181204
buddy_id: None,
182205
}),
183206
);
184-
assert!(svc.remove_source(endpoint.0, endpoint.1));
185-
let _ = events.send(ReaskEvent::SourceReleased { endpoint });
207+
// remove_source hands back the TCP lease key the release event must carry.
208+
assert_eq!(
209+
svc.remove_source(endpoint.0, endpoint.1),
210+
Some(lease_endpoint)
211+
);
212+
let _ = events.send(ReaskEvent::SourceReleased {
213+
endpoint: lease_endpoint,
214+
});
186215
match rx.try_recv().expect("a SourceReleased event") {
187-
ReaskEvent::SourceReleased { endpoint: released } => assert_eq!(released, endpoint),
216+
ReaskEvent::SourceReleased { endpoint: released } => assert_eq!(released, lease_endpoint),
188217
other => panic!("expected SourceReleased, got {other:?}"),
189218
}
190-
assert!(!svc.remove_source(endpoint.0, endpoint.1));
219+
assert_eq!(svc.remove_source(endpoint.0, endpoint.1), None);
191220
}
192221

193222
#[tokio::test]
@@ -258,6 +287,7 @@ fn detach_handle_register_is_received_as_a_command() {
258287
assert!(handle.register_kad_buddy_source(ReaskDetachArgs {
259288
file_hash,
260289
endpoint: (Ipv4Addr::new(10, 0, 0, 1), 5000),
290+
tcp_port: 4662,
261291
udp_version: 4,
262292
initial_reask_delay: Duration::ZERO,
263293
user_hash: None,
@@ -269,6 +299,7 @@ fn detach_handle_register_is_received_as_a_command() {
269299
match rx.try_recv().expect("a queued command") {
270300
ReaskCommand::Register(args) => {
271301
assert_eq!(args.endpoint, (Ipv4Addr::new(10, 0, 0, 1), 5000));
302+
assert_eq!(args.tcp_port, 4662);
272303
assert!(args.low_id);
273304
assert_eq!(
274305
args.buddy_endpoint,

0 commit comments

Comments
 (0)