0.3.4 — cancel a request in flight with a std::stop_token - #23
Merged
Merged
Conversation
There was no way to abandon a request that had been sent. A caller that stopped caring (a user pressing Esc, say) could only leave the call running on a thread and wait out readTimeoutMs, with the connection held open and the server still producing the response. send and send_stream take an optional std::stop_token. While a token is attached, every wait on the socket is made in slices of at most 50 ms with a check for a stop request between them, and bio_recv waits for the socket before reading, so a handshake or the second half of a TLS record is covered too. When the token is stopped the call returns within about one slice, and the connection is dropped rather than pooled, so the server sees it end. A close_notify goes out only when the stop comes after the handshake has finished. HttpResponse::cancelled says why the call ended, without matching text. Before a status line has arrived the response is statusCode 0 with statusText and bodyError "Cancelled"; after it, statusCode is the server's and bodyError is "cancelled". A cancelled request is not resent on a fresh connection by the stale-connection retry, and a redirect is not followed. proxy_tunnel and proxy_connect take the token as a trailing default argument and hand it to the socket or the TLS session they open, so the exchange with an HTTP, https or SOCKS5 proxy is covered as well. Without a token (stop_possible() is false) every wait is the single poll it was, and bio_recv does not poll first. Not covered: getaddrinfo, a write blocked because the peer is not reading, and connect on openkal, where connect completes synchronously. download_to_file is unchanged. Tests in test_cancel run against the in-process TLS server and a listener that accepts and says nothing: stopped while waiting for the response head, while a stream waits for its next chunk, and during the handshake, each with a 30 s read timeout so only the token can have ended it; a token already stopped sends nothing; a token never stopped leaves a 300 ms timeout alone; a cancelled connection is not reused; a cancelled request on a pooled connection is not retried; and 20 cancelled requests leave the descriptor count where it was. Not run on macOS or Windows; the change adds no platform-specific code beyond the poll the sockets already use.
A stop that arrived while one address was being tried did not end the connect. The loop went on to the remaining addresses and sent each of them a SYN, and when all had failed the manual DNS fallback ran, which does not look at the token and waits up to 2.5 s for each nameserver. Check the token before each connect attempt, before each lookup and before the fallback. A lookup that has already started still cannot be interrupted.
…eout 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.
The README example uses the jthread's own token. CHANGELOG now names the proxies the stop reaches and the Socket and TlsSocket members that are new, and both say that ok() does not look at cancelled, as it does not look at bodyComplete.
… Windows has entropy The token is set on the socket at the bottom of a connection and nowhere else. TlsSocket::set_stop forwards to the session beneath it, which is where every wait happens inside an https:// proxy's tunnel; setting only its own socket left a reused tunnel on the token of the call that opened it. Once that token was stopped, which a std::jthread does in its destructor, every wait on the tunnel failed at once and the stale-connection retry sent a POST that had already arrived a second time; a later call's own token never reached the tunnel at all. PooledConnection::keep() takes the token off the connection it returns to the pool, so nothing in the pool carries one. download_to_file takes a std::stop_token as its last argument, and DownloadToFileResult::cancelled is set by it or by isCancelled, as HttpResponse::cancelled is. proxy_connect, the wrapper nothing here calls, does not take one. Where the C library is musl, the TLS random generator is seeded from musl's getentropy rather than mbedTLS's reading of /dev/urandom, which above openkal on x86_64-windows-musl does not exist: every handshake there failed with "CTR_DRBG - The entropy source failed". Tests: a stop after the call returned does not reach the next call, and the next call's token does, each directly, through an http:// proxy and through an https:// proxy; a SOCKS5 proxy that never answers; a download cancelled before the head, during the body, with a token already stopped, and by isCancelled. test_cancel imports std before its includes under libc++, whose 20 and 22 fail to link stop_source::request_stop() the other way round. examples/openkal follows mcpp-index's openkal pins (runtime 0.15.2) and cancels a handshake against a local listener, which needs no network; it runs on x86_64-linux-gnu, x86_64-linux-musl, aarch64-linux-musl and x86_64-windows-musl.
… openkal targets Linux runs every test under gcc 16 and llvm 22. macOS (arm64, llvm 22) runs every test; nothing ran there before. Windows runs test_framing, test_tls_verify, test_pool, test_proxy and test_cancel besides test_ca_store. examples/openkal runs for the four targets mcpp-index measures tinyhttps on, under wine and qemu where the host cannot run the program itself.
…s, plan README and CHANGELOG describe the cancellation contract, download_to_file's token, the libc++ link defect and what differs above openkal on Windows. .agents/docs holds the design and the execution plan.
…ord what the plan found
…under wine wine took from four to more than thirty minutes to install on the Linux runners. The program is a static PE that imports only system DLLs, so the Linux job builds it and a windows-2022 job runs it, with Git for Windows's CA bundle beside it.
Sunrisepeak
added a commit
to mcpplibs/mcpp-index
that referenced
this pull request
Oct 1, 2026
A request in flight can be cancelled with a std::stop_token (mcpplibs/tinyhttps#23). A patch of a version consumers already name, so ^ resolution reaches it without a manifest change. The tinyhttps member moves to 0.3.4 and checks the new API without a network: a token stopped before send and download_to_file ends both with cancelled set and statusCode 0, and HttpResponse's four-initialiser brace-initialisation still compiles with the new field defaulted.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
A request that has been sent cannot be abandoned. A caller that stops caring about the response (a user who pressed Esc, a deadline that passed elsewhere) can only leave the call running on a thread and wait for
readTimeoutMs, with the connection held open and the server still producing the answer. The TLS handshake has no timeout at all, so a server that accepts the connection and never replies holds the call for good.This adds an optional
std::stop_tokentosend,send_streamanddownload_to_file. Stopping its source from another thread ends the call within about 50 ms and closes the connection. Released as 0.3.4: everything is added, nothing changes without a token, and every dependency writtentinyhttps = "0.3.x"receives it.Design and plan:
.agents/docs/design-cancel-stop-token.md,.agents/docs/exec-plan-0.3.4-cancel.md.API
HttpResponse::cancelledandDownloadToFileResult::cancelledsay why the call ended, so nobody has to match text. Before the status line:statusCode0, textCancelled. After it: the server's status,bodyError/errorcancelled.ok()does not look atcancelled, as it does not look atbodyComplete. A cancelled request is not retried and its redirect is not followed.isCancelledkeeps working and setscancelledtoo.proxy_tunneltakes the token as a trailing default argument;SocketandTlsSocketgainset_stop,Socketgainsstop_possible. A call by name compiles unchanged; a pointer to one of the three members needs the new parameter in its type.How it works
Three invariants:
Socket::wait_fd), so that is where it is set;TlsSocket::set_stopforwards to the session beneath it, which inside an https:// proxy's tunnel is where the waits are.perform_exchangesets it;PooledConnection::keep()takes it off when the connection goes back to the pool, so nothing in the pool carries a token. A token stopped after its call returned (astd::jthreaddoes that in its destructor) cannot reach the next call.pollit was, andbio_recvdoes not poll first.With a token, waits are
polls in slices of at most 50 ms with a check between them, the connect checks before each address, andbio_recvwaits beforerecvso the handshake and the second half of a TLS record are covered. A stop wins over whatever error the failure would otherwise report.shutdown(fd)from the stopping thread and a wake-up pipe were considered and rejected (fd-reuse races, no pipe on Windows, unmeasured connect semantics on macOS/Windows/openkal); slicing uses only thepollalready used on every platform.Not covered: name resolution, a write blocked because the server is not reading, and the connect above openkal (synchronous there).
Found and fixed on the way
set_stopset only the outer session's own socket, which is unused inside a tunnel. Once that token was stopped, every wait on the tunnel failed at once, and the stale-connection retry sent a POST the target had already received a second time (reproduced: three requests at the target for two calls); a later call's own token never reached the tunnel. Fixed by invariants 1 and 2; covered directly, through http:// and through https:// proxies./dev/urandomunless it recognises glibc, and that file does not exist there: every handshake failed withCTR_DRBG - The entropy source failed. Where the C library is musl (cfg(c-abi = "musl"), as for the socket interface), the generator is now seeded from musl'sgetentropy, which openkal-musl answers throughopenkal.random.std::stop_source::request_stop()in a file that includes<thread>or<condition_variable>beforeimport std;(an inline helper is never emitted); libstdc++ needs the opposite order.test_cancel.cppchooses by_LIBCPP_VERSION; README tells users.Tests
tests/test_cancel.cpp, 24 cases against the in-process TLS server, a listener that never speaks, and the proxy servers. Read timeout 30 s, so only the token can end them.download_to_file: cancelled before the head (no file created), during the body, with a token already stopped, and byisCancelled.Removing the forwarding in
TlsSocket::set_stopfails exactly the two https-proxy reuse cases.CI
test_ca_store+test_framing,test_tls_verify,test_pool,test_proxy,test_cancel(new)examples/openkal: parsers, a cancelled handshake against a local listener, one real HTTPS requestAbove openkal on Windows there is no name resolution (openkal has no resolver interface; musl reads
/etc/resolv.conf), so the HTTPS request there reports "no network"; the cancellation check needs none.