tsoracle-client 1.3.0

gRPC client driver for the timestamp oracle.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
1303
1304
1305
1306
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
1317
1318
1319
1320
1321
1322
1323
1324
1325
1326
1327
1328
1329
1330
1331
1332
1333
1334
1335
1336
1337
1338
1339
1340
1341
1342
1343
1344
1345
1346
1347
1348
1349
1350
1351
1352
1353
1354
1355
1356
1357
1358
1359
1360
1361
1362
1363
1364
1365
1366
1367
1368
1369
1370
1371
1372
1373
1374
1375
1376
1377
1378
1379
1380
1381
1382
1383
1384
1385
1386
1387
1388
1389
1390
1391
1392
1393
1394
1395
1396
1397
1398
1399
1400
1401
1402
1403
1404
1405
1406
1407
1408
1409
1410
1411
1412
1413
1414
1415
1416
1417
1418
1419
1420
1421
1422
1423
1424
1425
1426
1427
1428
1429
1430
1431
1432
1433
1434
1435
1436
1437
1438
1439
1440
1441
1442
1443
1444
1445
1446
1447
1448
1449
1450
1451
1452
1453
1454
1455
1456
1457
1458
1459
1460
1461
1462
1463
1464
1465
1466
1467
1468
1469
1470
1471
1472
1473
1474
1475
1476
1477
1478
1479
1480
1481
1482
1483
1484
1485
1486
1487
1488
1489
1490
1491
1492
1493
1494
1495
1496
1497
1498
1499
1500
1501
1502
1503
1504
1505
1506
1507
1508
1509
1510
1511
1512
1513
1514
1515
1516
1517
1518
1519
1520
1521
1522
1523
1524
1525
1526
1527
1528
1529
1530
1531
1532
1533
1534
1535
1536
1537
1538
1539
1540
1541
1542
1543
1544
1545
1546
1547
1548
1549
1550
1551
1552
1553
1554
1555
1556
1557
1558
1559
1560
1561
1562
1563
1564
1565
1566
1567
1568
1569
1570
1571
1572
1573
1574
1575
1576
1577
1578
1579
1580
1581
1582
1583
1584
1585
1586
1587
1588
1589
1590
1591
1592
1593
1594
1595
1596
1597
1598
1599
1600
1601
1602
1603
1604
1605
1606
1607
1608
1609
1610
1611
//
//  ░▀█▀░█▀▀░█▀█░█▀▄░█▀█░█▀▀░█░░░█▀▀
//  ░░█░░▀▀█░█░█░█▀▄░█▀█░█░░░█░░░█▀▀
//  ░░▀░░▀▀▀░▀▀▀░▀░▀░▀░▀░▀▀▀░▀▀▀░▀▀▀
//
//  tsoracle — Distributed Timestamp Oracle
//  https://www.tsoracle.rs
//
//  Copyright (c) 2026 Prisma Risk
//
//  Licensed under the Apache License, Version 2.0 (the "License");
//  you may not use this file except in compliance with the License.
//  You may obtain a copy of the License at
//
//      https://www.apache.org/licenses/LICENSE-2.0
//
//  Unless required by applicable law or agreed to in writing, software
//  distributed under the License is distributed on an "AS IS" BASIS,
//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
//  See the License for the specific language governing permissions and
//  limitations under the License.
//

//! Channel pool with leader-cache and NOT_LEADER redirect handling.

use parking_lot::Mutex;
use std::collections::HashMap;
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
use tokio::sync::OnceCell;
use tokio::time::Instant;
use tonic::transport::{Channel, Endpoint};
use tsoracle_proto::v1::tso_service_client::TsoServiceClient;

use crate::RetryPolicy;
use crate::error::ClientError;
use crate::transport::apply_endpoint_config;

// The leader-hint trailer key, the `LeaderHintLookup` classifier, and the
// `decode_leader_hint` helper now live in `tsoracle-proto` as the single
// source of truth for the wire contract (the server inserts the trailer; this
// client decodes it). Re-exported here so the retry loop's existing
// `crate::channel_pool::{LeaderHintLookup, decode_leader_hint}` import path
// is unchanged.
pub use tsoracle_proto::v1::{LeaderHintLookup, decode_leader_hint};

/// Upper bound on the number of *non-configured* (wire-supplied leader-hint)
/// channels the pool retains. Configured endpoints are operator-supplied and
/// bounded, so they are exempt; hint endpoints (`LeaderHint.leader_endpoint`)
/// are attacker-influenced strings, so a peer handing back a stream of
/// distinct but reachable decoys would otherwise pin an unbounded number of
/// live `Channel`s and their background reconnect tasks (issue #341). Once the
/// non-configured entries exceed this cap, the least-recently-used one is
/// evicted; the active leader, dialed on every request, stays most-recently-
/// used and is never the victim. The total map size is therefore bounded at
/// `configured.len() + MAX_HINT_CHANNELS`.
const MAX_HINT_CHANNELS: usize = 8;

/// Cached pointer to the endpoint that most recently behaved like the
/// leader, along with the epoch that confirmed it and the instant the
/// cache was last validated. `epoch` is `Option<u128>` (the full leader
/// epoch, reassembled from the wire's two 64-bit halves) so an old server
/// that emits NOT_LEADER hints without an epoch (or a wire payload arriving
/// before any successful GetTs has populated the epoch) can still seat a
/// cache entry; once any source provides an epoch, monotone-forward
/// comparisons take over.
#[derive(Debug, Clone)]
pub(crate) struct CachedLeader {
    pub endpoint: String,
    pub epoch: Option<u128>,
    pub last_used: Instant,
}

/// Which writer is seating the leader cache. Both writers share one
/// monotone-forward rule (see [`ChannelPool::seat_leader`]); the source only
/// changes the trust model in the ambiguous epoch-less, different-endpoint
/// case.
///
/// A `Confirmed` seat is backed by a *succeeded* RPC: hard proof the endpoint
/// served as leader at the supplied epoch, but the completion can be
/// arbitrarily delayed (a late response from a since-deposed term), so it may
/// be stale by the time it lands. A `Hinted` seat is a third-party NOT_LEADER
/// claim — a *forward-looking* "the leader is now X" assertion from a peer we
/// just contacted. The asymmetry only decides the case where the cache holds
/// no rankable epoch: a hint carrying fresh information seats, but a
/// possibly-stale success that cannot prove it outranks the fresh entry must
/// not clobber it (issue H2).
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
pub(crate) enum SeatSource {
    Confirmed,
    Hinted,
}

/// Merge an incoming epoch into the cached one without ever moving backward:
/// the `max` of two known epochs, and a known epoch is never downgraded to
/// `None`. A `None` cached epoch is upgraded to any incoming `Some`. Used by
/// the same-endpoint path, where a late, lower-epoch (or epoch-less) write
/// must keep the highest epoch the endpoint has been observed at.
fn merge_epoch_forward(cached: Option<u128>, incoming: Option<u128>) -> Option<u128> {
    match (cached, incoming) {
        (Some(cached), Some(incoming)) => Some(cached.max(incoming)),
        (Some(cached), None) => Some(cached),
        (None, incoming) => incoming,
    }
}

/// One pooled channel slot: the lazily-dialed channel cell plus the instant it
/// was last handed out. `last_used` drives the LRU eviction that bounds the
/// number of non-configured (hint-derived) entries (issue #341); it is
/// refreshed on every `client_with_cell` lookup — including cache hits — so the
/// endpoint dialed on every request (the active leader) stays most-recently-
/// used and is never chosen as a victim.
struct PooledChannel {
    cell: Arc<OnceCell<Channel>>,
    last_used: Instant,
}

pub struct ChannelPool {
    configured: Vec<String>,
    /// One lazily-dialed channel per endpoint. The `parking_lot::Mutex`
    /// guards only the map structure — never an `await` — so it stays a
    /// cheap synchronous lock. Each value is an `Arc<OnceCell<Channel>>`:
    /// concurrent first-callers to the same endpoint look up (or insert) the
    /// shared cell under the lock, drop the lock, then race into the cell's
    /// `get_or_try_init`, which runs the dial exactly once. A failed dial
    /// leaves the cell uninitialized so the next caller retries.
    ///
    /// Non-configured (hint-derived) entries are capped at
    /// [`MAX_HINT_CHANNELS`] via LRU eviction keyed on each slot's `last_used`;
    /// configured endpoints are exempt. See [`Self::enforce_hint_cap`].
    channels: Mutex<HashMap<String, PooledChannel>>,
    leader: Mutex<Option<CachedLeader>>,
    connector: Option<std::sync::Arc<crate::transport::ChannelConnector>>,
    /// Set by `ClientBuilder::tls_config`; cleared by `channel_connector`.
    /// Tells the retry loop to drop wire-supplied `http://` leader hints so
    /// a contacted peer cannot downgrade the transport. Has no effect on
    /// operator-supplied endpoints; those use the documented scheme rule
    /// ("explicit beats configured") unchanged.
    tls_required: bool,
    /// Frozen at builder time. The pool uses `per_attempt_deadline` plus
    /// the keepalive constants to build each `Endpoint`; the retry loop
    /// reads the same policy via [`Self::retry_policy`] to drive its
    /// per-attempt and overall deadlines.
    retry_policy: RetryPolicy,
    /// Per-pool round-robin cursor for `iter_round_robin`. Seeded *randomly*
    /// at construction so that different client instances start their
    /// cold-cache probes at different configured endpoints — a fleet cold
    /// start therefore spreads across nodes instead of every pool opening on
    /// `configured[0]`. Advanced with a `Relaxed` `fetch_add` per worklist;
    /// it carries no cross-thread invariant, so `Relaxed` is sufficient. A
    /// hash of the configured endpoints would *not* distribute load — every
    /// client ships the same endpoint list — so the seed must be random.
    rotation: AtomicUsize,
    /// Test-only count of how many times [`Self::enforce_hint_cap`] has
    /// actually run. The cap sweep is an O(n) `keys().filter()` scan (plus a
    /// `min_by_key` on overflow) under the synchronous map lock, gated to fire
    /// only when a brand-new slot is inserted — a cache hit reuses an existing
    /// slot and cannot push the non-configured count over the cap. That gating
    /// is invisible in the map's final state (a cache-hit sweep would be a
    /// guaranteed no-op), so the only way to assert it is to observe that the
    /// sweep did not run; this counter is that observation. `#[cfg(test)]`, so
    /// it does not exist in production builds.
    #[cfg(test)]
    hint_cap_sweeps: AtomicUsize,
}

impl ChannelPool {
    pub fn new(
        endpoints: Vec<String>,
        connector: Option<std::sync::Arc<crate::transport::ChannelConnector>>,
        tls_required: bool,
        retry_policy: RetryPolicy,
    ) -> Self {
        ChannelPool {
            configured: endpoints,
            channels: Mutex::new(HashMap::new()),
            leader: Mutex::new(None),
            connector,
            tls_required,
            retry_policy,
            rotation: AtomicUsize::new(rand::random_range(0..=usize::MAX)),
            #[cfg(test)]
            hint_cap_sweeps: AtomicUsize::new(0),
        }
    }

    /// True when the built-in TLS connector is in use. The retry loop uses
    /// this to refuse wire-supplied `http://` leader hints; see
    /// `crate::retry::issue_rpc`.
    pub fn tls_required(&self) -> bool {
        self.tls_required
    }

    pub fn retry_policy(&self) -> &RetryPolicy {
        &self.retry_policy
    }

    /// The currently-cached leader endpoint, or `None` if no leader has
    /// been observed yet or the cache has aged past `leader_ttl`. The
    /// TTL check is lazy: an expired entry is treated as absent on
    /// read; the underlying slot is cleared on the next mutation.
    pub fn cached_leader(&self) -> Option<String> {
        self.fresh_leader().map(|cached| cached.endpoint)
    }

    /// Internal helper returning the full `CachedLeader` only when it is
    /// within the configured `leader_ttl`. Used by `cached_leader` (and
    /// thus `iter_round_robin`) and the test surface. The monotone-forward
    /// freshness check in `seat_leader` is inlined there instead of routed
    /// through this helper, because that path must hold the lock across both
    /// the check and the write.
    pub(crate) fn fresh_leader(&self) -> Option<CachedLeader> {
        let guard = self.leader.lock();
        match &*guard {
            Some(cached) if cached.last_used.elapsed() < self.retry_policy.leader_ttl => {
                Some(cached.clone())
            }
            _ => None,
        }
    }

    /// Record a successful RPC against `endpoint` that observed the leader at
    /// `epoch`. A thin [`SeatSource::Confirmed`] wrapper over
    /// [`Self::seat_leader`], which owns the monotone-forward rule. A
    /// late-completing RPC against a since-deposed leader — a normal failover
    /// artifact, or out-of-order completion of two coalesced retries — carries
    /// a stale epoch, so the rule never lowers the cache: a same-endpoint
    /// success `max`es the epoch, and a different-endpoint success may unseat a
    /// fresh leader only when it proves it outranks it.
    pub(crate) fn record_success(&self, endpoint: &str, epoch: u128) {
        self.seat_leader(endpoint, Some(epoch), SeatSource::Confirmed);
    }

    /// Apply the monotone-forward rule and, if it holds, seat `endpoint`/`epoch`
    /// as the cached leader; returns whether the write happened. A thin
    /// [`SeatSource::Hinted`] wrapper over [`Self::seat_leader`], where the
    /// check and the write share one lock acquisition, so a concurrent
    /// higher-epoch promotion cannot land between "the hint passed the gate"
    /// and "the hint was written" and then be clobbered by this lower-or-equal
    /// hint.
    pub(crate) fn compare_and_set_leader(&self, endpoint: String, epoch: Option<u128>) -> bool {
        self.seat_leader(&endpoint, epoch, SeatSource::Hinted)
    }

    /// The single monotone-forward writer to the leader cache. Both
    /// [`Self::record_success`] and [`Self::compare_and_set_leader`] are thin
    /// typed wrappers over it, so the rule that decides whether a
    /// `(endpoint, epoch)` pair may seat the cache lives in exactly one place.
    /// A monotone-forward gate is only as strong as its weakest writer, and two
    /// hand-mirrored implementations were the root cause of issue H2: the
    /// `record_success` mirror defended a *known*-epoch entry (issue #333) but
    /// left a `None`-epoch entry — the epoch-less / mixed-version case, where
    /// it matters most — unguarded, so a late cross-endpoint success flapped
    /// the cache back to a deposed leader.
    ///
    /// The lock is held across the whole decision-and-write. Returns whether
    /// the cache was (re)seated at `endpoint`. The rule, given the cache state:
    ///
    /// - **Absent or expired entry:** no monotone floor — accept and seat.
    /// - **Same endpoint:** accept, refresh `last_used`, and merge the epoch
    ///   forward (`max` of the known epochs; a known epoch is never downgraded
    ///   to `None`). The endpoint just re-proved itself (a success) or was
    ///   re-named as leader (a hint), so it keeps its slot regardless of the
    ///   observed epoch. Reviving an expired same-endpoint entry carries no
    ///   flap risk, so this path ignores freshness.
    /// - **Different endpoint, fresh entry:** a write may unseat a fresh leader
    ///   only when it can *prove* it outranks it.
    ///   - **Both epochs known:** accept iff the incoming epoch is `>=` the
    ///     cached one (the monotone-forward gate; rejects a strictly-stale
    ///     write — issue #333).
    ///   - **Known cached epoch, epoch-less write:** no epoch to rank. A
    ///     `Hinted` write seats only when it names a *configured* endpoint —
    ///     one we would dial in round-robin anyway (issue #357); a `Confirmed`
    ///     write cannot reach here (a successful GetTs always carries a wire
    ///     epoch) and defends the entry to keep the rule total.
    ///   - **Unknown cached epoch:** no monotone floor. A `Hinted` write seats
    ///     (the bootstrap / old-server path); a `Confirmed` write does *not* —
    ///     a possibly-stale past proof with no rankable epoch must not flap the
    ///     fresh entry back to a possibly-deposed leader (issue H2). The cache
    ///     still converges, because an alive-but-follower endpoint redirects via
    ///     the hint path and a fully-dead one self-heals at `leader_ttl`.
    fn seat_leader(&self, endpoint: &str, epoch: Option<u128>, source: SeatSource) -> bool {
        let mut guard = self.leader.lock();
        match &mut *guard {
            // Same endpoint: refresh and merge the epoch forward, regardless of
            // freshness.
            Some(cached) if cached.endpoint == endpoint => {
                cached.epoch = merge_epoch_forward(cached.epoch, epoch);
                cached.last_used = Instant::now();
                true
            }
            // Different endpoint while the entry is still fresh.
            Some(cached) if cached.last_used.elapsed() < self.retry_policy.leader_ttl => {
                let accept = match (cached.epoch, epoch) {
                    (Some(cached_epoch), Some(incoming)) => incoming >= cached_epoch,
                    (Some(_), None) => match source {
                        SeatSource::Hinted => self.is_configured(endpoint),
                        SeatSource::Confirmed => false,
                    },
                    (None, _) => match source {
                        SeatSource::Hinted => true,
                        SeatSource::Confirmed => false,
                    },
                };
                if accept {
                    *guard = Some(CachedLeader {
                        endpoint: endpoint.to_string(),
                        epoch,
                        last_used: Instant::now(),
                    });
                }
                accept
            }
            // Absent or expired: no floor.
            _ => {
                *guard = Some(CachedLeader {
                    endpoint: endpoint.to_string(),
                    epoch,
                    last_used: Instant::now(),
                });
                true
            }
        }
    }

    /// Pin the round-robin cursor to a known value so the order-asserting
    /// tests are deterministic despite the random production seed. Test-only.
    #[cfg(test)]
    pub(crate) fn pin_rotation_for_test(&self, seed: usize) {
        self.rotation.store(seed, Ordering::Relaxed);
    }

    /// Test-only read of the cap-sweep invocation counter; see the
    /// [`Self::hint_cap_sweeps`] field.
    #[cfg(test)]
    pub(crate) fn hint_cap_sweep_count(&self) -> usize {
        self.hint_cap_sweeps.load(Ordering::Relaxed)
    }

    /// Test-only convenience wrapper over [`Self::client_with_cell`] for the
    /// callers that only need the client and not the cell handle. Production
    /// code goes through `client_with_cell` so it can evict the exact channel
    /// on a transport-class failure.
    #[cfg(test)]
    pub(crate) async fn client(
        &self,
        endpoint: &str,
    ) -> Result<TsoServiceClient<Channel>, ClientError> {
        self.client_with_cell(endpoint)
            .await
            .map(|(client, _cell)| client)
    }

    /// Returns a tonic client for `endpoint` plus the shared `OnceCell`
    /// backing its channel, opening the channel on first use.
    ///
    /// Look up (or insert) the endpoint's shared `OnceCell` under the
    /// synchronous map lock, release the lock, then drive the dial through
    /// `get_or_try_init`. Concurrent first-callers to the same endpoint share
    /// the one cell, so the dial — and its `connect.duration` / failure
    /// metrics — runs exactly once; later callers and cache hits clone the
    /// already-initialized `Channel` (itself an `Arc`-backed cheap clone).
    ///
    /// A failed dial does not stay cached: the endpoint's entry is evicted on
    /// the error path (via [`Self::evict_if_current`]) so a stream of distinct
    /// *failing* endpoints cannot grow the map without bound. That matters
    /// because wire-supplied leader hints (`LeaderHint.leader_endpoint`) reach
    /// this method as arbitrary endpoint strings, so a contacted peer handing
    /// back a fresh unparseable hint per request would otherwise leak one
    /// uninitialized cell each time. The next caller for the same endpoint
    /// re-inserts a fresh cell and re-dials, so the retry semantics are
    /// unchanged — only the dead slot is reclaimed.
    ///
    /// The returned cell handle lets the retry loop evict the *same* channel
    /// if a later RPC over it fails with a transport error (issue #239); see
    /// [`Self::evict_if_current`] and `crate::attempt::attempt`.
    pub(crate) async fn client_with_cell(
        &self,
        endpoint: &str,
    ) -> Result<(TsoServiceClient<Channel>, Arc<OnceCell<Channel>>), ClientError> {
        let cell = {
            let mut guard = self.channels.lock();
            // A brand-new endpoint is the *only* event that can push the
            // non-configured count over the cap; a cache hit reuses an existing
            // slot, leaving the map size unchanged. Captured before the insert
            // so the cap sweep below runs only when a slot is actually added.
            let inserted = !guard.contains_key(endpoint);
            let slot = guard
                .entry(endpoint.to_string())
                .or_insert_with(|| PooledChannel {
                    cell: Arc::new(OnceCell::new()),
                    last_used: Instant::now(),
                });
            // Refresh recency on every lookup (cache hit included) so the
            // endpoint dialed on each request stays most-recently-used (issue
            // #341); this must stay unconditional even though the sweep below
            // is gated on insertion.
            slot.last_used = Instant::now();
            let cell = slot.cell.clone();
            // Reclaim any non-configured slot that pushed us over the cap, but
            // only on a real insert: on a cache hit the count is unchanged and
            // was already trimmed to `<= MAX_HINT_CHANNELS` by the insert that
            // seated this slot, so the O(n) sweep would be a no-op under the
            // map lock. The freshly-touched slot is the most-recently-used, so
            // it is never its own victim.
            if inserted {
                self.enforce_hint_cap(&mut guard, endpoint);
            }
            cell
        };

        let result = cell
            .get_or_try_init(|| async {
                // Cache miss: we are about to actually dial. Time the dial so
                // the `connect.duration` histogram only captures real connect
                // work, not the cache-hit fast path.
                #[cfg(feature = "metrics")]
                let connect_started = std::time::Instant::now();
                let result = match &self.connector {
                    Some(connector) => connector(endpoint).await,
                    None => {
                        match crate::transport::normalize_uri(endpoint, false).parse::<Endpoint>() {
                            Ok(transport_endpoint) => {
                                let transport_endpoint =
                                    apply_endpoint_config(transport_endpoint, &self.retry_policy);
                                transport_endpoint
                                    .connect()
                                    .await
                                    .map_err(ClientError::from)
                            }
                            Err(_) => Err(ClientError::InvalidEndpoint(endpoint.into())),
                        }
                    }
                };
                #[cfg(feature = "metrics")]
                match &result {
                    Ok(_) => metrics::histogram!("tsoracle.client.connect.duration")
                        .record(connect_started.elapsed().as_secs_f64()),
                    Err(_) => {
                        metrics::counter!("tsoracle.client.connect.failures.total").increment(1)
                    }
                }
                result
            })
            .await;

        let channel = match result {
            Ok(channel) => channel,
            Err(err) => {
                // The dial failed, so `cell` is still uninitialized — a
                // `OnceCell` only stores a value on a successful init, and
                // `get_or_try_init` runs the closure at most once across every
                // caller sharing this cell, so no concurrent caller initialized
                // it either. Reclaim the map slot (the identity guard inside
                // `evict_if_current` skips it if a concurrent caller already
                // replaced the cell).
                self.evict_if_current(endpoint, &cell);
                return Err(err);
            }
        };

        Ok((TsoServiceClient::new(channel.clone()), cell))
    }

    /// Remove `endpoint`'s cached channel cell, but only while the map still
    /// holds *this* cell. Used on two paths: a failed dial (the cell is still
    /// uninitialized) and a transport-class RPC failure against an
    /// already-dialed channel (issue #239 — a pod-replaced endpoint whose
    /// cached channel and its background tonic reconnect task would otherwise
    /// be reused indefinitely, since a static `Endpoint` resolves its address
    /// once and never re-resolves). Eviction forces the next caller to re-dial
    /// and re-resolve.
    ///
    /// The `Arc::ptr_eq` identity check ensures we only drop the entry while
    /// it still holds the cell we were handed: if a concurrent caller has
    /// meanwhile re-inserted a fresh cell under the same key (and may already
    /// be dialing into it), that cell is left untouched, so a stale failure
    /// cannot evict a freshly-redialed good channel.
    pub(crate) fn evict_if_current(&self, endpoint: &str, cell: &Arc<OnceCell<Channel>>) {
        let mut guard = self.channels.lock();
        if guard
            .get(endpoint)
            .is_some_and(|current| Arc::ptr_eq(&current.cell, cell))
        {
            guard.remove(endpoint);
        }
    }

    /// True when `endpoint` is one of the operator-supplied configured
    /// endpoints. Configured endpoints are bounded and trusted, so they are
    /// exempt from the hint-channel cap; only wire-supplied hint endpoints are
    /// eligible for LRU eviction.
    fn is_configured(&self, endpoint: &str) -> bool {
        self.configured
            .iter()
            .any(|configured| configured == endpoint)
    }

    /// Evict least-recently-used non-configured channels until at most
    /// [`MAX_HINT_CHANNELS`] remain, bounding the resources a peer can pin by
    /// streaming distinct but reachable hint endpoints (issue #341).
    /// `protected` — the endpoint just touched by the caller — is never chosen
    /// as a victim, so a same-instant `last_used` tie cannot evict the very
    /// slot we are about to hand out.
    ///
    /// Evicting only drops the map's reference; a caller mid-dial holds its own
    /// clone of the `Arc<OnceCell<Channel>>`, so the channel survives until that
    /// caller drops it — eviction merely forces a future re-dial, matching the
    /// [`Self::evict_if_current`] contract.
    fn enforce_hint_cap(&self, channels: &mut HashMap<String, PooledChannel>, protected: &str) {
        // Record that the sweep ran so tests can assert it fires only on a real
        // insert; the count is otherwise invisible in the map's final state.
        #[cfg(test)]
        self.hint_cap_sweeps.fetch_add(1, Ordering::Relaxed);
        loop {
            let non_configured = channels
                .keys()
                .filter(|endpoint| !self.is_configured(endpoint))
                .count();
            if non_configured <= MAX_HINT_CHANNELS {
                return;
            }
            let victim = channels
                .iter()
                .filter(|(endpoint, _)| {
                    endpoint.as_str() != protected && !self.is_configured(endpoint)
                })
                .min_by_key(|(_, slot)| slot.last_used)
                .map(|(endpoint, _)| endpoint.clone());
            match victim {
                Some(endpoint) => {
                    channels.remove(&endpoint);
                }
                // Only the protected slot remains evictable; leave it so the
                // caller still gets its channel (cap is restored on the next
                // insert against a different endpoint).
                None => return,
            }
        }
    }

    /// The retry loop's dial order: the fresh cached leader first (if any),
    /// then the configured endpoints in genuine round-robin order.
    ///
    /// Each call advances a per-pool cursor and starts the configured tail at
    /// `cursor % configured.len()`, so successive cold-cache worklists from
    /// one pool rotate across nodes rather than always opening on
    /// `configured[0]`. The cursor is seeded randomly per pool (see the
    /// `rotation` field), which is what spreads a *fleet* cold start — every
    /// client otherwise starts at the same offset. The cached leader, when
    /// fresh, is still dialed first and is removed from the rotated tail so it
    /// is not dialed twice.
    pub fn iter_round_robin(&self) -> Vec<String> {
        let leader = self.cached_leader();
        let mut endpoints = Vec::with_capacity(self.configured.len() + 1);
        if let Some(leader_endpoint) = &leader {
            endpoints.push(leader_endpoint.clone());
        }
        // Guard the modulo: an empty configured list has nothing to rotate
        // (the hint-only path already pushed the leader above).
        if self.configured.is_empty() {
            return endpoints;
        }
        let offset = self.rotation.fetch_add(1, Ordering::Relaxed) % self.configured.len();
        for endpoint in self.configured[offset..]
            .iter()
            .chain(&self.configured[..offset])
        {
            if Some(endpoint) != leader.as_ref() {
                endpoints.push(endpoint.clone());
            }
        }
        endpoints
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::RetryPolicy;
    use std::sync::Arc;
    use std::sync::atomic::{AtomicUsize, Ordering};

    // The `decode_leader_hint` Absent/Malformed/Decoded classification tests
    // live alongside the helper in `tsoracle-proto`; this module covers the
    // channel-pool and leader-cache behavior that is unique to the client.

    #[test]
    fn iter_starts_with_cached_leader() {
        let pool = ChannelPool::new(
            vec!["a:1".into(), "b:1".into(), "c:1".into()],
            None,
            false,
            RetryPolicy::default(),
        );
        pool.pin_rotation_for_test(0);
        pool.record_success("b:1", 1);
        let order = pool.iter_round_robin();
        assert_eq!(order, vec!["b:1", "a:1", "c:1"]);
    }

    #[test]
    fn iter_without_cache_rotates_configured() {
        let pool = ChannelPool::new(
            vec!["a:1".into(), "b:1".into(), "c:1".into()],
            None,
            false,
            RetryPolicy::default(),
        );
        pool.pin_rotation_for_test(0);
        // Successive cold-cache worklists rotate the configured order; the
        // offset wraps at `configured.len()`.
        assert_eq!(pool.iter_round_robin(), vec!["a:1", "b:1", "c:1"]); // offset 0
        assert_eq!(pool.iter_round_robin(), vec!["b:1", "c:1", "a:1"]); // offset 1
        assert_eq!(pool.iter_round_robin(), vec!["c:1", "a:1", "b:1"]); // offset 2
        assert_eq!(pool.iter_round_robin(), vec!["a:1", "b:1", "c:1"]); // offset 3 % 3
    }

    /// The starting offset comes from the (seeded) cursor, not a hardcoded
    /// zero — this is the mechanism behind fleet cold-start distribution. The
    /// randomness of the production seed is trusted and intentionally not
    /// asserted (a "different pools differ" test would be a CI-flake risk).
    #[test]
    fn iter_round_robin_honors_seeded_offset() {
        let pool = ChannelPool::new(
            vec!["a:1".into(), "b:1".into(), "c:1".into()],
            None,
            false,
            RetryPolicy::default(),
        );
        pool.pin_rotation_for_test(1);
        assert_eq!(pool.iter_round_robin(), vec!["b:1", "c:1", "a:1"]);
    }

    /// An empty configured list must not panic on the `% configured.len()`
    /// rotation (modulo-by-zero) and yields an empty worklist; a cached
    /// hint-only leader is still surfaced.
    #[test]
    fn iter_round_robin_empty_configured_does_not_panic() {
        let pool = ChannelPool::new(Vec::new(), None, false, RetryPolicy::default());
        assert!(pool.iter_round_robin().is_empty());
        pool.record_success("hint:1", 1);
        assert_eq!(pool.iter_round_robin(), vec!["hint:1"]);
    }

    /// A single configured endpoint is the degenerate rotation case (offset is
    /// always `0 % 1 == 0`): every worklist is just that endpoint, and a
    /// cached leader equal to it is emitted once, never duplicated.
    #[test]
    fn iter_round_robin_single_configured_endpoint() {
        let pool = ChannelPool::new(vec!["a:1".into()], None, false, RetryPolicy::default());
        pool.pin_rotation_for_test(0);
        assert_eq!(pool.iter_round_robin(), vec!["a:1"]);
        assert_eq!(pool.iter_round_robin(), vec!["a:1"]);
        pool.record_success("a:1", 1);
        assert_eq!(pool.iter_round_robin(), vec!["a:1"]);
    }

    /// A higher-epoch hint must win regardless of arrival order — the
    /// single-lock `compare_and_set_leader` is what enforces this. Two
    /// orderings of the same pair of hints are exercised here; both must
    /// land the pool on the higher-epoch endpoint.
    #[test]
    fn higher_epoch_hint_wins_regardless_of_arrival_order() {
        for (first_endpoint, first_epoch, second_endpoint, second_epoch, winner) in [
            ("a:1", 7u128, "b:1", 5u128, "a:1"), // higher arrives first
            ("a:1", 5u128, "b:1", 7u128, "b:1"), // higher arrives second
        ] {
            let pool = ChannelPool::new(
                vec!["a:1".into(), "b:1".into()],
                None,
                false,
                RetryPolicy::default(),
            );
            pool.compare_and_set_leader(first_endpoint.into(), Some(first_epoch));
            pool.compare_and_set_leader(second_endpoint.into(), Some(second_epoch));
            assert_eq!(
                pool.cached_leader().as_deref(),
                Some(winner),
                "ordering {first_endpoint}@{first_epoch} then {second_endpoint}@{second_epoch}"
            );
        }
    }

    /// `compare_and_set_leader` folds the monotone-forward check and the
    /// write into one lock acquisition, closing the TOCTOU window that
    /// existed when the gate (`accept_hint`) and the write
    /// (`set_leader_with`) were separate lock acquisitions. The return
    /// value reports whether the write happened, and — crucially — a
    /// rejected hint must leave the cache exactly where it was.
    #[test]
    fn compare_and_set_leader_checks_and_writes_atomically() {
        let pool = ChannelPool::new(
            vec!["a:1".into(), "b:1".into()],
            None,
            false,
            RetryPolicy::default(),
        );
        // Empty cache: any hint is accepted and seated, even a `None` epoch.
        assert!(pool.compare_and_set_leader("a:1".into(), None));
        assert_eq!(pool.cached_leader().as_deref(), Some("a:1"));

        // Seat the cache at `b:1` epoch 5. A *confirmed* RPC could not
        // cross-seat over the fresh `a:1` unknown-epoch entry — that defense is
        // issue H2 — but a hint carrying a known epoch is new information and
        // seats over an unknown-epoch cache.
        assert!(pool.compare_and_set_leader("b:1".into(), Some(5)));
        assert_eq!(pool.cached_leader().as_deref(), Some("b:1"));

        // A lower-epoch hint is rejected and must not move the cache.
        assert!(!pool.compare_and_set_leader("a:1".into(), Some(4)));
        assert_eq!(pool.cached_leader().as_deref(), Some("b:1"));

        // An equal-epoch hint is accepted (the rule is `>=`) and seated.
        assert!(pool.compare_and_set_leader("a:1".into(), Some(5)));
        assert_eq!(pool.cached_leader().as_deref(), Some("a:1"));

        // A strictly higher epoch promotes the cache forward.
        assert!(pool.compare_and_set_leader("b:1".into(), Some(9)));
        assert_eq!(pool.cached_leader().as_deref(), Some("b:1"));

        // Once seated forward at epoch 9, an intermediate epoch (8) is
        // still behind and must be rejected without disturbing the cache.
        assert!(!pool.compare_and_set_leader("a:1".into(), Some(8)));
        assert_eq!(pool.cached_leader().as_deref(), Some("b:1"));

        // Old-server fallback: a hint without an epoch stays acceptable
        // even once a known epoch has been observed.
        assert!(pool.compare_and_set_leader("a:1".into(), None));
        assert_eq!(pool.cached_leader().as_deref(), Some("a:1"));
    }

    /// A hint naming the *same* endpoint as the cached leader is accepted and
    /// holds the higher epoch, even when the hint's epoch is below the cached
    /// one. Unifying both writers behind `seat_leader` made the same-endpoint
    /// path uniform with `record_success`: the endpoint is the cached leader
    /// either way, so it keeps its slot (and its higher epoch) and the worklist
    /// still steers to it. The old `compare_and_set_leader` returned `false`
    /// here (a `HintUnusable { reason: StaleEpoch }`); the unified rule returns `true`.
    #[test]
    fn same_endpoint_lower_epoch_hint_is_accepted_and_holds_higher_epoch() {
        let pool = ChannelPool::new(
            vec!["a:1".into(), "b:1".into()],
            None,
            false,
            RetryPolicy::default(),
        );
        pool.record_success("a:1", 9);
        assert!(pool.compare_and_set_leader("a:1".into(), Some(4)));
        assert_eq!(pool.cached_leader().as_deref(), Some("a:1"));
        assert_eq!(
            pool.fresh_leader().expect("cache seated").epoch,
            Some(9),
            "a same-endpoint hint must not lower the cached epoch"
        );
    }

    /// A same-endpoint *epoch-less* hint must not downgrade a known cached
    /// epoch to `None`. The old `compare_and_set_leader` routed a same-endpoint
    /// `(Some, None)` pair through the configured-list arm and overwrote the
    /// entry, dropping the epoch; the unified `merge_epoch_forward` keeps it.
    #[test]
    fn same_endpoint_epochless_hint_keeps_known_epoch() {
        let pool = ChannelPool::new(
            vec!["a:1".into(), "b:1".into()],
            None,
            false,
            RetryPolicy::default(),
        );
        pool.record_success("a:1", 9);
        assert!(pool.compare_and_set_leader("a:1".into(), None));
        assert_eq!(
            pool.fresh_leader().expect("cache seated").epoch,
            Some(9),
            "an epoch-less same-endpoint hint must not drop the known epoch"
        );
    }

    /// An epoch-less hint must not downgrade a *fresh, known-epoch* cached
    /// leader when the hinted endpoint is **off the configured list** (issue
    /// #357). A mixed-version peer or a misbehaving redirect source can emit a
    /// NOT_LEADER hint carrying no epoch; without a rankable epoch, the only
    /// trust signal left is "is this an endpoint we'd dial anyway?". An
    /// off-list endpoint fails that test, so the confirmed leader stands.
    #[test]
    fn unknown_epoch_offlist_hint_rejected_when_cache_fresh_known() {
        let pool = ChannelPool::new(
            vec!["a:1".into(), "b:1".into()],
            None,
            false,
            RetryPolicy::default(),
        );
        // Confirm `a:1` at a known epoch via a successful RPC.
        pool.record_success("a:1", 9);
        // An epoch-less hint to an endpoint that is NOT configured must be
        // rejected — it cannot prove it outranks the confirmed leader.
        assert!(!pool.compare_and_set_leader("attacker:1".into(), None));
        assert_eq!(
            pool.cached_leader().as_deref(),
            Some("a:1"),
            "an off-list epoch-less hint must not unseat a fresh known-epoch leader"
        );
    }

    /// The flip side of the #357 carve-out: an epoch-less hint to a
    /// *configured* endpoint is still accepted over a fresh, known-epoch
    /// leader. A node we would dial in round-robin anyway is trustworthy
    /// enough to redirect to immediately — preserving fast failover in a
    /// mixed-version cluster where the new leader runs an old server that
    /// emits no epoch.
    #[test]
    fn unknown_epoch_configured_hint_accepted_when_cache_fresh_known() {
        let pool = ChannelPool::new(
            vec!["a:1".into(), "b:1".into()],
            None,
            false,
            RetryPolicy::default(),
        );
        pool.record_success("a:1", 9);
        // `b:1` is configured, so an epoch-less hint to it is honored.
        assert!(pool.compare_and_set_leader("b:1".into(), None));
        assert_eq!(pool.cached_leader().as_deref(), Some("b:1"));
    }

    /// When the cache itself holds an *unknown* epoch there is no monotone
    /// floor to defend, so the #357 carve-out does not apply: an epoch-less
    /// hint — even to an off-list endpoint — still seats. This is the
    /// bootstrap / old-server path the gate must keep open.
    #[test]
    fn unknown_epoch_hint_still_accepted_when_cache_unknown_epoch() {
        let pool = ChannelPool::new(
            vec!["a:1".into(), "b:1".into()],
            None,
            false,
            RetryPolicy::default(),
        );
        // Seat an unknown-epoch entry (the cache holds `None`).
        assert!(pool.compare_and_set_leader("a:1".into(), None));
        assert_eq!(pool.cached_leader().as_deref(), Some("a:1"));
        // With no known epoch cached, an off-list epoch-less hint still wins.
        assert!(pool.compare_and_set_leader("attacker:1".into(), None));
        assert_eq!(pool.cached_leader().as_deref(), Some("attacker:1"));
    }

    /// Once the known-epoch entry has aged past `leader_ttl`, it imposes no
    /// floor, so even an off-list epoch-less hint seats — the #357 carve-out
    /// only guards a *fresh* known-epoch leader. Re-checking freshness under
    /// the write lock is what keeps this consistent.
    #[tokio::test(start_paused = true)]
    async fn unknown_epoch_offlist_hint_accepted_once_cache_expires() {
        let policy = RetryPolicy {
            leader_ttl: std::time::Duration::from_millis(50),
            ..RetryPolicy::default()
        };
        let pool = ChannelPool::new(vec!["a:1".into(), "b:1".into()], None, false, policy);
        pool.record_success("a:1", 9);
        // Fresh: the off-list epoch-less hint is rejected.
        assert!(!pool.compare_and_set_leader("attacker:1".into(), None));
        assert_eq!(pool.cached_leader().as_deref(), Some("a:1"));
        // Advance past the TTL; the epoch-9 entry is now stale.
        tokio::time::advance(std::time::Duration::from_millis(75)).await;
        // No floor remains, so the same off-list epoch-less hint now seats.
        assert!(pool.compare_and_set_leader("attacker:1".into(), None));
        assert_eq!(pool.cached_leader().as_deref(), Some("attacker:1"));
    }

    /// A cache entry that has aged past `leader_ttl` is treated as absent,
    /// so `compare_and_set_leader` accepts and seats any hint — including
    /// one whose epoch is below the stale entry's. Re-checking freshness
    /// under the same lock as the write is what makes this safe; a TOCTOU
    /// between a freshness read and the write could otherwise resurrect a
    /// just-expired entry.
    #[tokio::test(start_paused = true)]
    async fn compare_and_set_leader_accepts_any_hint_once_cache_expires() {
        let policy = RetryPolicy {
            leader_ttl: std::time::Duration::from_millis(50),
            ..RetryPolicy::default()
        };
        let pool = ChannelPool::new(vec!["a:1".into(), "b:1".into()], None, false, policy);
        pool.record_success("a:1", 9);
        // Fresh: a lower-epoch hint is still rejected.
        assert!(!pool.compare_and_set_leader("b:1".into(), Some(4)));
        assert_eq!(pool.cached_leader().as_deref(), Some("a:1"));
        // Advance past the TTL; the epoch-9 entry is now stale.
        tokio::time::advance(std::time::Duration::from_millis(75)).await;
        // The same lower-epoch hint now seats, because an expired entry
        // imposes no monotone-forward floor.
        assert!(pool.compare_and_set_leader("b:1".into(), Some(4)));
        assert_eq!(pool.cached_leader().as_deref(), Some("b:1"));
    }

    /// Per the cache-invalidation issue's acceptance criterion: a cached
    /// leader that has aged past `leader_ttl` must not be re-prepended
    /// to `iter_round_robin`. The next RPC falls back to the configured
    /// endpoint order. Uses a small TTL plus a real sleep — the entry
    /// is still in the slot, but `cached_leader()` reports `None` once
    /// the elapsed time crosses the threshold.
    #[tokio::test(start_paused = true)]
    async fn cached_leader_past_ttl_is_not_prepended_to_worklist() {
        let policy = RetryPolicy {
            leader_ttl: std::time::Duration::from_millis(50),
            ..RetryPolicy::default()
        };
        let pool = ChannelPool::new(
            vec!["a:1".into(), "b:1".into(), "c:1".into()],
            None,
            false,
            policy,
        );
        pool.pin_rotation_for_test(0);
        // Fresh cache prepends leader c; the offset-0 tail is [a, b].
        pool.record_success("c:1", 1);
        assert_eq!(pool.iter_round_robin(), vec!["c:1", "a:1", "b:1"]);
        // Advance virtual time past the TTL; the entry now reads as absent.
        tokio::time::advance(std::time::Duration::from_millis(75)).await;
        // No prepend now: the worklist is the pure offset-1 rotation, in which
        // c sits mid-list rather than being forced to the front.
        assert!(pool.cached_leader().is_none());
        assert_eq!(pool.iter_round_robin(), vec!["b:1", "c:1", "a:1"]);
    }

    /// A successful RPC against the cached leader refreshes the TTL
    /// clock rather than leaving the entry to age out. Without this,
    /// a continuously-busy steady-state leader would re-evaluate the
    /// worklist on a fixed interval and burn the configured-list
    /// prefix on every TTL boundary.
    #[tokio::test(start_paused = true)]
    async fn record_success_against_cached_leader_refreshes_ttl() {
        let policy = RetryPolicy {
            leader_ttl: std::time::Duration::from_millis(100),
            ..RetryPolicy::default()
        };
        let pool = ChannelPool::new(vec!["a:1".into(), "b:1".into()], None, false, policy);
        pool.record_success("b:1", 1);
        tokio::time::advance(std::time::Duration::from_millis(60)).await;
        // 60ms in: still fresh. Touch the cache.
        pool.record_success("b:1", 2);
        tokio::time::advance(std::time::Duration::from_millis(60)).await;
        // Total elapsed since the original record_success is 120ms,
        // past TTL — but the touch reset the clock 60ms ago, so the
        // cache must still report `b:1` as fresh.
        assert_eq!(pool.cached_leader().as_deref(), Some("b:1"));
    }

    /// A late-completing RPC against the *cached* leader endpoint must not
    /// lower the cached epoch (issue #333). Out-of-order completion of two
    /// coalesced retries, or a slow response from a since-superseded term,
    /// can arrive carrying an older epoch; `record_success` must `max` it,
    /// never overwrite downward. If it overwrote, the monotone-forward CAS
    /// gate would then accept a genuinely-stale hint it was built to reject.
    #[test]
    fn record_success_same_endpoint_does_not_lower_epoch() {
        let pool = ChannelPool::new(
            vec!["a:1".into(), "b:1".into()],
            None,
            false,
            RetryPolicy::default(),
        );
        pool.record_success("a:1", 9);
        // A late, lower-epoch success against the same endpoint.
        pool.record_success("a:1", 4);
        assert_eq!(
            pool.fresh_leader().expect("cache seated").epoch,
            Some(9),
            "a stale same-endpoint success must not lower the cached epoch"
        );
        // The CAS gate must still reject an epoch-5 hint (5 < 9). Were the
        // epoch lowered to 4, this hint would be wrongly accepted.
        assert!(!pool.compare_and_set_leader("b:1".into(), Some(5)));
        assert_eq!(pool.cached_leader().as_deref(), Some("a:1"));
    }

    /// A late success against a *different*, now-deposed leader must not
    /// replace a fresh, higher-epoch cache entry (issue #333). The
    /// cross-endpoint replacement mirrors the CAS rule: reject only when the
    /// cache is fresh, both epochs are known, and the new epoch is strictly
    /// below the cached one.
    #[test]
    fn record_success_different_endpoint_lower_epoch_is_rejected() {
        let pool = ChannelPool::new(
            vec!["a:1".into(), "b:1".into()],
            None,
            false,
            RetryPolicy::default(),
        );
        pool.record_success("a:1", 9);
        // A late success against a peer that led at an earlier epoch.
        pool.record_success("b:1", 4);
        assert_eq!(
            pool.cached_leader().as_deref(),
            Some("a:1"),
            "a stale cross-endpoint success must not unseat the higher-epoch leader"
        );
        assert_eq!(pool.fresh_leader().expect("cache seated").epoch, Some(9));
    }

    /// The epoch-less flap (issue H2): a `None`-epoch cache entry — seated by
    /// an epoch-less leader hint or at bootstrap — has no monotone floor, so a
    /// late, genuinely-stale success against a *different*, now-deposed leader
    /// must still not clobber it while it is fresh. Before the fix the
    /// cross-endpoint reject arm only fired when the cached epoch was known
    /// (`is_some_and`), so a `None`-epoch entry fell straight through to the
    /// replace arm — exactly the backward flap the monotone-forward gate
    /// exists to prevent, unguarded in the deployment (epoch-less / mixed
    /// version) where it matters. A success is a *past* proof whose completion
    /// can be arbitrarily delayed, so when it cannot prove it outranks the
    /// fresh entry the entry is defended.
    #[test]
    fn record_success_different_endpoint_does_not_clobber_fresh_unknown_epoch_cache() {
        let pool = ChannelPool::new(
            vec!["a:1".into(), "b:1".into()],
            None,
            false,
            RetryPolicy::default(),
        );
        // Seat `a:1` with an *unknown* epoch via an epoch-less hint (the
        // mixed-version / bootstrap path).
        assert!(pool.compare_and_set_leader("a:1".into(), None));
        assert_eq!(pool.cached_leader().as_deref(), Some("a:1"));
        // A late success against a different, now-deposed leader must not
        // unseat the fresh entry, even though the cache has no epoch to rank.
        pool.record_success("b:1", 5);
        assert_eq!(
            pool.cached_leader().as_deref(),
            Some("a:1"),
            "a stale cross-endpoint success must not clobber a fresh unknown-epoch entry"
        );
    }

    /// The issue's literal "both epoch-less" case: an epoch-less cache plus a
    /// success that carries the wire's zero epoch (an epoch-less / old server
    /// sends `(0, 0)`, which reassembles to `Some(0)`). A different-endpoint
    /// success still must not flap the fresh entry backward.
    #[test]
    fn record_success_epochless_does_not_clobber_fresh_unknown_epoch_cache() {
        let pool = ChannelPool::new(
            vec!["a:1".into(), "b:1".into()],
            None,
            false,
            RetryPolicy::default(),
        );
        assert!(pool.compare_and_set_leader("a:1".into(), None));
        // Epoch-less server: the success reassembles to epoch 0.
        pool.record_success("b:1", 0);
        assert_eq!(
            pool.cached_leader().as_deref(),
            Some("a:1"),
            "an epoch-0 cross-endpoint success must not clobber a fresh unknown-epoch entry"
        );
    }

    /// The flip side: once the unknown-epoch entry ages past `leader_ttl` it
    /// imposes no floor, so a different-endpoint success seats freely — the
    /// defense only guards a *fresh* entry, and the cache self-heals at TTL.
    #[tokio::test(start_paused = true)]
    async fn record_success_seats_against_unknown_epoch_cache_once_expired() {
        let policy = RetryPolicy {
            leader_ttl: std::time::Duration::from_millis(50),
            ..RetryPolicy::default()
        };
        let pool = ChannelPool::new(vec!["a:1".into(), "b:1".into()], None, false, policy);
        assert!(pool.compare_and_set_leader("a:1".into(), None));
        // Fresh: the different-endpoint success is rejected.
        pool.record_success("b:1", 5);
        assert_eq!(pool.cached_leader().as_deref(), Some("a:1"));
        // Advance past the TTL; the unknown-epoch entry is now stale.
        tokio::time::advance(std::time::Duration::from_millis(75)).await;
        // No floor remains, so the same success now seats.
        pool.record_success("b:1", 5);
        assert_eq!(pool.cached_leader().as_deref(), Some("b:1"));
        assert_eq!(pool.fresh_leader().expect("cache seated").epoch, Some(5));
    }

    /// A success against the *same* endpoint as a fresh unknown-epoch entry
    /// upgrades it to the observed epoch (and refreshes the TTL) — the
    /// bootstrap-then-first-success path. The endpoint is unchanged, so there
    /// is no flap concern; the cache simply gains the epoch it was missing.
    #[test]
    fn record_success_same_endpoint_upgrades_unknown_epoch() {
        let pool = ChannelPool::new(
            vec!["a:1".into(), "b:1".into()],
            None,
            false,
            RetryPolicy::default(),
        );
        assert!(pool.compare_and_set_leader("a:1".into(), None));
        assert_eq!(pool.fresh_leader().expect("cache seated").epoch, None);
        pool.record_success("a:1", 7);
        assert_eq!(pool.cached_leader().as_deref(), Some("a:1"));
        assert_eq!(
            pool.fresh_leader().expect("cache seated").epoch,
            Some(7),
            "a same-endpoint success must upgrade an unknown cached epoch"
        );
    }

    /// A genuine failover — a success against a different endpoint at a
    /// strictly higher epoch — must advance the cache forward.
    #[test]
    fn record_success_different_endpoint_higher_epoch_advances() {
        let pool = ChannelPool::new(
            vec!["a:1".into(), "b:1".into()],
            None,
            false,
            RetryPolicy::default(),
        );
        pool.record_success("a:1", 5);
        pool.record_success("b:1", 7);
        assert_eq!(pool.cached_leader().as_deref(), Some("b:1"));
        assert_eq!(pool.fresh_leader().expect("cache seated").epoch, Some(7));
    }

    /// An expired cache imposes no monotone-forward floor: once the entry has
    /// aged past `leader_ttl`, a lower-epoch success against a different
    /// endpoint seats freely, mirroring `compare_and_set_leader`'s handling
    /// of a stale entry. Re-checking freshness under the write lock is what
    /// keeps this consistent.
    #[tokio::test(start_paused = true)]
    async fn record_success_different_endpoint_lower_epoch_seats_once_expired() {
        let policy = RetryPolicy {
            leader_ttl: std::time::Duration::from_millis(50),
            ..RetryPolicy::default()
        };
        let pool = ChannelPool::new(vec!["a:1".into(), "b:1".into()], None, false, policy);
        pool.record_success("a:1", 9);
        tokio::time::advance(std::time::Duration::from_millis(75)).await;
        // The epoch-9 entry is now stale, so a lower-epoch success seats.
        pool.record_success("b:1", 4);
        assert_eq!(pool.cached_leader().as_deref(), Some("b:1"));
        assert_eq!(pool.fresh_leader().expect("cache seated").epoch, Some(4));
    }

    /// A same-endpoint success refreshes the TTL even when its epoch is lower
    /// than the cached one: the endpoint just proved it is alive and serving,
    /// so it should keep its place in the worklist. The epoch is held at the
    /// higher value, but `last_used` is reset.
    #[tokio::test(start_paused = true)]
    async fn record_success_same_endpoint_lower_epoch_still_refreshes_ttl() {
        let policy = RetryPolicy {
            leader_ttl: std::time::Duration::from_millis(100),
            ..RetryPolicy::default()
        };
        let pool = ChannelPool::new(vec!["a:1".into(), "b:1".into()], None, false, policy);
        pool.record_success("a:1", 9);
        tokio::time::advance(std::time::Duration::from_millis(60)).await;
        // 60ms in: still fresh. A late, lower-epoch success touches the cache.
        pool.record_success("a:1", 4);
        tokio::time::advance(std::time::Duration::from_millis(60)).await;
        // 120ms since the first success but only 60ms since the touch, so the
        // entry must still be fresh — and the epoch must not have dropped.
        assert_eq!(pool.cached_leader().as_deref(), Some("a:1"));
        assert_eq!(pool.fresh_leader().expect("cache seated").epoch, Some(9));
    }

    #[tokio::test]
    async fn pool_with_custom_connector_invokes_closure_per_endpoint() {
        let captured = Arc::new(parking_lot::Mutex::new(Vec::<String>::new()));
        let captured_for_closure = captured.clone();
        let connector: Arc<crate::transport::ChannelConnector> = Arc::new(move |endpoint: &str| {
            captured_for_closure.lock().push(endpoint.to_string());
            let endpoint_owned = endpoint.to_string();
            Box::pin(async move { Err(crate::error::ClientError::InvalidEndpoint(endpoint_owned)) })
        });
        let pool = ChannelPool::new(
            vec!["a:1".into(), "b:1".into()],
            Some(connector),
            false,
            RetryPolicy::default(),
        );
        let _ = pool.client("a:1").await;
        let _ = pool.client("b:1").await;
        let seen = captured.lock().clone();
        assert_eq!(seen, vec!["a:1".to_string(), "b:1".to_string()]);
    }

    #[tokio::test]
    async fn pool_caches_channel_from_custom_connector() {
        let call_count = Arc::new(AtomicUsize::new(0));
        let call_count_for_closure = call_count.clone();
        let connector: Arc<crate::transport::ChannelConnector> =
            Arc::new(move |_endpoint: &str| {
                let n = call_count_for_closure.fetch_add(1, Ordering::SeqCst);
                assert_eq!(n, 0, "connector must only be invoked once per endpoint");
                Box::pin(async {
                    let channel = tonic::transport::Endpoint::from_static("http://127.0.0.1:1")
                        .connect_lazy();
                    Ok(channel)
                })
            });
        let pool = ChannelPool::new(
            vec!["a:1".into()],
            Some(connector),
            false,
            RetryPolicy::default(),
        );
        let _ = pool
            .client("a:1")
            .await
            .expect("first client() must succeed");
        let _ = pool
            .client("a:1")
            .await
            .expect("second client() must hit cache");
        assert_eq!(call_count.load(Ordering::SeqCst), 1);
    }

    /// Per issue #99's acceptance criterion: N concurrent first-callers
    /// racing a fresh pool for the *same* endpoint must observe exactly one
    /// `connect()`. The connector sleeps inside its future to widen the
    /// cache-miss window; under the pre-fix check-without-lock → connect →
    /// lock-and-insert sequence every racer misses the cache and dials, so
    /// the count would equal the number of tasks. The per-endpoint
    /// `OnceCell` collapses them onto a single shared init.
    #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
    async fn racing_first_callers_connect_endpoint_exactly_once() {
        let connect_count = Arc::new(AtomicUsize::new(0));
        let connect_count_for_closure = connect_count.clone();
        let connector: Arc<crate::transport::ChannelConnector> =
            Arc::new(move |_endpoint: &str| {
                connect_count_for_closure.fetch_add(1, Ordering::SeqCst);
                Box::pin(async {
                    // Hold the dial open long enough that every spawned racer
                    // has cleared the cache-miss check before the first finishes.
                    tokio::time::sleep(std::time::Duration::from_millis(50)).await;
                    Ok(
                        tonic::transport::Endpoint::from_static("http://127.0.0.1:1")
                            .connect_lazy(),
                    )
                })
            });
        let pool = Arc::new(ChannelPool::new(
            vec!["a:1".into()],
            Some(connector),
            false,
            RetryPolicy::default(),
        ));

        let mut handles = Vec::new();
        for _ in 0..16 {
            let pool = pool.clone();
            handles.push(tokio::spawn(
                async move { pool.client("a:1").await.map(|_| ()) },
            ));
        }
        for handle in handles {
            handle
                .await
                .expect("racer task must not panic")
                .expect("client() must succeed");
        }

        assert_eq!(
            connect_count.load(Ordering::SeqCst),
            1,
            "concurrent first-callers must share a single connect()"
        );
    }

    #[tokio::test]
    async fn leader_hint_endpoint_goes_through_same_connector() {
        let captured = Arc::new(parking_lot::Mutex::new(Vec::<String>::new()));
        let captured_for_closure = captured.clone();
        let connector: Arc<crate::transport::ChannelConnector> = Arc::new(move |endpoint: &str| {
            captured_for_closure.lock().push(endpoint.to_string());
            Box::pin(async { Err(crate::error::ClientError::InvalidEndpoint("x".into())) })
        });
        let pool = ChannelPool::new(
            vec!["a:1".into()],
            Some(connector),
            false,
            RetryPolicy::default(),
        );
        let _ = pool.client("hinted:1").await;
        let seen = captured.lock().clone();
        assert_eq!(seen, vec!["hinted:1".to_string()]);
    }

    /// A failed dial must not leave a permanent entry in the channel cache.
    /// #286 began inserting the endpoint's `OnceCell` under the map lock
    /// *before* the parse/dial, so a parse or connect failure left an
    /// uninitialized cell behind forever. Because wire-supplied leader hints
    /// (`LeaderHint.leader_endpoint`) flow into `client()` as arbitrary
    /// endpoint strings, a contacted peer returning a fresh invalid hint per
    /// request could grow this map without bound. Each failed dial must
    /// reclaim its own slot, so a run of distinct failing endpoints leaves
    /// nothing behind.
    #[tokio::test]
    async fn failed_dials_do_not_accumulate_in_channel_cache() {
        let connector: Arc<crate::transport::ChannelConnector> = Arc::new(|endpoint: &str| {
            let endpoint_owned = endpoint.to_string();
            Box::pin(async move { Err(crate::error::ClientError::InvalidEndpoint(endpoint_owned)) })
        });
        let pool = ChannelPool::new(Vec::new(), Some(connector), false, RetryPolicy::default());

        for i in 0..8 {
            let endpoint = format!("attacker-hint-{i}:1");
            assert!(
                pool.client(&endpoint).await.is_err(),
                "the failing connector must surface an error for {endpoint}"
            );
        }

        let guard = pool.channels.lock();
        assert_eq!(
            guard.len(),
            0,
            "failed dials must not be retained in the channel cache"
        );
    }

    /// `evict_if_current` clears the endpoint's cached cell when the map
    /// still holds *that* cell. A successful dial seats a cell; evicting it
    /// with the same handle removes the entry, so the next caller re-dials.
    /// This is the primitive the retry loop uses to drop a channel whose RPC
    /// failed with a transport error.
    #[tokio::test]
    async fn evict_if_current_removes_the_cached_cell() {
        let connector: Arc<crate::transport::ChannelConnector> = Arc::new(|_endpoint: &str| {
            Box::pin(async {
                Ok(tonic::transport::Endpoint::from_static("http://127.0.0.1:1").connect_lazy())
            })
        });
        let pool = ChannelPool::new(Vec::new(), Some(connector), false, RetryPolicy::default());

        let (_client, cell) = pool
            .client_with_cell("a:1")
            .await
            .expect("dial against the success connector must succeed");
        assert!(
            pool.channels.lock().contains_key("a:1"),
            "a successful dial must seat a cell"
        );

        pool.evict_if_current("a:1", &cell);
        assert!(
            !pool.channels.lock().contains_key("a:1"),
            "evicting with the live cell must clear the entry"
        );
    }

    /// The identity guard, mirroring the dial-failure eviction: if a
    /// concurrent caller has replaced the endpoint's cell with a fresh one,
    /// evicting with a *stale* handle must spare the live cell. Without it, a
    /// transport failure observed on an old channel could evict a
    /// freshly-redialed good channel, forcing a redundant re-dial.
    #[tokio::test]
    async fn evict_if_current_spares_a_replaced_cell() {
        let connector: Arc<crate::transport::ChannelConnector> = Arc::new(|_endpoint: &str| {
            Box::pin(async {
                Ok(tonic::transport::Endpoint::from_static("http://127.0.0.1:1").connect_lazy())
            })
        });
        let pool = ChannelPool::new(Vec::new(), Some(connector), false, RetryPolicy::default());

        let (_first, cell1) = pool.client_with_cell("a:1").await.expect("first dial");
        // Evict and re-dial so the map holds a different cell than `cell1`.
        pool.evict_if_current("a:1", &cell1);
        let (_second, cell2) = pool.client_with_cell("a:1").await.expect("second dial");
        assert!(
            !Arc::ptr_eq(&cell1, &cell2),
            "the re-dial must seat a fresh cell"
        );

        // A stale-handle eviction must leave the live cell in place.
        pool.evict_if_current("a:1", &cell1);
        assert!(
            pool.channels.lock().contains_key("a:1"),
            "stale-cell eviction must spare the live cell"
        );
    }

    /// The flip side of the eviction-on-failure rule: a successful dial must
    /// still be cached so the single-flight fast path (and the #286
    /// per-endpoint `OnceCell`) keeps working. Removing the entry only when
    /// the cell is still uninitialized is what preserves this.
    #[tokio::test]
    async fn successful_dials_are_retained_in_channel_cache() {
        let connector: Arc<crate::transport::ChannelConnector> = Arc::new(|_endpoint: &str| {
            Box::pin(async {
                Ok(tonic::transport::Endpoint::from_static("http://127.0.0.1:1").connect_lazy())
            })
        });
        let pool = ChannelPool::new(Vec::new(), Some(connector), false, RetryPolicy::default());

        pool.client("a:1")
            .await
            .expect("dial against the success connector must succeed");

        let guard = pool.channels.lock();
        assert_eq!(guard.len(), 1, "a successful dial must stay cached");
        assert!(
            guard
                .get("a:1")
                .expect("the dialed endpoint must have a cache entry")
                .cell
                .get()
                .is_some(),
            "the retained cell must be initialized"
        );
    }

    /// A stream of distinct but *reachable* wire-supplied hint endpoints must
    /// not grow the channel map without bound (issue #341). The failed-dial
    /// leak (#290) and transport-failure eviction (#239) only reclaim on
    /// failure; a hint that dials *successfully* and never has a transport
    /// failure was retained forever, each entry pinning a live `Channel` and
    /// its background reconnect task. Non-configured (hint-derived) entries are
    /// now capped at `MAX_HINT_CHANNELS`, so a peer pointing at live decoys
    /// cannot pin unbounded resources.
    #[tokio::test]
    async fn successful_hint_dials_are_bounded() {
        let connector: Arc<crate::transport::ChannelConnector> = Arc::new(|_endpoint: &str| {
            Box::pin(async {
                Ok(tonic::transport::Endpoint::from_static("http://127.0.0.1:1").connect_lazy())
            })
        });
        // No configured endpoints: every dial below is a hint-derived entry.
        let pool = ChannelPool::new(Vec::new(), Some(connector), false, RetryPolicy::default());

        for i in 0..50 {
            let endpoint = format!("decoy-{i}:1");
            pool.client(&endpoint)
                .await
                .expect("the success connector must seat a channel");
        }

        let guard = pool.channels.lock();
        assert!(
            guard.len() <= MAX_HINT_CHANNELS,
            "non-configured channels must be capped at {MAX_HINT_CHANNELS}, found {}",
            guard.len()
        );
    }

    /// Configured (operator-supplied) endpoints are exempt from the hint cap:
    /// they are bounded and trusted, so a flood of distinct hint endpoints must
    /// never evict them. The total map stays bounded at
    /// `configured.len() + MAX_HINT_CHANNELS`.
    #[tokio::test]
    async fn configured_endpoints_are_never_evicted() {
        let connector: Arc<crate::transport::ChannelConnector> = Arc::new(|_endpoint: &str| {
            Box::pin(async {
                Ok(tonic::transport::Endpoint::from_static("http://127.0.0.1:1").connect_lazy())
            })
        });
        let pool = ChannelPool::new(
            vec!["cfg-a:1".into(), "cfg-b:1".into()],
            Some(connector),
            false,
            RetryPolicy::default(),
        );
        // Seat the configured endpoints, then never touch them again.
        pool.client("cfg-a:1").await.expect("dial cfg-a");
        pool.client("cfg-b:1").await.expect("dial cfg-b");
        // Flood with distinct, reachable hint endpoints.
        for i in 0..50 {
            pool.client(&format!("decoy-{i}:1"))
                .await
                .expect("dial decoy");
        }

        let guard = pool.channels.lock();
        assert!(
            guard.contains_key("cfg-a:1") && guard.contains_key("cfg-b:1"),
            "configured endpoints must survive a hint flood"
        );
        assert!(
            guard.len() <= 2 + MAX_HINT_CHANNELS,
            "total map size must stay bounded at configured.len() + cap, found {}",
            guard.len()
        );
    }

    /// LRU policy: an endpoint dialed on every round (standing in for the
    /// active leader, which is dialed on each request) stays most-recently-used
    /// and survives, while older idle hint endpoints are evicted. This is what
    /// distinguishes LRU from a naive insertion-order/FIFO eviction — the
    /// leader is seated *first*, so a FIFO policy would discard it.
    #[tokio::test]
    async fn lru_retains_the_repeatedly_dialed_endpoint() {
        let connector: Arc<crate::transport::ChannelConnector> = Arc::new(|_endpoint: &str| {
            Box::pin(async {
                Ok(tonic::transport::Endpoint::from_static("http://127.0.0.1:1").connect_lazy())
            })
        });
        let pool = ChannelPool::new(Vec::new(), Some(connector), false, RetryPolicy::default());

        // Seat the "leader" first so a FIFO policy would evict it earliest.
        pool.client("leader:1").await.expect("dial leader");
        for i in 0..50 {
            pool.client(&format!("decoy-{i}:1"))
                .await
                .expect("dial decoy");
            // Touch the leader every round, keeping it most-recently-used.
            pool.client("leader:1").await.expect("redial leader");
        }

        let guard = pool.channels.lock();
        assert!(
            guard.contains_key("leader:1"),
            "the repeatedly-dialed endpoint must survive LRU eviction"
        );
        assert!(
            guard.len() <= MAX_HINT_CHANNELS,
            "non-configured channels must stay capped, found {}",
            guard.len()
        );
    }

    /// The cap sweep is the pool's hottest-path cost — an O(n) `keys().filter()`
    /// scan (plus a `min_by_key` on overflow) under the synchronous map lock —
    /// and it can only ever evict on a *new* insert: a cache hit reuses an
    /// existing slot, so the non-configured count is unchanged and already
    /// `<= MAX_HINT_CHANNELS` from the insert that seated it. The sweep is
    /// therefore gated to run only when a brand-new slot is added. That contract
    /// is invisible in the map's final state — a cache-hit sweep would be a
    /// guaranteed no-op — so this asserts it directly via the sweep counter:
    /// each genuine insert bumps it, each cache hit leaves it untouched.
    #[tokio::test]
    async fn cap_sweep_runs_on_insert_not_on_cache_hit() {
        let connector: Arc<crate::transport::ChannelConnector> = Arc::new(|_endpoint: &str| {
            Box::pin(async {
                Ok(tonic::transport::Endpoint::from_static("http://127.0.0.1:1").connect_lazy())
            })
        });
        // No configured endpoints, so every dial below is a hint-derived slot
        // eligible for the cap sweep.
        let pool = ChannelPool::new(Vec::new(), Some(connector), false, RetryPolicy::default());

        // First dial of `a:1` inserts a slot, so the cap sweep runs once.
        pool.client("a:1").await.expect("insert a:1");
        assert_eq!(
            pool.hint_cap_sweep_count(),
            1,
            "the first dial inserts a slot and must sweep once"
        );

        // Re-dialing `a:1` is a cache hit: no new slot, so no sweep.
        pool.client("a:1").await.expect("cache hit a:1");
        assert_eq!(
            pool.hint_cap_sweep_count(),
            1,
            "a cache hit must not run the O(n)-under-lock cap sweep"
        );

        // A different endpoint inserts again, so the sweep runs a second time.
        pool.client("b:1").await.expect("insert b:1");
        assert_eq!(
            pool.hint_cap_sweep_count(),
            2,
            "a dial against a brand-new endpoint must sweep"
        );

        // Cache hits on either seated endpoint still add no sweeps.
        pool.client("a:1").await.expect("cache hit a:1");
        pool.client("b:1").await.expect("cache hit b:1");
        assert_eq!(
            pool.hint_cap_sweep_count(),
            2,
            "cache hits never sweep, regardless of which seated slot is reused"
        );
    }

    /// The cap sweep is gated on insertion, but the `last_used` recency refresh
    /// must stay *unconditional*: it is what keeps the active leader — re-dialed
    /// on every request and thus almost always a cache hit — most-recently-used,
    /// so the next insert's sweep never picks it as a victim (issue #341). This
    /// guards that the insert gate did not also swallow the refresh: a cache hit
    /// advances the slot's `last_used` while running no sweep and leaving the
    /// map size unchanged. Were the refresh folded under the same gate, the
    /// leader would age and the eviction protection of issue #341 would reopen.
    #[tokio::test(start_paused = true)]
    async fn cache_hit_refreshes_recency_without_sweeping() {
        let connector: Arc<crate::transport::ChannelConnector> = Arc::new(|_endpoint: &str| {
            Box::pin(async {
                Ok(tonic::transport::Endpoint::from_static("http://127.0.0.1:1").connect_lazy())
            })
        });
        let pool = ChannelPool::new(Vec::new(), Some(connector), false, RetryPolicy::default());

        pool.client("a:1").await.expect("insert a:1");
        let seated_at = pool
            .channels
            .lock()
            .get("a:1")
            .expect("a:1 must be seated")
            .last_used;
        let sweeps_after_insert = pool.hint_cap_sweep_count();

        // Let virtual time pass, then hit the cache for the same endpoint.
        tokio::time::advance(std::time::Duration::from_millis(10)).await;
        pool.client("a:1").await.expect("cache hit a:1");

        let guard = pool.channels.lock();
        assert_eq!(guard.len(), 1, "a cache hit must not grow the map");
        assert!(
            guard
                .get("a:1")
                .expect("a:1 must still be seated")
                .last_used
                > seated_at,
            "a cache hit must refresh last_used unconditionally"
        );
        drop(guard);
        assert_eq!(
            pool.hint_cap_sweep_count(),
            sweeps_after_insert,
            "the recency refresh must not drag the cap sweep along with it"
        );
    }
}