33// Every wait here is far longer than the test should take (readTimeoutMs is
44// 30 s), so a request that returns promptly with `cancelled` set can only have
55// been ended by the token, never by a timeout.
6- // `import std` comes first on purpose. With libc++ 22, a translation unit that
7- // includes <atomic> or <thread> before importing std and then calls
8- // stop_source::request_stop() fails to link (undefined
9- // __atomic_unique_lock::__set_locked_bit). The other order links.
10- import std;
11-
126#include < gtest/gtest.h>
7+ #include " proxy_test_server.hpp"
138#include " tls_test_server.hpp"
149
1510import mcpplibs.tinyhttps;
11+ import std;
1612
1713namespace https = mcpplibs::tinyhttps;
1814
@@ -51,14 +47,23 @@ class Stopper {
5147 explicit Stopper (std::chrono::milliseconds delay,
5248 std::function<bool ()> ready = {})
5349 : thread_([this , delay, ready = std::move(ready)] {
54- for (int i = 0 ; ready && !ready () && i < 1000 ; ++i) {
50+ for (int i = 0 ; ready && !ready () && i < 1000 && !quit_.load (); ++i) {
51+ std::this_thread::sleep_for (std::chrono::milliseconds (5 ));
52+ }
53+ for (auto end = Clock::now () + delay;
54+ Clock::now () < end && !quit_.load ();) {
5555 std::this_thread::sleep_for (std::chrono::milliseconds (5 ));
5656 }
57- std::this_thread::sleep_for (delay) ;
57+ if (quit_. load ()) return ;
5858 stoppedAt_.store (Clock::now ().time_since_epoch ().count ());
5959 source.request_stop ();
6060 }) {}
61- ~Stopper () { if (thread_.joinable ()) thread_.join (); }
61+ // A stop that has not come yet is abandoned, so a long delay costs nothing
62+ // when the call it was meant to end has already returned.
63+ ~Stopper () {
64+ quit_ = true ;
65+ if (thread_.joinable ()) thread_.join ();
66+ }
6267
6368 long long since_stop_ms (Clock::time_point returned) {
6469 thread_.join ();
@@ -69,6 +74,7 @@ class Stopper {
6974
7075private:
7176 std::atomic<Clock::rep> stoppedAt_ { 0 };
77+ std::atomic<bool > quit_ { false };
7278 std::thread thread_;
7379};
7480
@@ -145,8 +151,8 @@ class SilentListener {
145151 std::thread thread_;
146152};
147153
148- // Waits up to `limit` for `flag `, which is how a test learns that the server saw
149- // the connection end.
154+ // Waits up to `limit` for `predicate `, which is how a test learns that the server
155+ // saw the connection end.
150156bool becomes_true (const std::function<bool ()>& predicate,
151157 std::chrono::milliseconds limit = std::chrono::seconds(2 )) {
152158 const auto deadline = Clock::now () + limit;
@@ -201,23 +207,12 @@ TEST_F(CancelTest, AStreamWaitingForTheNextChunkIsAbandoned) {
201207
202208 https::HttpClient client (test_config ());
203209 std::atomic<int > events { 0 };
204- std::stop_source source;
205- std::atomic<Clock::rep> stoppedAt { 0 };
206- std::thread stopper ([&] {
207- for (int i = 0 ; i < 1000 && events.load () == 0 ; ++i) {
208- std::this_thread::sleep_for (std::chrono::milliseconds (5 ));
209- }
210- std::this_thread::sleep_for (std::chrono::milliseconds (100 ));
211- stoppedAt = Clock::now ().time_since_epoch ().count ();
212- source.request_stop ();
213- });
210+ Stopper stopper (std::chrono::milliseconds (100 ), [&] { return events.load () > 0 ; });
214211
215212 auto res = client.send_stream (get (server.url (" /" )),
216213 [&](const https::SseEvent&) { ++events; return true ; },
217- source.get_token ());
218- const auto returned = Clock::now ();
219- stopper.join ();
220- const auto latency = ms_between (Clock::time_point (Clock::duration (stoppedAt.load ())), returned);
214+ stopper.source .get_token ());
215+ const auto latency = stopper.since_stop_ms (Clock::now ());
221216 std::cout << " [ latency ] stream: " << latency << " ms after the stop" << std::endl;
222217
223218 EXPECT_EQ (res.statusCode , 200 );
@@ -285,7 +280,8 @@ TEST_F(CancelTest, ATokenAlreadyStoppedSendsNothing) {
285280}
286281
287282// The slices must not shorten or lengthen the timeout a token-carrying call
288- // already had.
283+ // already had. The token is stopped after 5 s, so a timeout that never comes
284+ // fails the test rather than hanging the suite.
289285TEST_F (CancelTest, ATokenThatIsNeverStoppedLeavesTheTimeoutAlone) {
290286 tls_test::Server server ([](tls_test::Conn& conn, int ) {
291287 conn.park ();
@@ -294,10 +290,10 @@ TEST_F(CancelTest, ATokenThatIsNeverStoppedLeavesTheTimeoutAlone) {
294290 ASSERT_FALSE (server.failed ());
295291
296292 https::HttpClient client (test_config (/* readTimeoutMs=*/ 300 ));
297- std::stop_source source ;
293+ Stopper backstop ( std::chrono::seconds ( 5 )) ;
298294
299295 const auto started = Clock::now ();
300- auto res = client.send (get (server.url (" /" )), source.get_token ());
296+ auto res = client.send (get (server.url (" /" )), backstop. source .get_token ());
301297 const auto elapsed = ms_between (started, Clock::now ());
302298
303299 EXPECT_FALSE (res.cancelled );
@@ -402,3 +398,125 @@ TEST_F(CancelTest, CancelledRequestsDoNotLeakDescriptors) {
402398 cancel_stuck_requests (20 );
403399 EXPECT_EQ (open_fds (), before);
404400}
401+
402+ // A redirect whose body is still arriving when the stop comes has to be reported
403+ // as it is, not followed: the next request would be one the caller abandoned.
404+ TEST_F (CancelTest, ARedirectIsNotFollowedOnceTheRequestIsCancelled) {
405+ tls_test::Server server ([](tls_test::Conn& conn, int ) {
406+ conn.write (" HTTP/1.1 302 Found\r\n Location: /next\r\n Content-Length: 100\r\n\r\n partial" );
407+ conn.park ();
408+ return false ;
409+ });
410+ ASSERT_FALSE (server.failed ());
411+
412+ https::HttpClient client (test_config ());
413+ Stopper stopper (std::chrono::milliseconds (100 ), [&] { return server.requests () >= 1 ; });
414+ auto res = client.send (get (server.url (" /" )), stopper.source .get_token ());
415+
416+ EXPECT_TRUE (res.cancelled );
417+ EXPECT_EQ (res.statusCode , 302 );
418+ EXPECT_EQ (res.bodyError , " cancelled" );
419+ EXPECT_EQ (server.requests (), 1 );
420+ }
421+
422+ // Connecting is a wait too, and nothing may be sent to an address once the stop
423+ // has been asked for. The listener only has to exist: the kernel completes the
424+ // connection for it, so an attempt shows as an accept.
425+ TEST_F (CancelTest, AConnectAfterTheStopSendsNoSyn) {
426+ tls_test::Server server ([](tls_test::Conn&, int ) { return false ; });
427+ ASSERT_FALSE (server.failed ());
428+
429+ std::stop_source source;
430+ source.request_stop ();
431+ https::Socket sock;
432+ sock.set_stop (source.get_token ());
433+
434+ EXPECT_FALSE (sock.connect (" 127.0.0.1" , server.port (), 4000 ));
435+ std::this_thread::sleep_for (std::chrono::milliseconds (300 ));
436+ EXPECT_EQ (server.accepts (), 0 );
437+ }
438+
439+ // ── through a proxy ──────────────────────────────────────────────────────────
440+
441+ namespace {
442+
443+ https::HttpClientConfig proxied_config (const std::string& proxyUrl) {
444+ auto cfg = test_config ();
445+ cfg.proxy = proxyUrl;
446+ return cfg;
447+ }
448+
449+ // Holds a connection for up to 5 s or until `done`, so a test whose cancellation
450+ // fails sees the connection dropped (and an assertion fail) rather than a hang.
451+ void hold (const std::atomic<bool >& done) {
452+ for (int i = 0 ; i < 500 && !done.load (); ++i) {
453+ std::this_thread::sleep_for (std::chrono::milliseconds (10 ));
454+ }
455+ }
456+
457+ } // namespace
458+
459+ TEST_F (CancelTest, AProxyThatNeverAnswersConnectIsAbandoned) {
460+ std::atomic<bool > sawConnect { false };
461+ std::atomic<bool > done { false };
462+ proxy_test::Server proxy ([&](proxy_test::Peer& peer) {
463+ peer.read_head ();
464+ sawConnect = true ;
465+ hold (done);
466+ });
467+ ASSERT_FALSE (proxy.failed ());
468+
469+ https::HttpClient client (proxied_config (" http://127.0.0.1:" + std::to_string (proxy.port ())));
470+ Stopper stopper (std::chrono::milliseconds (100 ), [&] { return sawConnect.load (); });
471+ auto res = client.send (get (" https://127.0.0.1:9/" ), stopper.source .get_token ());
472+ const auto returned = Clock::now ();
473+ done = true ;
474+
475+ EXPECT_TRUE (res.cancelled );
476+ EXPECT_EQ (res.statusCode , 0 );
477+ EXPECT_EQ (res.statusText , " Cancelled" );
478+ EXPECT_LT (stopper.since_stop_ms (returned), kPromptMs );
479+ }
480+
481+ TEST_F (CancelTest, AnHttpsProxyThatNeverCompletesItsHandshakeIsAbandoned) {
482+ std::atomic<bool > sawHello { false };
483+ std::atomic<bool > done { false };
484+ proxy_test::Server proxy ([&](proxy_test::Peer& peer) {
485+ peer.read_some ();
486+ sawHello = true ;
487+ hold (done);
488+ });
489+ ASSERT_FALSE (proxy.failed ());
490+
491+ https::HttpClient client (proxied_config (" https://127.0.0.1:" + std::to_string (proxy.port ())));
492+ Stopper stopper (std::chrono::milliseconds (100 ), [&] { return sawHello.load (); });
493+ auto res = client.send (get (" https://127.0.0.1:9/" ), stopper.source .get_token ());
494+ const auto returned = Clock::now ();
495+ done = true ;
496+
497+ EXPECT_TRUE (res.cancelled );
498+ EXPECT_EQ (res.statusCode , 0 );
499+ EXPECT_LT (stopper.since_stop_ms (returned), kPromptMs );
500+ }
501+
502+ // The session to the target runs inside the one to the proxy, and the stop has
503+ // to reach the proxy's session for the inner handshake to be interruptible.
504+ TEST_F (CancelTest, AHandshakeInsideAnHttpsProxyTunnelIsAbandoned) {
505+ std::atomic<bool > tunnelOpen { false };
506+ tls_test::Server proxy ([&](tls_test::Conn& conn, int ) {
507+ conn.write (" HTTP/1.1 200 Connection established\r\n\r\n " );
508+ tunnelOpen = true ;
509+ conn.wait_peer_close (std::chrono::seconds (5 ));
510+ return false ;
511+ });
512+ ASSERT_FALSE (proxy.failed ());
513+
514+ https::HttpClient client (proxied_config (" https://127.0.0.1:" + std::to_string (proxy.port ())));
515+ Stopper stopper (std::chrono::milliseconds (200 ), [&] { return tunnelOpen.load (); });
516+ auto res = client.send (get (" https://127.0.0.1:9/" ), stopper.source .get_token ());
517+ const auto returned = Clock::now ();
518+
519+ EXPECT_TRUE (res.cancelled );
520+ EXPECT_EQ (res.statusCode , 0 );
521+ EXPECT_LT (stopper.since_stop_ms (returned), kPromptMs );
522+ }
0 commit comments