Skip to content

Commit dc6b19d

Browse files
committed
test(icaptcha): tag the observer flush by source port, not by counter tick
The drain barrier sampled target = count + 1 before connecting the flush and waited for any next increment. A leak accepted after that sample and before the flush was counted satisfied the wait; the test then subtracted the flush as if it were that leak, so leak_count could be 0 with a real leak present (false green). Tag the flush connection instead: the accept loop records each accepted connection's source port, and drain waits until the flush's own source port is recorded (the flush is never counted). Because accept() is FIFO, when the flush is recorded every connection that completed its handshake before it is recorded too, so the post-drain count still covers all egress in flight. Verified with a probe that reproduces the reported scenario (leak accepted before the flush is now counted) before removing it.
1 parent 59378ea commit dc6b19d

1 file changed

Lines changed: 54 additions & 37 deletions

File tree

‎crates/icaptcha-client/tests/icaptcha_mock_consumed.rs‎

Lines changed: 54 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -18,10 +18,11 @@
1818
//! closed-port blackhole (which only surfaces propagating errors) would miss.
1919
//! To close the timing race inherent in a detached accept loop, the count is
2020
//! read only after a bounded drain: the test opens a flush connection and
21-
//! blocks until the accept thread has acknowledged (counted) it. Because
22-
//! `accept()` hands out connections FIFO, every leak connection that completed
23-
//! its TCP handshake before the flush is counted before the flush — the
24-
//! assertion therefore covers all egress in flight when `obtain_proof`
21+
//! blocks until the accept thread has accepted *that specific connection*
22+
//! (tagged by its source port), not merely until any next counter tick.
23+
//! Because `accept()` hands out connections FIFO, every leak connection that
24+
//! completed its TCP handshake before the flush is counted before the flush —
25+
//! the assertion therefore covers all egress in flight when `obtain_proof`
2526
//! returned. A leak that starts its handshake after the drain is outside the
2627
//! window; that is the irreducible limit of observing a detached accept loop,
2728
//! and it is the remaining, well-defined gap this guard accepts.
@@ -50,38 +51,45 @@ mod support;
5051
/// A TCP listener that counts every accepted connection.
5152
///
5253
/// Acts as the proxy target: any leaked connection to a non-loopback
53-
/// destination will be routed through this listener and increment the
54-
/// counter. The count is guarded by a Mutex + Condvar so the test can block
55-
/// until the detached accept thread has acknowledged a specific connection
56-
/// (see [`ProxyObserver::drain`]).
54+
/// destination will be routed through this listener and counted. Every
55+
/// accepted connection is recorded by its source port so the test's own
56+
/// flush connection ([`ProxyObserver::drain`]) can be recognised and
57+
/// excluded from the leak count.
5758
struct ProxyObserver {
5859
_listener: Arc<TcpListener>,
5960
port: u16,
60-
count: Arc<Mutex<usize>>,
61+
/// Source ports of every accepted connection, in accept order.
62+
accepted: Arc<Mutex<Vec<u16>>>,
63+
/// Source ports of the test's own flush connections (never counted).
64+
flush_ports: Arc<Mutex<Vec<u16>>>,
65+
/// Signalled after each accept so `drain` can wait on the flush.
6166
ack: Arc<Condvar>,
6267
}
6368

6469
impl ProxyObserver {
6570
fn bind() -> Self {
6671
let listener = Arc::new(TcpListener::bind("127.0.0.1:0").expect("bind proxy observer"));
6772
let port = listener.local_addr().unwrap().port();
68-
let count = Arc::new(Mutex::new(0usize));
73+
let accepted = Arc::new(Mutex::new(Vec::new()));
74+
let flush_ports = Arc::new(Mutex::new(Vec::new()));
6975
let ack = Arc::new(Condvar::new());
70-
let c = Arc::clone(&count);
71-
let a = Arc::clone(&ack);
76+
let a = Arc::clone(&accepted);
77+
let c = Arc::clone(&ack);
7278
let l = Arc::clone(&listener);
7379
std::thread::spawn(move || {
7480
for stream in l.incoming() {
75-
if stream.is_ok() {
76-
*c.lock().unwrap() += 1;
77-
a.notify_all();
78-
}
81+
let Ok(stream) = stream else { continue };
82+
a.lock()
83+
.unwrap()
84+
.push(stream.peer_addr().map(|a| a.port()).unwrap_or(0));
85+
c.notify_all();
7986
}
8087
});
8188
ProxyObserver {
8289
_listener: listener,
8390
port,
84-
count,
91+
accepted,
92+
flush_ports,
8593
ack,
8694
}
8795
}
@@ -90,29 +98,39 @@ impl ProxyObserver {
9098
self.port
9199
}
92100

101+
/// Number of accepted connections that are not the test's own flush
102+
/// connections — i.e. the leak count.
93103
fn count(&self) -> usize {
94-
*self.count.lock().unwrap()
104+
let flush = self.flush_ports.lock().unwrap();
105+
let accepted = self.accepted.lock().unwrap();
106+
accepted.iter().filter(|p| !flush.contains(p)).count()
95107
}
96108

97109
/// Bounded acknowledgement barrier for the accept loop.
98110
///
99-
/// Opens a flush connection to the listener and blocks until the accept
100-
/// thread has counted it (bounded by a timeout). Because `accept()`
101-
/// hands out connections FIFO, once this flush is counted every
102-
/// connection that completed its handshake before it is counted too.
103-
/// This lets the test assert "no egress was accepted before this point"
104-
/// without racing the detached accept loop.
111+
/// Opens a flush connection and blocks until the accept thread has
112+
/// accepted *that connection* (identified by its source port), bounded by
113+
/// a timeout. Because `accept()` hands out connections FIFO, once the
114+
/// flush is accepted every connection that completed its handshake before
115+
/// it has been accepted and recorded too — so counting on afterwards
116+
/// cannot miss a leak that was already in flight. The flush connection
117+
/// itself is never counted.
118+
///
119+
/// A counter-tick barrier would be unsound here: sampling "current + 1"
120+
/// and waiting for any increment lets a leak accepted after the sample
121+
/// satisfy the wait, and the flush is then miscounted as that leak.
105122
fn drain(&self) {
106-
let target = *self.count.lock().unwrap() + 1;
107-
let _flush =
123+
let flush =
108124
TcpStream::connect(("127.0.0.1", self.port)).expect("observer flush connection");
109-
let mut count = self.count.lock().unwrap();
110-
while *count < target {
125+
let flush_port = flush.local_addr().unwrap().port();
126+
self.flush_ports.lock().unwrap().push(flush_port);
127+
let mut accepted = self.accepted.lock().unwrap();
128+
while !accepted.contains(&flush_port) {
111129
let (guard, timed_out) = self
112130
.ack
113-
.wait_timeout(count, std::time::Duration::from_secs(5))
131+
.wait_timeout(accepted, std::time::Duration::from_secs(5))
114132
.unwrap();
115-
count = guard;
133+
accepted = guard;
116134
assert!(
117135
!timed_out.timed_out(),
118136
"proxy observer drain timed out; accept loop stalled"
@@ -165,14 +183,13 @@ fn obtain_proof_consumes_the_mocked_service_and_makes_no_live_call() {
165183
challenge.assert();
166184
answer.assert();
167185

168-
// Drain the accept loop before asserting: a leak connection that was
169-
// accepted (or sitting in the backlog) when obtain_proof returned is
170-
// counted ahead of the flush, so the count after drain reflects all
171-
// egress in flight during the call. The flush connection itself is
172-
// accepted and counted too, so it is excluded from the leak accounting
173-
// (exactly one connection).
186+
// Drain the accept loop before asserting: the flush connection is not
187+
// counted, and because accept() is FIFO every leak connection that
188+
// completed its handshake before the flush is recorded before the flush
189+
// is — so the count after drain reflects all egress in flight during the
190+
// call.
174191
observer.drain();
175-
let leak_count = observer.count() - count_before - 1;
192+
let leak_count = observer.count() - count_before;
176193

177194
assert_eq!(
178195
leak_count, 0,

0 commit comments

Comments
 (0)