Skip to content

Commit fb5d38a

Browse files
committed
test(cancel): cover proxies, connect and redirects, and bound the timeout test
* Cancelling while an HTTP proxy has not answered CONNECT, while an https proxy has not finished its handshake, and while the handshake with the target inside an https proxy's tunnel has not been answered. * A redirect is not followed once the request has been cancelled. * A connect after the stop sends nothing to the listener. * The timeout test carries a stop 5 s in, so a timeout that never comes fails it instead of holding the suite until its own limit. * The stream test uses the same stopper as the others, and a stopper that is no longer needed does not wait out its delay. * The file starts with the includes and then imports std, like the other test files.
1 parent 4fab0e2 commit fb5d38a

1 file changed

Lines changed: 146 additions & 28 deletions

File tree

‎tests/test_cancel.cpp‎

Lines changed: 146 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -3,16 +3,12 @@
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

1510
import mcpplibs.tinyhttps;
11+
import std;
1612

1713
namespace 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

7075
private:
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.
150156
bool 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.
289285
TEST_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\nLocation: /next\r\nContent-Length: 100\r\n\r\npartial");
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

Comments
 (0)