@@ -2236,15 +2236,32 @@ impl StreamingClient {
22362236 unsubscribe,
22372237 "sent full-stream subscription frame"
22382238 ) ;
2239- // Track / untrack for reconnection.
2239+ // Untrack-on-unsubscribe is terminal: remove the tracked entry, then
2240+ // evict the in-flight correlation for this identity for the same reason
2241+ // as the per-contract unsubscribe — the removed entry is no longer live,
2242+ // so a late rejection of its subscribe must not survive to untrack a
2243+ // future re-subscribe of the same `(kind, sec_type)` by value.
2244+ if unsubscribe {
2245+ self . active_full_subs
2246+ . lock ( )
2247+ . unwrap_or_else ( std:: sync:: PoisonError :: into_inner)
2248+ . retain ( |( k, s) | !( * k == kind_for_track && * s == sec_type) ) ;
2249+ io_loop:: evict_pending_for_identity (
2250+ & mut self
2251+ . pending_subs
2252+ . lock ( )
2253+ . unwrap_or_else ( std:: sync:: PoisonError :: into_inner) ,
2254+ & protocol:: PendingSub :: Full ( kind_for_track, sec_type) ,
2255+ ) ;
2256+ return Ok ( ( ) ) ;
2257+ }
2258+
2259+ // Track for reconnection.
22402260 let mut subs = self
22412261 . active_full_subs
22422262 . lock ( )
22432263 . unwrap_or_else ( std:: sync:: PoisonError :: into_inner) ;
2244- let newly_tracked = if unsubscribe {
2245- subs. retain ( |( k, s) | !( * k == kind_for_track && * s == sec_type) ) ;
2246- false
2247- } else if subs
2264+ let newly_tracked = if subs
22482265 . iter ( )
22492266 . any ( |( k, s) | * k == kind_for_track && * s == sec_type)
22502267 {
@@ -2261,10 +2278,9 @@ impl StreamingClient {
22612278 // Record the pending full-stream subscribe by `req_id` so a server
22622279 // rejection drops exactly this entry from the replay set. Only the
22632280 // subscribe that actually added the tracked entry may carry an
2264- // untrack-capable correlation: an unsubscribe removed its entry above
2265- // and has nothing to untrack, and a duplicate subscribe shares the one
2266- // live entry, so letting its rejection untrack by value would drop the
2267- // live subscription.
2281+ // untrack-capable correlation: an unsubscribe is handled terminally
2282+ // above and a duplicate subscribe shares the one live entry, so letting
2283+ // its rejection untrack by value would drop the live subscription.
22682284 if newly_tracked {
22692285 let mut pending = self
22702286 . pending_subs
@@ -2372,6 +2388,21 @@ impl StreamingClient {
23722388 subs. retain ( |( k, c) | !( k == & kind && c == contract) ) ;
23732389 }
23742390
2391+ // Evict any in-flight correlation for this identity. The removed entry
2392+ // is no longer live, so a late rejection of the subscribe that created
2393+ // it must not survive to untrack a future re-subscribe of the same
2394+ // `(kind, contract)` by value.
2395+ {
2396+ let mut pending = self
2397+ . pending_subs
2398+ . lock ( )
2399+ . unwrap_or_else ( std:: sync:: PoisonError :: into_inner) ;
2400+ io_loop:: evict_pending_for_identity (
2401+ & mut pending,
2402+ & protocol:: PendingSub :: Contract ( kind, contract. clone ( ) ) ,
2403+ ) ;
2404+ }
2405+
23752406 tracing:: debug!(
23762407 req_id,
23772408 kind = ?kind,
@@ -3425,6 +3456,140 @@ mod full_stream_guard_tests {
34253456 client. shutdown ( ) ;
34263457 }
34273458
3459+ /// Subscribe, unsubscribe, then re-subscribe the same contract, all before
3460+ /// the first subscribe's `REQ_RESPONSE` lands; a late rejection of that
3461+ /// superseded first request must not drop the re-subscribed live entry.
3462+ ///
3463+ /// The first subscribe owns the correlation for its `req_id`; the
3464+ /// unsubscribe both removes the tracked entry and evicts that correlation,
3465+ /// so when the re-subscribe re-adds the entry it owns a fresh correlation
3466+ /// under a new `req_id`. A server rejection of the obsolete first `req_id`
3467+ /// then finds no resident correlation and is a no-op, leaving the live
3468+ /// re-subscribed entry tracked and in the reconnect-replay set (which is a
3469+ /// clone of `active_subs`).
3470+ #[ test]
3471+ fn unsub_resub_then_reject_superseded_keeps_live_sub ( ) {
3472+ use super :: io_loop:: apply_req_response_for_test;
3473+ use crate :: tdbe:: types:: enums:: StreamResponseType ;
3474+
3475+ let client = StreamingClient :: for_self_join_test (
3476+ 0 ,
3477+ 64 ,
3478+ HarnessPublishMode :: BlockingPublish ,
3479+ None ,
3480+ |_event| { } ,
3481+ ) ;
3482+
3483+ // req_id counter starts at 1: subscribe -> 1, unsubscribe -> 2,
3484+ // re-subscribe -> 3.
3485+ let contract = Contract :: stock ( "AAPL" ) ;
3486+ client
3487+ . subscribe ( contract. clone ( ) . trade ( ) )
3488+ . expect ( "first subscribe" ) ;
3489+ client
3490+ . unsubscribe ( contract. clone ( ) . trade ( ) )
3491+ . expect ( "unsubscribe" ) ;
3492+ client
3493+ . subscribe ( contract. clone ( ) . trade ( ) )
3494+ . expect ( "re-subscribe" ) ;
3495+
3496+ // Exactly one resident correlation: the unsubscribe evicted the first
3497+ // subscribe's (req_id 1) correlation, and the re-subscribe registered a
3498+ // fresh one (req_id 3).
3499+ assert_eq ! (
3500+ client
3501+ . pending_subs
3502+ . lock( )
3503+ . unwrap_or_else( std:: sync:: PoisonError :: into_inner)
3504+ . len( ) ,
3505+ 1 ,
3506+ "unsubscribe must evict the superseded correlation; only the \
3507+ re-subscribe's correlation may remain"
3508+ ) ;
3509+
3510+ // The server rejects the obsolete first req_id (1). With its
3511+ // correlation already evicted this is a no-op, not a value match that
3512+ // would drop the live re-subscribed entry.
3513+ apply_req_response_for_test (
3514+ & client. pending_subs ,
3515+ & client. active_subs ,
3516+ & client. active_full_subs ,
3517+ 1 ,
3518+ StreamResponseType :: MaxStreamsReached ,
3519+ ) ;
3520+
3521+ let tracked = client. active_subscriptions ( ) ;
3522+ assert_eq ! (
3523+ tracked. len( ) ,
3524+ 1 ,
3525+ "rejecting a superseded subscribe must leave the re-subscribed entry \
3526+ tracked, got {tracked:?}"
3527+ ) ;
3528+ assert ! (
3529+ tracked. iter( ) . any( |( _, c) | * c == contract) ,
3530+ "the re-subscribed contract must remain in active_subscriptions() so \
3531+ it is replayed on reconnect, got {tracked:?}"
3532+ ) ;
3533+
3534+ client. shutdown ( ) ;
3535+ }
3536+
3537+ /// The full-stream analogue: subscribe, unsubscribe, re-subscribe a
3538+ /// full-stream `(kind, sec_type)`, then reject the superseded first
3539+ /// request — the re-subscribed full-stream entry must stay tracked.
3540+ #[ test]
3541+ fn full_unsub_resub_then_reject_superseded_keeps_live_sub ( ) {
3542+ use super :: io_loop:: apply_req_response_for_test;
3543+ use crate :: tdbe:: types:: enums:: StreamResponseType ;
3544+
3545+ let client = StreamingClient :: for_self_join_test (
3546+ 0 ,
3547+ 64 ,
3548+ HarnessPublishMode :: BlockingPublish ,
3549+ None ,
3550+ |_event| { } ,
3551+ ) ;
3552+
3553+ // subscribe -> 1, unsubscribe -> 2, re-subscribe -> 3.
3554+ client
3555+ . subscribe ( SecType :: Stock . full_trades ( ) )
3556+ . expect ( "first full subscribe" ) ;
3557+ client
3558+ . unsubscribe ( SecType :: Stock . full_trades ( ) )
3559+ . expect ( "full unsubscribe" ) ;
3560+ client
3561+ . subscribe ( SecType :: Stock . full_trades ( ) )
3562+ . expect ( "re-subscribe full" ) ;
3563+
3564+ assert_eq ! (
3565+ client
3566+ . pending_subs
3567+ . lock( )
3568+ . unwrap_or_else( std:: sync:: PoisonError :: into_inner)
3569+ . len( ) ,
3570+ 1 ,
3571+ "full-stream unsubscribe must evict the superseded correlation"
3572+ ) ;
3573+
3574+ apply_req_response_for_test (
3575+ & client. pending_subs ,
3576+ & client. active_subs ,
3577+ & client. active_full_subs ,
3578+ 1 ,
3579+ StreamResponseType :: MaxStreamsReached ,
3580+ ) ;
3581+
3582+ let tracked = client. active_full_subscriptions ( ) ;
3583+ assert_eq ! (
3584+ tracked. len( ) ,
3585+ 1 ,
3586+ "rejecting a superseded full-stream subscribe must leave the \
3587+ re-subscribed entry tracked, got {tracked:?}"
3588+ ) ;
3589+
3590+ client. shutdown ( ) ;
3591+ }
3592+
34283593 /// The command channel is bounded: once it is saturated, a further
34293594 /// `try_send` reports `Full` rather than growing without limit. This
34303595 /// pins the backpressure contract `send_cmd` relies on to surface a
0 commit comments