diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f1bca8054..3b6396ed3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -861,6 +861,7 @@ jobs: cd boost-root cmake -S . -B build \ -DCMAKE_BUILD_TYPE=Release \ + -DBUILD_TESTING=ON \ -DBOOST_INCLUDE_LIBRARIES="${{ steps.patch.outputs.module }}" \ -DCMAKE_EXPORT_COMPILE_COMMANDS=ON cmake --build build --target tests -j$(sysctl -n hw.ncpu) diff --git a/doc/error-handling-rulebook.md b/doc/error-handling-rulebook.md index a3a44faec..afa0dfe73 100644 --- a/doc/error-handling-rulebook.md +++ b/doc/error-handling-rulebook.md @@ -26,6 +26,16 @@ by classifying its failures: would be an attractive nuisance: the retry it invites is a double-close hazard, because POSIX leaves descriptor release under `EINTR` unspecified. +- **No caller at all** — an internal wakeup (`interrupt_reactor()`, + `wake_self()`) has nobody to report to, but swallowing the failure + must not swallow every later wake too: a coalescing flag stands for + a byte a failed write never sent, so the failure path disarms it. + The cost is then the wakes already in flight rather than every wake + after them. Never throw from a wake path. +- A service's `shutdown()` is the same shape. Handing process-wide + state back (`sigaction`/`signal` restored to `SIG_DFL`) has no + channel and no caller left to act on one, so its result is + `std::ignore`d deliberately. - Never both channels for one operation. Never `std::error_code&` out-params. Never a throwing/non-throwing overload pair. @@ -62,6 +72,17 @@ as `std::system_error` carrying the code the piecewise spelling returns); and root setup for which no code-returning spelling can exist (`io_context` backend creation, allocation). +Root setup is everything the backend needs — completion port, ring, +wakeup channel, thread pool — and all of it is built during +construction, so a system that refuses any of it throws from the +constructor instead of from the first operation, and the failed +construction leaves nothing open. An initiator may then assume that +infrastructure exists, which is what makes "initiators never throw" +reachable at all. The one piece held back is the thread pool's +workers, which start on the first blocking operation: a thread the +system refuses there is reported through that operation's own channel, +never thrown. + ## 3. The Classification Test Ask: **can the caller reliably prevent the failure by checking state @@ -125,6 +146,23 @@ second channel: - `tls_context` setters record configuration that is applied when a handshake first configures the engine; application failures surface through that handshake's completion. +- A failure with no operation to attach to is latched on the object + and pre-answers the operations that follow, until the step it + belongs to succeeds again: a multishot arming the ring never took + reports to every accept until the next arming clears it. +- A completion queued into a scheduler that spends a + `work_finished()` on everything it dispatches needs a matching + `work_started()`. An operation nothing counted reports through its + owner's channel instead of the completion queue. +- Infrastructure an operation needs but does not own — the + blocking-I/O pool's worker threads, the wait reactor's polling + thread — is created on the first operation that needs it, and a + system that refuses it answers through that operation's completion. + The refusal is that operation's alone: the next one asks again. A + refusal the initiator learns of on its own thread, before any part of + the operation is cross-thread, completes there — the same exit the + closed-object and zero-length contracts take a few lines above it, + not a completion posted back through the scheduler. ## 6. Attributes and Spelling @@ -139,8 +177,11 @@ second channel: functions with a `-DBOOST_COROSIO_DYN_LINK -DBOOST_COROSIO_SOURCE` syntax check. - Deliberate discards use `std::ignore = expr;`, never `(void)` - casts. Reserve them for calls whose outcome is asserted downstream - (hostile-input tests, best-effort bench teardown). + casts and never `[[maybe_unused]]` on a named result. Reserve them + for calls whose outcome is asserted downstream (hostile-input tests, + best-effort bench teardown), whose failure is impossible by + construction at that site, or which have no channel to report on at + all (§1's wake and `shutdown()` paths). - Unused names — parameters kept for signature clarity, structured bindings partially consumed, `#if`-gated uses — are declared `[[maybe_unused]]`, never silenced with a void cast. @@ -157,14 +198,20 @@ second channel: `filename_too_long`, `already_connected` (`connect_pair` on an open socket), `no_such_device_or_address` (`corosio::connect` with no viable - candidate). + candidate), + `resource_unavailable_try_again` (io_uring submission queue + exhausted, for a submitted op, for the signal reader, and for a + multishot arming that never reached the kernel, latched on the + object until an arming succeeds; and a polling thread the system + would not start). - Portable comparison comes from **normalizing at the boundary**: the Windows `make_err` maps the contracted WSA/Win32 codes to generic-category `errc` values (`WSAEOPNOTSUPP`, `WSAENOTSOCK`, `WSAEAFNOSUPPORT`, `WSAEPROTOTYPE`, `WSAEADDRINUSE`, - `WSAEADDRNOTAVAIL`, `ERROR_NEGATIVE_SEEK`; `iocp_make_err` adds the - async condition set and `WSAEBADF`/`ERROR_INVALID_HANDLE`). On - POSIX, raw errno satisfies `errc` comparison with one exception: + `WSAEADDRNOTAVAIL`, `ERROR_NEGATIVE_SEEK`, `ERROR_MAX_THRDS_REACHED`; + `iocp_make_err` adds the async condition set and + `WSAEBADF`/`ERROR_INVALID_HANDLE`). On POSIX, raw errno satisfies + `errc` comparison with one exception: `make_err` normalizes `ENOTSUP` so platforms where it differs from `EOPNOTSUPP` still compare equal to `errc::operation_not_supported`. @@ -175,10 +222,23 @@ second channel: AF_INET sockets; Darwin's `getsockopt(TCP_NODELAY)` on AF_UNIX); Windows reports `not_a_socket` where POSIX validation reports `EBADF` for garbage (non-sentinel) handles. +- A failing call that leaves a zero last error must not become an + empty `error_code`: read the last error before anything that can + clobber it, and substitute rather than report success. Prefer a + contracted condition to a plausible-looking raw platform value, + which is indistinguishable from a code the provider really gave. - Conditions the standard cannot spell come from capy: `capy::cond::eof`, `capy::cond::canceled` (a stop token, not `errc::operation_canceled`), `capy::cond::timeout` (our deadline, not a kernel `ETIMEDOUT`). +- A background thread that dies mid-flight owes one answer, not two: + the error that killed it, latched, both to the operations it was + holding and to the ones that arrive afterwards. `canceled` is a + stop token and belongs only to operations something cancelled — a + thread that never started is the same rule from the other end: the + operation that asked for it reports the code the system gave + (`resource_unavailable_try_again` where the refusal carries no code + of its own), never `canceled`. ## 8. Testing diff --git a/doc/modules/ROOT/pages/4.guide/4c2.configuration.adoc b/doc/modules/ROOT/pages/4.guide/4c2.configuration.adoc index a1df49366..aacc4d191 100644 --- a/doc/modules/ROOT/pages/4.guide/4c2.configuration.adoc +++ b/doc/modules/ROOT/pages/4.guide/4c2.configuration.adoc @@ -134,8 +134,12 @@ On POSIX platforms, file I/O (`stream_file`, `random_access_file`) and DNS resolution use a shared thread pool. * *Concurrent file operations*: increase to match expected - parallelism (e.g. 4 for four concurrent file reads). -* *No file I/O*: leave at 1 (the pool is created lazily). + parallelism (e.g. 4 for four concurrent file reads). The whole set + starts at once, on the first file or resolver call, so a larger pool + makes that one call more expensive and no other. +* *No file I/O*: leave at 1; the pool is created with the context, but + its workers start on the first file or resolver operation, so a pool + nothing uses costs nothing. [#single-threaded-mode] === Locking Tiers (`locking`) diff --git a/include/boost/corosio/detail/thread_pool.hpp b/include/boost/corosio/detail/thread_pool.hpp index d7db11749..469af5d7c 100644 --- a/include/boost/corosio/detail/thread_pool.hpp +++ b/include/boost/corosio/detail/thread_pool.hpp @@ -12,13 +12,16 @@ #include #include +#include #include #include +#include #include #include #include #include +#include #include #include @@ -45,7 +48,7 @@ namespace boost::corosio::detail { my_work w; w.func_ = &my_work::execute; w.result = &r; - pool.post( &w ); + auto ec = pool.post( &w ); @endcode */ struct pool_work_item : intrusive_queue::node @@ -64,8 +67,10 @@ struct pool_work_item : intrusive_queue::node calls). Registered as an `execution_context::service` so it is a singleton per io_context. - Threads are created eagerly in the constructor. The default - thread count is 1. + The service is created with its context, but the workers start on + the first `post()`: a context that never opens a file and never + resolves a name never pays for a thread. The default thread count + is 1. @par Thread Safety All public member functions are thread-safe. @@ -74,28 +79,42 @@ struct pool_work_item : intrusive_queue::node Sets a shutdown flag, notifies all threads, and joins them. In-flight blocking calls complete naturally before the thread exits. + + @note Create this service after the scheduler its work items post + completions to. Services shut down newest first, so a pool created + earlier joins its workers only after the scheduler has drained its + completion queue, and the completion the last worker posts is then + neither run nor destroyed. + + @note The type is symbol-visible because services are keyed by type + identity: with RTTI, hidden behind a shared library boundary, a + module that asks for the pool would look up, and create, one of its + own (the no-RTTI key is a template static whose visibility follows + the template it is instantiated from). */ -class thread_pool final : public capy::execution_context::service +class BOOST_COROSIO_SYMBOL_VISIBLE thread_pool final + : public capy::execution_context::service { std::mutex mutex_; std::condition_variable cv_; intrusive_queue work_queue_; std::vector threads_; + unsigned num_threads_; bool shutdown_ = false; void worker_loop(unsigned index); + std::error_code start_workers() noexcept; public: using key_type = thread_pool; /** Construct the thread pool service. - Eagerly creates all worker threads. + Records the worker count. The workers themselves start on the + first `post()`. @par Exception Safety - Strong guarantee. If thread creation fails, all - already-created threads are shut down and joined - before the exception propagates. + Strong guarantee. @param ctx Reference to the owning execution_context. @param num_threads Number of worker threads. Must be @@ -106,38 +125,64 @@ class thread_pool final : public capy::execution_context::service explicit thread_pool( [[maybe_unused]] capy::execution_context& ctx, unsigned num_threads = 1) + : num_threads_(num_threads) { if (!num_threads) throw std::logic_error("thread_pool requires at least 1 thread"); - threads_.reserve(num_threads); - try - { - for (unsigned i = 0; i < num_threads; ++i) - threads_.emplace_back([this, i] { worker_loop(i + 1); }); - } - catch (...) - { - shutdown(); - throw; - } } - ~thread_pool() override = default; + /** Destroy the pool, joining any worker `shutdown()` never reached. + + The context's shutdown walk is the normal path; this only + catches a pool created after that walk, whose `shutdown()` is + therefore never called and whose joinable threads would + otherwise terminate the process. A pool that was never posted + to holds no thread and needs neither. + */ + ~thread_pool() override + { + if (!threads_.empty()) + shutdown(); + } thread_pool(thread_pool const&) = delete; thread_pool& operator=(thread_pool const&) = delete; /** Enqueue a work item for execution on the thread pool. - Zero-allocation: the caller owns the work item's storage. + The first item posted starts the workers. Zero-allocation: + the caller owns the work item's storage. + + A refusal answers with the code the caller reports for the + operation it was starting, so that a system that will not give + the pool a thread is not mistaken for a cancellation. + + @par Thread Safety + Safe. Racing first posts start the workers once. @param w The work item to execute. Must remain valid until its `func_` has been called. - @return `true` if the item was enqueued, `false` if the - pool has already shut down. + @return An empty code if the item was enqueued; + `capy::error::canceled` if the pool has already shut + down; otherwise the code of the thread the system + refused, which left the pool with no worker at all. */ - bool post(pool_work_item* w) noexcept; + [[nodiscard]] std::error_code post(pool_work_item* w) noexcept; + + /** Return the number of workers the pool has started. + + Zero until the first `post()`, and zero again once + `shutdown()` has joined them. + + @par Thread Safety + Safe. + */ + unsigned worker_count() noexcept + { + std::lock_guard lock(mutex_); + return static_cast(threads_.size()); + } /** Shut down the thread pool. @@ -176,17 +221,58 @@ thread_pool::worker_loop(unsigned index) } } -inline bool +// Called with mutex_ held, so the workers are started once however +// many threads race the first post. +inline std::error_code +thread_pool::start_workers() noexcept +{ + if (!threads_.empty()) + return {}; + std::error_code ec; + try + { + threads_.reserve(num_threads_); + for (unsigned i = 0; i < num_threads_; ++i) + threads_.emplace_back([this, i] { worker_loop(i + 1); }); + } + catch (std::system_error const& e) + { + // The refusal is carried out, not swallowed: a thread the + // system will not give is a real error and the operation that + // asked for it says so, rather than reporting the cancellation + // that belongs to a stop token. + ec = e.code(); + } + catch (...) + { + ec = std::make_error_code(std::errc::resource_unavailable_try_again); + } + // A pool short of workers still runs everything posted to it, only + // less of it at once, so a partial start is a start. What it does + // not do is come back for the rest: the size is a tuning knob, and + // topping it up would put a thread creation on the initiator's + // path for every operation after a refusal. + if (!threads_.empty()) + return {}; + return ec; +} + +inline std::error_code thread_pool::post(pool_work_item* w) noexcept { { std::lock_guard lock(mutex_); if (shutdown_) - return false; + return capy::error::canceled; + // The system can refuse a thread, and an initiator has no way + // to throw; a refused post is the failure the callers already + // report through the operation they were starting. + if (auto ec = start_workers()) + return ec; work_queue_.push(w); } cv_.notify_one(); - return true; + return {}; } inline void @@ -198,6 +284,10 @@ thread_pool::shutdown() } cv_.notify_all(); + // Unlocked, though a post may add to threads_: the flag above is + // published under the same mutex, so a post that has not taken it + // yet will find it set and start nothing, and one already inside + // released the mutex before this thread acquired it. for (auto& t : threads_) { if (t.joinable()) @@ -212,6 +302,77 @@ thread_pool::shutdown() } } +/** A reference to the context's shared thread pool, bound on first use. + + Services that hand blocking work to the pool hold one of these + instead of a reference bound at construction. They are constructed + from the scheduler's constructor, where the pool they created would + be older than the scheduler and would join too late; binding on + first use puts the pool after it instead. + + The owning `io_context` creates the pool service during + construction, so by the time any operation can run the binding only + ever finds it. That is what keeps `get()` from constructing + anything on an initiator's thread, and so from throwing where an + initiator may not: the throwing spelling exists for a scheduler + driven without an `io_context`. What the service defers is its + workers, and those are started by `post()`, which reports a refusal + rather than throwing it. + + @par Thread Safety + Distinct objects: Safe. + Shared objects: Safe. + + @see thread_pool +*/ +class thread_pool_ref +{ + capy::execution_context& ctx_; + std::atomic pool_{nullptr}; + +public: + /** Construct a reference into the given context. + + @param ctx The context whose pool is used. + */ + explicit thread_pool_ref(capy::execution_context& ctx) noexcept + : ctx_(ctx) + { + } + + thread_pool_ref(thread_pool_ref const&) = delete; + thread_pool_ref& operator=(thread_pool_ref const&) = delete; + + /** Return the pool, creating it if this is the first use. + + @par Preconditions + For the throwing clauses below to be unreachable, the owning + context must already hold the pool service. Every `io_context` + constructor installs it — what waits for a first post is the + service's workers, not the service — so the creating branch is + reached only by a scheduler driven without one. + + @par Exception Safety + Strong guarantee. + + @throws std::bad_alloc If the service cannot be allocated. + + @throws std::logic_error If the pool is asked for zero threads. + + @return The context's shared thread pool. + */ + thread_pool& get() + { + auto* p = pool_.load(std::memory_order_acquire); + if (!p) + { + p = &ctx_.use_service(); + pool_.store(p, std::memory_order_release); + } + return *p; + } +}; + } // namespace boost::corosio::detail #endif // BOOST_COROSIO_DETAIL_THREAD_POOL_HPP diff --git a/include/boost/corosio/io_context.hpp b/include/boost/corosio/io_context.hpp index 201ff7833..9125f7db8 100644 --- a/include/boost/corosio/io_context.hpp +++ b/include/boost/corosio/io_context.hpp @@ -224,6 +224,13 @@ effective_concurrency_hint( `capy::run` / `capy::run_async` is work-tracked, so a normal `run()` completion already waits for it. + @par Exception Safety + A context that constructs is usable. The infrastructure its + backend needs — the completion port, the ring, the reactor's + wakeup channel — is created during construction, so a system that + refuses it throws from the constructor rather than from the first + operation, and the failed construction leaves nothing open. + @par Thread Safety Distinct objects: Safe.@n Shared objects: Safe, unless the context was constructed with a @@ -234,18 +241,24 @@ effective_concurrency_hint( */ class BOOST_COROSIO_DECL io_context : public capy::execution_context { - /// Pre-create services that depend on options (before construct). + /// Reject invalid options before the backend is constructed. void apply_options_pre_(io_context_options const& opts); - /// Apply runtime tuning to the scheduler (after construct). + /** Create the blocking-I/O thread pool, apply runtime tuning to the + scheduler and finish bringing the backend up. The tail of every + options constructor: the backend infrastructure whose setup reads + these options is created here, so a failure to create it throws + from the constructor. */ void apply_options_post_( io_context_options const& opts, unsigned concurrency_hint); - /** Apply only the decomposed threading configuration (locking tiers). - Used by the plain constructors, which — unlike the options - constructors — deliberately leave the reactor budget at its defaults - rather than engaging the multi-thread post-everything heuristic. */ + /** Create the blocking-I/O thread pool and apply only the decomposed + threading configuration (locking tiers), then finish bringing the + backend up. The tail of every plain constructor, which — unlike + the options constructors — deliberately leaves the reactor budget + at its defaults rather than engaging the multi-thread + post-everything heuristic. */ void apply_threading_(io_context_options const& opts); protected: @@ -261,6 +274,9 @@ class BOOST_COROSIO_DECL io_context : public capy::execution_context case it reports 0) as the concurrency hint, and the default @ref locking_mode::safe tier. Select a lockless tier via @ref io_context_options::locking. + + @throws std::system_error If the backend's infrastructure + could not be created. */ io_context(); @@ -268,6 +284,9 @@ class BOOST_COROSIO_DECL io_context : public capy::execution_context @param concurrency_hint Hint for the number of threads that will call `run()`. + + @throws std::system_error If the backend's infrastructure + could not be created. */ explicit io_context(unsigned concurrency_hint); @@ -280,6 +299,9 @@ class BOOST_COROSIO_DECL io_context : public capy::execution_context @throws std::invalid_argument If `opts.thread_pool_size` is less than 1 (POSIX). + + @throws std::system_error If the backend's infrastructure + could not be created. */ explicit io_context( io_context_options const& opts, @@ -291,6 +313,9 @@ class BOOST_COROSIO_DECL io_context : public capy::execution_context multiplexer (e.g. `corosio::epoll`). @param concurrency_hint Hint for the number of threads that will call `run()`. + + @throws std::system_error If the backend's infrastructure + could not be created. */ template requires requires { Backend::construct; } @@ -317,6 +342,9 @@ class BOOST_COROSIO_DECL io_context : public capy::execution_context @throws std::invalid_argument If `opts.thread_pool_size` is less than 1 (POSIX). + + @throws std::system_error If the backend's infrastructure + could not be created. */ template requires requires { Backend::construct; } diff --git a/include/boost/corosio/native/detail/epoll/epoll_scheduler.hpp b/include/boost/corosio/native/detail/epoll/epoll_scheduler.hpp index e87c69e55..77ee43e84 100644 --- a/include/boost/corosio/native/detail/epoll/epoll_scheduler.hpp +++ b/include/boost/corosio/native/detail/epoll/epoll_scheduler.hpp @@ -294,8 +294,18 @@ epoll_scheduler::interrupt_reactor() const expected, true, std::memory_order_release, std::memory_order_relaxed)) { - std::uint64_t val = 1; - [[maybe_unused]] auto r = ::write(event_fd_, &val, sizeof(val)); + std::uint64_t val = 1; + if (::write(event_fd_, &val, sizeof(val)) < 0) + { + // The flag is what coalesces later interrupts into a byte + // already in the eventfd; a write that failed put no byte + // there, so leaving it armed would swallow every interrupt + // that follows. Disarming keeps the cost to the interrupts + // already in flight -- the next one arms and writes again, + // instead of every one after this coalescing into a byte + // that does not exist. + eventfd_armed_.store(false, std::memory_order_release); + } } } diff --git a/include/boost/corosio/native/detail/io_uring/io_uring_multishot_acceptor.hpp b/include/boost/corosio/native/detail/io_uring/io_uring_multishot_acceptor.hpp index efe2be8f0..768b044c2 100644 --- a/include/boost/corosio/native/detail/io_uring/io_uring_multishot_acceptor.hpp +++ b/include/boost/corosio/native/detail/io_uring/io_uring_multishot_acceptor.hpp @@ -114,6 +114,12 @@ class io_uring_multishot_acceptor_base waiter_node* read_wait_ = nullptr; std::unique_ptr multi_op_; bool closing_ = false; + /// Non-zero once an arming failed to reach the kernel (guarded by + /// `mutex_`). Nothing will ever deliver a connection through an + /// SQE the ring never took, so an accept reports this instead of + /// parking on a delivery that cannot come. Cleared by the next + /// arming that does reach the kernel. + int arm_err_ = 0; /// Bumped whenever an arming is retired. A re-arm posted for an /// earlier generation must not resubmit: `multi_op_` now names a /// different op, and resubmitting a live one would alias a single @@ -310,6 +316,7 @@ class io_uring_multishot_acceptor_base w->stop_cb.emplace(token, waiter_canceller{w}); bool was_cancelled = false; + int arm_err = 0; { std::lock_guard lk(mutex_); if (w->cancelled.load(std::memory_order_acquire) || closing_) @@ -318,10 +325,17 @@ class io_uring_multishot_acceptor_base } else if (ready_fds_.empty()) { - w->queued = true; - sched_->work_started(); - read_wait_ = w; - return; + // Readiness here means a future delivery, which a failed + // arming has already ruled out: report it rather than + // wait on one. + arm_err = arm_err_; + if (arm_err == 0) + { + w->queued = true; + sched_->work_started(); + read_wait_ = w; + return; + } } // else: a connection arrived while the callback was armed; // complete as ready below. @@ -333,6 +347,7 @@ class io_uring_multishot_acceptor_base op->h = w->h; op->ex = w->ex; op->ec_out = w->ec_out; + op->err = arm_err; if (was_cancelled) op->cancelled.store(true, std::memory_order_release); delete w; @@ -451,15 +466,28 @@ class io_uring_multishot_acceptor_base a live arming here would leave it un-cancelled in the kernel — two armings on one listener, with the retired one's deliveries closed on arrival. + + An arming that failed to reach the kernel is not one of those. + It covers nothing, so a re-listen is the caller's way back and + has to be allowed through. */ bool prepare_listen_arm() noexcept { + bool submitted = true; { std::lock_guard lk(mutex_); - if (multi_op_ && !closing_) + if (multi_op_ && !closing_ && arm_err_ == 0) return false; + // The op behind a failed arming was never handed to the + // ring, so no CQE is owed for it and it is still ours. + submitted = (arm_err_ == 0); } - retire_multishot(); + // Retiring is for an op the kernel still holds: it parks the op + // in the scheduler until a terminal CQE releases it. One that + // was never submitted would wait there for a completion that + // cannot come, so it is reused in place instead. + if (submitted) + retire_multishot(); intrusive_list stale; { std::lock_guard lk(mutex_); @@ -493,17 +521,86 @@ class io_uring_multishot_acceptor_base // Reuse the existing op (re-arm path). Reset peer scratch // so the kernel writes into a clean slot. listen_fd and // impl_ptr are re-seeded so the op can never carry state - // from an arming that has since been torn down. + // from an arming that has since been torn down. `res` is + // one of those: an arming that failed to submit left + // -EAGAIN there, and the reader of `res` cannot tell a + // result the kernel wrote from one it did not. multi_op_->peer_storage = sockaddr_storage{}; multi_op_->peer_len = sizeof(sockaddr_storage); + multi_op_->res = 0; multi_op_->listen_fd = fd_; multi_op_->impl_ptr = this->shared_from_this(); } auto* op = multi_op_.get(); - io_uring_submit_op(*sched_, op); // Deliberately no work_started(): the multishot SQE is a persistent // internal mechanism. User-visible work is tracked per-accept call. + // The try_ spelling is what says so: it keeps a failed submission + // off the scheduler's completion queue, which spends a + // work_finished() on everything it dispatches. + if (io_uring_try_submit_op(*sched_, op)) + { + std::lock_guard lk(mutex_); + arm_err_ = 0; + return; + } + fail_arm(EAGAIN); + } + + /** Report an arming that never reached the kernel. + + No CQE can arrive for an SQE the ring never took, so an accept + parked on this acceptor would park for good. The error is + remembered for the accepts still to come and delivered now to + whoever is already parked, which is the same EAGAIN every other + op reports when the SQ stays full. + + @param err The code to report to the accepts this arming can no + longer serve. + */ + void fail_arm(int err) noexcept + { + intrusive_list claimed; + { + std::lock_guard lk(mutex_); + arm_err_ = err; + // Claim each waiter the way a delivery does. One the + // canceller already claimed belongs to it: cancel_waiter + // is waiting on this mutex to unlink the node itself, so + // it has to still be in the list when it gets in. + intrusive_list keep; + while (auto* w = waiters_.pop_front()) + { + if (!w->cancelled.exchange(true, std::memory_order_acq_rel)) + claimed.push_back(w); + else + keep.push_back(w); + } + while (auto* w = keep.pop_front()) + waiters_.push_back(w); + if (read_wait_ && + !read_wait_->cancelled.exchange( + true, std::memory_order_acq_rel)) + { + claimed.push_back(read_wait_); + read_wait_ = nullptr; + } + } + + while (auto* w = claimed.pop_front()) + { + w->stop_cb.reset(); + // NOLINTNEXTLINE(bugprone-unhandled-exception-at-new) — noexcept arming path: OOM => std::terminate is the intended behavior + auto* op = new uring_accept_op(); + op->h = w->h; + op->ex = w->ex; + op->ec_out = w->ec_out; + op->impl_out = w->impl_out; + op->err = err; + delete w; + sched_->post(op); + sched_->work_finished(); // balance the waiter's work_started + } } /// Pull a parked fd or queue a waiter — used by Derived::accept(). @@ -621,6 +718,18 @@ class io_uring_multishot_acceptor_base ready_op->peer_len = r->peer_len; delete r; } + else if (arm_err_ != 0) + { + // No arming reached the kernel, so no CQE will deliver + // a connection: parking here would park for good. + // NOLINTNEXTLINE(bugprone-unhandled-exception-at-new) — noexcept accept path: OOM => std::terminate is the intended behavior + ready_op = new uring_accept_op(); + ready_op->h = h; + ready_op->ex = ex; + ready_op->ec_out = ec; + ready_op->impl_out = impl_out; + ready_op->err = arm_err_; + } else { w->queued = true; diff --git a/include/boost/corosio/native/detail/io_uring/io_uring_scheduler.hpp b/include/boost/corosio/native/detail/io_uring/io_uring_scheduler.hpp index 0bba10032..2f5b471fb 100644 --- a/include/boost/corosio/native/detail/io_uring/io_uring_scheduler.hpp +++ b/include/boost/corosio/native/detail/io_uring/io_uring_scheduler.hpp @@ -41,6 +41,7 @@ #include #include #include +#include #include #include @@ -183,6 +184,26 @@ class BOOST_COROSIO_DECL io_uring_scheduler final /// Initialize the io_uring ring on first access. Idempotent. void lazy_init_ring() const; + /** Create the io_uring ring. + + Called once the owning `io_context` has applied every option + that feeds the ring's setup flags, so that a kernel that + refuses the ring is reported from the `io_context` + constructor rather than from the first operation submitted + through it. Idempotent. + + @par Exception Safety + Strong guarantee. A ring that cannot be created leaves no + descriptor behind. + + @throws std::system_error If the ring, its wakeup eventfd, or + the poll watching that eventfd could not be set up. + */ + void init_ring() const + { + lazy_init_ring(); + } + /// Wake the leader if it's blocked in `submit_and_wait_timeout`. /// Best-effort: the wakeup is suppressed if the leader has already /// been signalled and not yet acked. @@ -340,9 +361,8 @@ class BOOST_COROSIO_DECL io_uring_scheduler final /** Configure SQPOLL parameters. - Must be called before the first run/poll/post — the values - are cached and read by `lazy_init_ring_unlocked` when the - ring is first constructed. No-op if `enable` is false (the + Must be called before the ring is created — the values are + cached and read when it is. No-op if `enable` is false (the default). @note When combined with single-threaded mode, @@ -418,7 +438,6 @@ class BOOST_COROSIO_DECL io_uring_scheduler final int sq_thread_cpu_ = -1; int cancel_sentinel_ = 0; - mutable std::atomic wakeup_armed_{false}; // Ops adopted by retire_op, kept alive so the kernel never sees // their user_data reused. Declared before ring_ is exited only in @@ -492,16 +511,16 @@ class BOOST_COROSIO_DECL io_uring_scheduler final static constexpr int drain_cqes_max_rounds = 8; static constexpr unsigned long drain_cqes_kick_ns = 1'000'000; - // ring_inited_ goes true once on first run/poll/submit. The init is - // deferred from the constructor so configure_threading() can take - // effect before io_uring_queue_init_params chooses flags. + // ring_inited_ goes true once the ring exists. The init is deferred + // from the constructor so configure_threading() and configure_sqpoll() + // can take effect before io_uring_queue_init_params chooses flags. mutable std::once_flag ring_init_once_; mutable bool ring_inited_ = false; std::size_t do_one(long timeout_us); void process_completions(); void drain_wakeup_eventfd() const noexcept; - void prep_multishot_poll(int fd, void* data) noexcept; + bool prep_multishot_poll(int fd, void* data) noexcept; void lazy_init_ring_unlocked() const; }; @@ -524,9 +543,12 @@ io_uring_scheduler::io_uring_scheduler( get_resolver_service(ctx, *this); get_signal_service(ctx, *this); - // Ring init is deferred to lazy_init_ring() so configure_single_- - // threaded(true), which the io_context applies after construction, - // can take effect before io_uring_queue_init_params chooses flags. + // Ring init is deferred so the options the io_context applies + // after this constructor — the locking tier and SQPOLL — can feed + // the flags io_uring_queue_init_params is given. The io_context + // calls init_ring() once they are in place; the lazy_init_ring() + // calls scattered through the op paths only matter for a + // scheduler used without one. } inline @@ -632,6 +654,7 @@ io_uring_scheduler::lazy_init_ring_unlocked() const if (!sqe) { ::close(wakeup_eventfd_); + wakeup_eventfd_ = -1; ::io_uring_queue_exit(&ring_); detail::throw_system_error( make_err(ENOSPC), "io_uring_get_sqe (wakeup)"); @@ -646,6 +669,7 @@ io_uring_scheduler::lazy_init_ring_unlocked() const if (submit_rc < 0) { ::close(wakeup_eventfd_); + wakeup_eventfd_ = -1; ::io_uring_queue_exit(&ring_); detail::throw_system_error( make_err(-submit_rc), "io_uring_submit (wakeup)"); @@ -763,34 +787,29 @@ io_uring_scheduler::interrupt_reactor() const noexcept // (drained together by drain_wakeup_eventfd's single read of // the eventfd counter). std::uint64_t v = 1; - [[maybe_unused]] auto r = ::write(wakeup_eventfd_, &v, sizeof(v)); - wakeup_armed_.store(true, std::memory_order_release); + std::ignore = ::write(wakeup_eventfd_, &v, sizeof(v)); } inline void io_uring_scheduler::drain_wakeup_eventfd() const noexcept { std::uint64_t v; - [[maybe_unused]] auto r = ::read(wakeup_eventfd_, &v, sizeof(v)); + std::ignore = ::read(wakeup_eventfd_, &v, sizeof(v)); // Multishot poll never needs re-arming. The poll-add was queued // once at lazy_init_ring with IORING_POLL_ADD_MULTI; each eventfd // POLLIN produces a CQE without consuming the SQE. - // - // Release pairs with the acquire side of interrupt_reactor's CAS: - // a posting thread that observes wakeup_armed_ == false from this - // store will see the eventfd already drained by the leader. - wakeup_armed_.store(false, std::memory_order_release); } -inline void +inline bool io_uring_scheduler::prep_multishot_poll(int fd, void* data) noexcept { // Prepare a multishot POLLIN SQE on `fd` tagged with `data`. Caller holds // ring_mutex_ and flushes separately (re-arm sites ride the batch submit; - // register/init submit explicitly). Best-effort: a get_sqe failure after - // one flush leaves the poll un-armed. Shared by the wakeup-eventfd and - // signal self-pipe multishot polls. + // register/init submit explicitly). Returns false when no SQE could be + // had after one flush, so a caller that has someone to report to does + // not mistake the submit of an empty queue for an armed poll. Shared by + // the wakeup-eventfd and signal self-pipe multishot polls. ::io_uring_sqe* sqe = ::io_uring_get_sqe(&ring_); if (!sqe) { @@ -798,9 +817,10 @@ io_uring_scheduler::prep_multishot_poll(int fd, void* data) noexcept sqe = ::io_uring_get_sqe(&ring_); } if (!sqe) - return; + return false; ::io_uring_prep_poll_multishot(sqe, fd, POLLIN); ::io_uring_sqe_set_data(sqe, data); + return true; } inline std::error_code @@ -817,7 +837,11 @@ io_uring_scheduler::register_signal_reader(int read_fd) lazy_init_ring(); lock_type lock(ring_mutex_); - prep_multishot_poll(read_fd, &signal_pipe_sentinel_); + // EAGAIN, not ENOBUFS: an exhausted submission queue reports the + // same retryable code wherever it is hit, and the next add() is + // the retry. + if (!prep_multishot_poll(read_fd, &signal_pipe_sentinel_)) + return make_err(EAGAIN); int rc = ::io_uring_submit(&ring_); if (rc < 0) return make_err(-rc); @@ -1310,8 +1334,10 @@ io_uring_scheduler::process_completions() // If multishot terminated (kernel dropped under memory // pressure or similar), re-arm. Each CQE except the last // sets IORING_CQE_F_MORE. + // Best-effort: nothing here has a caller to report to, and + // prep_multishot_poll already flushed once to make room. if ((cqe->flags & IORING_CQE_F_MORE) == 0) - prep_multishot_poll(wakeup_eventfd_, nullptr); + std::ignore = prep_multishot_poll(wakeup_eventfd_, nullptr); } else if (ud == &cancel_sentinel_) { @@ -1328,7 +1354,7 @@ io_uring_scheduler::process_completions() // io_uring_inflight_ (like the wakeup eventfd poll): its progress // does not gate DEFER_TASKRUN GETEVENTS. if ((cqe->flags & IORING_CQE_F_MORE) == 0) - prep_multishot_poll( + std::ignore = prep_multishot_poll( signal_pipe_read_fd_, &signal_pipe_sentinel_); bool expected = false; if (signal_drain_op_.queued_.compare_exchange_strong( @@ -1565,7 +1591,8 @@ io_uring_scheduler::drain_cqes_for(io_uring_op* target) noexcept // does. Never incremented, so never decremented. drain_wakeup_eventfd(); if ((cqe->flags & IORING_CQE_F_MORE) == 0) - prep_multishot_poll(wakeup_eventfd_, nullptr); + std::ignore = + prep_multishot_poll(wakeup_eventfd_, nullptr); } else if (ud == &signal_pipe_sentinel_) { @@ -1577,7 +1604,7 @@ io_uring_scheduler::drain_cqes_for(io_uring_op* target) noexcept // was armed via prep_multishot_poll, which never increments), // so it must NOT be decremented. if ((cqe->flags & IORING_CQE_F_MORE) == 0) - prep_multishot_poll( + std::ignore = prep_multishot_poll( signal_pipe_read_fd_, &signal_pipe_sentinel_); } else if (ud == &cancel_sentinel_) diff --git a/include/boost/corosio/native/detail/io_uring/io_uring_socket_ops.hpp b/include/boost/corosio/native/detail/io_uring/io_uring_socket_ops.hpp index ffa2ed2a6..00ced752d 100644 --- a/include/boost/corosio/native/detail/io_uring/io_uring_socket_ops.hpp +++ b/include/boost/corosio/native/detail/io_uring/io_uring_socket_ops.hpp @@ -31,6 +31,7 @@ #include +#include #include #include #include @@ -427,27 +428,28 @@ struct uring_connect_op : io_uring_op } }; -/** Submit an `io_uring_op` whose `prep_func` is set. +/** Submit an `io_uring_op`, reporting an SQ that stayed full. - Acquires the ring mutex, prepares the SQE, and (under the same - mutex) CAS-sets `submit_op_posted_`. The first submitter of a - batch wins the CAS and posts the scheduler's `submit_sqes_op`, - which later flushes all queued SQEs in a single - `io_uring_submit_and_get_events` call and drains any ready CQEs. - Subsequent submitters in the same batch piggyback — their SQEs - sit in the user-space SQ ring until that op dispatches. - - On SQ-ring exhaustion (after one flush retry), surfaces `EAGAIN` - on `*op->ec_out` and queues the op as completed so its handler - dispatches on the next `do_one` cycle. + The body behind @ref io_uring_submit_op and + @ref io_uring_try_submit_op; `counted` selects which of the two + answers an exhausted SQ ring gets. @pre `op->prep_func != nullptr`. @par Exception Safety Nothrow. + + @param sched The scheduler owning the ring. + @param op The operation to submit. + @param counted True when a `work_started()` backs this op, so its + completion may ride the scheduler's queue. + + @return `false` when the SQ stayed full and the op was left for the + caller to report; `true` otherwise. */ -inline void -io_uring_submit_op(io_uring_scheduler& sched, io_uring_op* op) noexcept +inline bool +io_uring_do_submit_op( + io_uring_scheduler& sched, io_uring_op* op, bool counted) noexcept { sched.lazy_init_ring(); @@ -466,14 +468,26 @@ io_uring_submit_op(io_uring_scheduler& sched, io_uring_op* op) noexcept if (!sqe) { // SQ stayed full after one flush — synchronous failure path. - // Surface EAGAIN and queue the op as completed so do_one - // dispatches the handler. The caller's work_started() already - // counted this op. (CAS path is not entered here.) - if (op->ec_out) - *op->ec_out = make_err(EAGAIN); + // Report EAGAIN the way a CQE would, because every handler + // re-derives its result from `res`: a code written straight + // to ec_out is overwritten on the way out, and a res left at + // zero reads as end-of-file, a zero-byte write, or a + // successful connect that never happened. Queue the op as + // completed so do_one dispatches the handler; the caller's + // work_started() pays for the work_finished() do_one spends + // on it. (CAS path is not entered here.) + op->res = -EAGAIN; + if (!counted) + { + // Nothing counted this op, so queueing it would spend a + // work_finished() the context never owed and drive + // outstanding_work_ below what is really outstanding. + // The owner reports the failure instead. + return false; + } typename io_uring_scheduler::lock_type lock(sched.dispatch_mutex()); sched.push_completed_locked(op); - return; + return true; } op->prep_func(op, sqe); @@ -498,6 +512,72 @@ io_uring_submit_op(io_uring_scheduler& sched, io_uring_op* op) noexcept // Flush is deferred to submit_sqes_op; post() owns the wake. sched.post(&sched.submit_op_ref()); } + return true; +} + +/** Submit an `io_uring_op` a `work_started()` already paid for. + + Acquires the ring mutex, prepares the SQE, and (under the same + mutex) CAS-sets `submit_op_posted_`. The first submitter of a + batch wins the CAS and posts the scheduler's `submit_sqes_op`, + which later flushes all queued SQEs in a single + `io_uring_submit_and_get_events` call and drains any ready CQEs. + Subsequent submitters in the same batch piggyback — their SQEs + sit in the user-space SQ ring until that op dispatches. + + On SQ-ring exhaustion (after one flush retry), completes the op + with `EAGAIN` and queues it so its handler dispatches on the next + `do_one` cycle, exactly as if the kernel had returned that error. + That is why this spelling reports nothing: the failure reaches the + caller as the operation's own `EAGAIN` completion. + + @pre `op->prep_func != nullptr`. + @pre A `work_started()` backs this op, so the `work_finished()` the + scheduler spends on everything it dispatches is owed. + + @par Exception Safety + Nothrow. + + @param sched The scheduler owning the ring. + @param op The operation to submit. + + @see io_uring_try_submit_op +*/ +inline void +io_uring_submit_op(io_uring_scheduler& sched, io_uring_op* op) noexcept +{ + // The result is true by construction: a counted op's SQ-full path + // queues the op and answers through its own completion. + io_uring_do_submit_op(sched, op, true); +} + +/** Submit an `io_uring_op` nothing counted, reporting a full SQ. + + The scheduler spends a `work_finished()` on everything it + dispatches out of `completed_ops_`, so an op no `work_started()` + backs cannot be completed through that queue: doing so drives + `outstanding_work_` below what is really outstanding. This spelling + hands an SQ that stayed full back to the owner instead, which + reports it through its own channel — the multishot accept arm + latches it and answers the accepts it can no longer serve. + + @pre `op->prep_func != nullptr`. + + @par Exception Safety + Nothrow. + + @param sched The scheduler owning the ring. + @param op The operation to submit. + + @return True when the SQE was prepared; false when the SQ stayed + full after one flush and the caller owns the failure. + + @see io_uring_submit_op +*/ +[[nodiscard]] inline bool +io_uring_try_submit_op(io_uring_scheduler& sched, io_uring_op* op) noexcept +{ + return io_uring_do_submit_op(sched, op, false); } /** Readiness wait via `IORING_OP_POLL_ADD`. @@ -571,11 +651,32 @@ struct uring_wait_op : io_uring_op if (self->sched_) self->sched_->reset_inline_budget(); + // A POLL_ADD completion carries the error band in its revents + // (res), not as a negative res, so name the reason the reactor + // way — SO_ERROR, or EIO when the kernel has none — instead of + // completing wait(error) with an empty, benign-looking code. + // OOB (POLLPRI) is a readiness signal, not an error. + std::error_code ec{}; + if (self->res < 0) + { + ec = make_err(-self->res); + } + else if (self->res & (POLLERR | POLLHUP | POLLNVAL)) + { + int so_err = 0; + socklen_t len = sizeof(so_err); + if (::getsockopt(self->fd, SOL_SOCKET, SO_ERROR, &so_err, &len) < 0) + so_err = errno; + if (so_err == 0) + so_err = EIO; + ec = make_err(so_err); + } + // Wait reports only success/cancel/error — no bytes, no EOF. decode_io_result( self->ec_out, self->cancelled.load(std::memory_order_acquire), - self->res < 0 ? make_err(-self->res) : std::error_code{}, + ec, /*is_read=*/false, /*bytes=*/0, /*empty_buffer=*/false); coro_resume(self); diff --git a/include/boost/corosio/native/detail/iocp/win_local_stream_acceptor_service.hpp b/include/boost/corosio/native/detail/iocp/win_local_stream_acceptor_service.hpp index 66af42719..179cb1ad0 100644 --- a/include/boost/corosio/native/detail/iocp/win_local_stream_acceptor_service.hpp +++ b/include/boost/corosio/native/detail/iocp/win_local_stream_acceptor_service.hpp @@ -113,7 +113,7 @@ local_stream_acceptor_wait_op::do_cancel_impl(overlapped_op* base) noexcept if (op->acceptor_ptr) { op->acceptor_ptr->socket_service().scheduler() - .cancel_wait_if_constructed(op); + .cancel_wait(op); } } @@ -291,7 +291,7 @@ win_local_stream_acceptor_internal::cancel() noexcept ::CancelIoEx(reinterpret_cast(socket_), nullptr); acc_.request_cancel(); wt_.request_cancel(); - svc_.scheduler().cancel_wait_if_constructed(&wt_); + svc_.scheduler().cancel_wait(&wt_); } inline std::coroutine_handle<> @@ -345,7 +345,7 @@ win_local_stream_acceptor_internal::close_socket() noexcept // to connection_aborted by iocp_make_err (see win_tcp_socket close_socket). acc_.request_cancel(); wt_.request_cancel(); - svc_.scheduler().cancel_wait_if_constructed(&wt_); + svc_.scheduler().cancel_wait(&wt_); if (socket_ != INVALID_SOCKET) { diff --git a/include/boost/corosio/native/detail/iocp/win_local_stream_service.hpp b/include/boost/corosio/native/detail/iocp/win_local_stream_service.hpp index b8725e23f..84b0cd5cb 100644 --- a/include/boost/corosio/native/detail/iocp/win_local_stream_service.hpp +++ b/include/boost/corosio/native/detail/iocp/win_local_stream_service.hpp @@ -218,7 +218,7 @@ local_stream_wait_op::do_cancel_impl(overlapped_op* base) noexcept ::CancelIoEx( reinterpret_cast(op->internal.native_handle()), op); } - op->internal.svc_.scheduler().cancel_wait_if_constructed(op); + op->internal.svc_.scheduler().cancel_wait(op); } // ============================================================ @@ -679,7 +679,7 @@ win_local_stream_socket_internal::cancel() noexcept rd_.request_cancel(); wr_.request_cancel(); wt_.request_cancel(); - svc_.scheduler().cancel_wait_if_constructed(&wt_); + svc_.scheduler().cancel_wait(&wt_); } inline void @@ -692,7 +692,7 @@ win_local_stream_socket_internal::close_socket() noexcept rd_.request_cancel(); wr_.request_cancel(); wt_.request_cancel(); - svc_.scheduler().cancel_wait_if_constructed(&wt_); + svc_.scheduler().cancel_wait(&wt_); if (socket_ != INVALID_SOCKET) { diff --git a/include/boost/corosio/native/detail/iocp/win_overlapped_op.hpp b/include/boost/corosio/native/detail/iocp/win_overlapped_op.hpp index 75d80331e..86cc33d8d 100644 --- a/include/boost/corosio/native/detail/iocp/win_overlapped_op.hpp +++ b/include/boost/corosio/native/detail/iocp/win_overlapped_op.hpp @@ -153,7 +153,12 @@ struct overlapped_op bytes_transferred = 0; empty_buffer = false; is_read = false; - cancelled.store(false, std::memory_order_relaxed); + // Release, not relaxed: the wait reactor decides whether a + // queued cancel request is stale by loading this flag, so the + // clear has to be ordered against the fields written above it + // rather than against whichever lock the caller happens to + // take next. + cancelled.store(false, std::memory_order_release); } // coro_op::request_cancel() (set the cancelled flag) is inherited diff --git a/include/boost/corosio/native/detail/iocp/win_resolver_service.hpp b/include/boost/corosio/native/detail/iocp/win_resolver_service.hpp index dfda81821..4ef7b3eb0 100644 --- a/include/boost/corosio/native/detail/iocp/win_resolver_service.hpp +++ b/include/boost/corosio/native/detail/iocp/win_resolver_service.hpp @@ -85,15 +85,27 @@ class BOOST_COROSIO_DECL win_resolver_service final /** Notify scheduler that I/O work completed. */ void work_finished() noexcept; - /** Return the resolver thread pool. */ - thread_pool& pool() noexcept + /** Return the resolver thread pool. + + The pool's service is created on first use, so this can fail + where a plain accessor could not. Its workers start later, on + the first post, and a thread the system refuses there is + reported by that post rather than thrown here. + + @throws std::bad_alloc If the service cannot be allocated. + + @return The context's shared blocking-I/O pool. + + @see thread_pool_ref::get + */ + thread_pool& pool() { - return pool_; + return pool_.get(); } private: scheduler& sched_; - thread_pool& pool_; + thread_pool_ref pool_; win_mutex mutex_; intrusive_list resolver_list_; std::unordered_map> @@ -245,6 +257,9 @@ resolve_op::do_complete( op->results = nullptr; } op->cancel_handle = nullptr; + // Dropping the keepalive may destroy the implementation this op + // is embedded in, so nothing may touch it afterwards. + auto suicide = std::move(op->impl_ptr); return; } @@ -276,6 +291,9 @@ resolve_op::do_complete( op->cancel_handle = nullptr; op->cont.h = op->h; + // Hold the keepalive across the dispatch: it may be the last + // reference to the implementation this op is embedded in. + auto prevent_destroy = std::move(op->impl_ptr); dispatch_coro(op->ex, op->cont).resume(); } @@ -298,6 +316,9 @@ reverse_resolve_op::do_complete( if (!owner) { op->stop_cb.reset(); + // Dropping the keepalive may destroy the implementation this + // op is embedded in, so nothing may touch it afterwards. + auto suicide = std::move(op->impl_ptr); return; } @@ -321,6 +342,9 @@ reverse_resolve_op::do_complete( } op->cont.h = op->h; + // Hold the keepalive across the dispatch: it may be the last + // reference to the implementation this op is embedded in. + auto prevent_destroy = std::move(op->impl_ptr); dispatch_coro(op->ex, op->cont).resume(); } @@ -363,6 +387,12 @@ win_resolver::resolve( // Keep io_context alive while resolution is pending svc_.work_started(); + // Prevent impl destruction while the async resolve is in flight and + // its completion waits in the scheduler queue: the op is embedded in + // this win_resolver, which teardown may otherwise free before the + // queued completion drains. Mirrors the reverse path's keepalive. + op.impl_ptr = this->shared_from_this(); + int result = ::GetAddrInfoExW( op.host_w.empty() ? nullptr : op.host_w.c_str(), op.service_w.empty() ? nullptr : op.service_w.c_str(), NS_DNS, nullptr, @@ -418,15 +448,21 @@ win_resolver::reverse_resolve( reverse_pool_op_.resolver_ = this; reverse_pool_op_.ref_ = this->shared_from_this(); reverse_pool_op_.func_ = &win_resolver::do_reverse_resolve_work; - if (!svc_.pool().post(&reverse_pool_op_)) + if (auto pec = svc_.pool().post(&reverse_pool_op_)) { - // Pool shut down — complete with cancellation + // The pool is shutting down, or the system refused it a thread. + // Nothing of this resolve went cross-thread, so it answers here + // rather than through a completion the scheduler has to carry + // back. reverse_pool_op_.ref_.reset(); - op.cancelled.store(true, std::memory_order_release); + op.stop_cb.reset(); svc_.work_finished(); - svc_.post(&reverse_op_); + *ec = pec; + op.cont.h = h; + return dispatch_coro(d, op.cont); } - // completion is always posted to scheduler queue, never inline. + // The work the pool took completes on its own thread and is always + // posted to the scheduler queue, never inline. return std::noop_coroutine(); } @@ -489,9 +525,10 @@ win_resolver::do_reverse_resolve_work(pool_work_item* w) noexcept self->svc_.work_finished(); - // Move ref to stack before post — post may trigger destroy_impl - // which erases the last shared_ptr, destroying *self (and *pw) - auto ref = std::move(pw->ref_); + // Hand the keepalive to the op: the completion waits in the + // scheduler's queue, and the implementation embedding it must + // outlive that wait. Nothing may touch *self after the post. + self->reverse_op_.impl_ptr = std::move(pw->ref_); self->svc_.post(&self->reverse_op_); } @@ -500,7 +537,7 @@ win_resolver::do_reverse_resolve_work(pool_work_item* w) noexcept inline win_resolver_service::win_resolver_service( capy::execution_context& ctx, scheduler& sched) : sched_(sched) - , pool_(ctx.use_service()) + , pool_(ctx) { } diff --git a/include/boost/corosio/native/detail/iocp/win_scheduler.hpp b/include/boost/corosio/native/detail/iocp/win_scheduler.hpp index 55e148417..bb5b1fddb 100644 --- a/include/boost/corosio/native/detail/iocp/win_scheduler.hpp +++ b/include/boost/corosio/native/detail/iocp/win_scheduler.hpp @@ -138,31 +138,21 @@ class BOOST_COROSIO_DECL win_scheduler final mutable op_queue completed_ops_; std::unique_ptr timers_; std::unique_ptr wait_reactor_; - std::once_flag wait_reactor_once_; - std::atomic wait_reactor_ready_{false}; BOOST_COROSIO_MSVC_WARNING_POP public: - /** Auxiliary select-based reactor for IOCP wait operations. + /** Return the auxiliary select-based reactor for wait operations. - Lazily created on first access; lives for the lifetime of the - scheduler and is stopped+joined in ~win_scheduler. Used by - socket and acceptor wait() implementations whose readiness - cannot be expressed natively in IOCP (datagram-read, - acceptor-read, error-wait). + Built with the scheduler and stopped+joined in ~win_scheduler, + so it is always there to hand a wait to. Used by socket and + acceptor wait() implementations whose readiness cannot be + expressed natively in IOCP (datagram-read, acceptor-read, + error-wait). */ win_wait_reactor& wait_reactor(); - /** Cancel a parked wait op only if the reactor exists. - - Safe to call from any thread. If no wait op has ever been - registered, the reactor was never constructed, so there is - nothing to cancel and we avoid spinning up a thread + wakeup - socketpair on the cancel path. Acquire/release pairs with the - store in wait_reactor() so reads see a fully-constructed - reactor when the flag is true. - */ - void cancel_wait_if_constructed(overlapped_op* op) noexcept; + /// Cancel a parked wait op. Safe to call from any thread. + void cancel_wait(overlapped_op* op) noexcept; }; /* @@ -217,10 +207,11 @@ struct thread_context_guard } // namespace iocp -// The constructor, ~win_scheduler() and shutdown() are defined at the -// bottom of this header so the unique_ptr's deleter -// and wait_reactor_->stop() see the type complete. The constructor -// needs it too: its unwind path destroys wait_reactor_. +// The constructor, ~win_scheduler(), shutdown(), wait_reactor() and +// cancel_wait() are defined at the bottom of this header so the +// unique_ptr's deleter, its operator* and +// wait_reactor_->stop() see the type complete. The constructor needs +// it to build the reactor, and its unwind path to destroy it. inline void win_scheduler::post(std::coroutine_handle<> h) const @@ -700,8 +691,8 @@ win_scheduler::update_timeout() // Defer including the auxiliary wait reactor until the scheduler is // fully defined, since the reactor's inline methods call back into // win_scheduler. This also gives the ctor, dtor and wait_reactor() -// below a complete win_wait_reactor type for unique_ptr destruction -// and lazy construction. +// below a complete win_wait_reactor type: the constructor builds it, +// the destructor and unique_ptr's deleter tear it down. // // The macro lets win_wait_reactor.hpp diagnose direct inclusion // (which would land it here with win_scheduler still incomplete). @@ -727,14 +718,42 @@ inline win_scheduler::win_scheduler( if (iocp_ == nullptr) detail::throw_system_error(make_err(::GetLastError())); - // Create timer wakeup mechanism (tries NT native, falls back to thread) - timers_ = make_win_timers(iocp_, &dispatch_required_); - - // Connect timer service to scheduler - set_timer_service(&get_timer_service(ctx, *this)); - - // Initialize resolver service - ctx.make_service(*this); + try + { + timers_ = make_win_timers(iocp_, &dispatch_required_); + set_timer_service(&get_timer_service(ctx, *this)); + ctx.make_service(*this); + + // A scheduler whose wait reactor could not be built would + // answer every wait with a parked op, so it refuses to exist + // instead. Last, so the catch below is the whole cleanup: + // the reactor holds its own Winsock reference and needs no + // help from the order. + wait_reactor_ = std::make_unique(*this); + } + catch (...) + { + // ~win_scheduler never runs for a constructor that throws, and + // the port is a raw handle nothing else owns. The timer thread + // is stopped first because it posts to that port. + // + // The services registered above are not unregistered here, and + // cannot be: the context owns them and offers no way to take + // one back. Each holds a scheduler reference this unwind is + // about to invalidate, and each survives to be shut down and + // destroyed with the context. What makes that safe is that + // none of them touches its scheduler while it holds nothing: + // a scheduler that never finished constructing handed out no + // timer, resolver or file, so every one of those shutdowns + // walks an empty list. Registering anything here that would + // reach back into the scheduler on an empty shutdown breaks + // that, and the fix would have to be a rollback in the + // context, not an ordering trick here. + timers_.reset(); + ::CloseHandle(iocp_); + iocp_ = nullptr; + throw; + } } inline void @@ -755,8 +774,7 @@ win_scheduler::shutdown() // Same problem for the auxiliary wait reactor: ops parked in it // owe completion packets. Stop the reactor early so its loop // posts them as cancelled and the pending count can reach zero. - if (wait_reactor_ready_.load(std::memory_order_acquire)) - wait_reactor_->stop(); + wait_reactor_->stop(); // Reap every packet still owed to the port before the services // free the op memory those packets reference. Work-guard credits, @@ -842,8 +860,7 @@ win_scheduler::shutdown() inline win_scheduler::~win_scheduler() { - if (wait_reactor_) - wait_reactor_->stop(); + wait_reactor_->stop(); wait_reactor_.reset(); if (iocp_ != nullptr) @@ -853,22 +870,13 @@ inline win_scheduler::~win_scheduler() inline win_wait_reactor& win_scheduler::wait_reactor() { - // Lazy thread-safe init: multiple IOCP workers may race the first - // wait() call. wait_reactor_ready_ is set with release ordering - // after construction so cancel_wait_if_constructed can safely - // observe the reactor without forcing construction itself. - std::call_once(wait_reactor_once_, [this] { - wait_reactor_ = std::make_unique(*this); - wait_reactor_ready_.store(true, std::memory_order_release); - }); return *wait_reactor_; } inline void -win_scheduler::cancel_wait_if_constructed(overlapped_op* op) noexcept +win_scheduler::cancel_wait(overlapped_op* op) noexcept { - if (wait_reactor_ready_.load(std::memory_order_acquire)) - wait_reactor_->cancel_wait(op); + wait_reactor_->cancel_wait(op); } } // namespace boost::corosio::detail diff --git a/include/boost/corosio/native/detail/iocp/win_signals.hpp b/include/boost/corosio/native/detail/iocp/win_signals.hpp index adfb32bba..4a538eba2 100644 --- a/include/boost/corosio/native/detail/iocp/win_signals.hpp +++ b/include/boost/corosio/native/detail/iocp/win_signals.hpp @@ -26,6 +26,7 @@ #include #include +#include #include @@ -170,7 +171,11 @@ class BOOST_COROSIO_DECL win_signals final win_signals(win_signals const&) = delete; win_signals& operator=(win_signals const&) = delete; - /** Shut down the service. */ + /** Shut down the service. + + Destroys every implementation the service still owns and gives + each of their registrations back to the process-global table. + */ void shutdown() override; /** Destroy a signal implementation. */ @@ -397,19 +402,41 @@ inline win_signals::~win_signals() inline void win_signals::shutdown() { + signal_detail::signal_state* state = signal_detail::get_signal_state(); + std::lock_guard state_lock(state->mutex); std::lock_guard lock(mutex_); for (auto* impl = impl_list_.pop_front(); impl != nullptr; impl = impl_list_.pop_front()) { - // Clear registrations while (auto* reg = impl->signals_) { + int const signal_number = reg->signal_number; + + // The registration table outlives every io_context, so a set + // still registered here has to give its count and handler + // back the way clear() would: otherwise the handler stays + // installed for a signal no set owns any more. The per-node + // table unlink clear() also does is skipped in favour of the + // wholesale null-out below. + if (state->registration_count[signal_number] == 1) + std::ignore = ::signal(signal_number, SIG_DFL); + + --state->registration_count[signal_number]; + impl->signals_ = reg->next_in_set; delete reg; } delete impl; } + + // Every live registration hung off an implementation in impl_list_, + // so the whole table goes stale at once and can be dropped wholesale + // rather than node by node. It has to be dropped: deliver_signal() + // walks this service until the destructor unlinks it from the global + // list. + for (int i = 0; i < max_signal_number; ++i) + registrations_[i] = nullptr; } inline io_object::implementation* diff --git a/include/boost/corosio/native/detail/iocp/win_tcp_acceptor_service.hpp b/include/boost/corosio/native/detail/iocp/win_tcp_acceptor_service.hpp index 80c52f86d..a490328cf 100644 --- a/include/boost/corosio/native/detail/iocp/win_tcp_acceptor_service.hpp +++ b/include/boost/corosio/native/detail/iocp/win_tcp_acceptor_service.hpp @@ -169,10 +169,9 @@ wait_op::do_cancel_impl(overlapped_op* base) noexcept reinterpret_cast(op->internal.native_handle()), op); } // wait_type::error parks the op in the auxiliary select reactor; - // wake it so the reactor can post a cancelled completion. No-op - // if the reactor was never constructed (e.g. zero-byte WSARecv - // path was the only thing this socket ever did). - op->internal.svc_.scheduler().cancel_wait_if_constructed(op); + // wake it so the reactor can post a cancelled completion. A cancel + // for an op the reactor never registered finds nothing and returns. + op->internal.svc_.scheduler().cancel_wait(op); } inline void @@ -197,7 +196,7 @@ acceptor_wait_op::do_cancel_impl(overlapped_op* base) noexcept if (op->acceptor_ptr) { op->acceptor_ptr->socket_service().scheduler() - .cancel_wait_if_constructed(op); + .cancel_wait(op); } } @@ -791,9 +790,8 @@ win_tcp_socket_internal::cancel() noexcept wt_.request_cancel(); // CancelIoEx covers overlapped I/O on the socket but cannot reach // a wait op parked in the auxiliary reactor (no overlapped is - // outstanding). Route through the reactor explicitly. Safe no-op - // if the reactor was never constructed. - svc_.scheduler().cancel_wait_if_constructed(&wt_); + // outstanding). Route through the reactor explicitly. + svc_.scheduler().cancel_wait(&wt_); } inline void @@ -813,7 +811,7 @@ win_tcp_socket_internal::close_socket() noexcept // otherwise the reactor would keep polling a dangling fd (and on a Winsock // SOCKET-id reuse the wrong fd could be polled briefly). wt_.request_cancel(); - svc_.scheduler().cancel_wait_if_constructed(&wt_); + svc_.scheduler().cancel_wait(&wt_); if (socket_ != INVALID_SOCKET) { @@ -1589,7 +1587,7 @@ win_tcp_acceptor_internal::cancel() noexcept acc_.request_cancel(); wt_.request_cancel(); - svc_.scheduler().cancel_wait_if_constructed(&wt_); + svc_.scheduler().cancel_wait(&wt_); } inline void @@ -1601,7 +1599,7 @@ win_tcp_acceptor_internal::close_socket() noexcept acc_.request_cancel(); // Tear down any aux-reactor-parked wait op first. wt_.request_cancel(); - svc_.scheduler().cancel_wait_if_constructed(&wt_); + svc_.scheduler().cancel_wait(&wt_); if (socket_ != INVALID_SOCKET) { diff --git a/include/boost/corosio/native/detail/iocp/win_udp_service.hpp b/include/boost/corosio/native/detail/iocp/win_udp_service.hpp index b26028862..9ac93efff 100644 --- a/include/boost/corosio/native/detail/iocp/win_udp_service.hpp +++ b/include/boost/corosio/native/detail/iocp/win_udp_service.hpp @@ -266,7 +266,7 @@ udp_wait_op::do_cancel_impl(overlapped_op* base) noexcept ::CancelIoEx( reinterpret_cast(op->internal.native_handle()), op); } - op->internal.svc_.scheduler().cancel_wait_if_constructed(op); + op->internal.svc_.scheduler().cancel_wait(op); } // Connected-mode completion handlers @@ -746,7 +746,7 @@ win_udp_socket_internal::cancel() noexcept send_wr_.request_cancel(); recv_rd_.request_cancel(); wt_.request_cancel(); - svc_.scheduler().cancel_wait_if_constructed(&wt_); + svc_.scheduler().cancel_wait(&wt_); } inline void @@ -761,7 +761,7 @@ win_udp_socket_internal::close_socket() noexcept send_wr_.request_cancel(); recv_rd_.request_cancel(); wt_.request_cancel(); - svc_.scheduler().cancel_wait_if_constructed(&wt_); + svc_.scheduler().cancel_wait(&wt_); if (socket_ != INVALID_SOCKET) { diff --git a/include/boost/corosio/native/detail/iocp/win_wait_reactor.hpp b/include/boost/corosio/native/detail/iocp/win_wait_reactor.hpp index 82418e8d4..ed35045d0 100644 --- a/include/boost/corosio/native/detail/iocp/win_wait_reactor.hpp +++ b/include/boost/corosio/native/detail/iocp/win_wait_reactor.hpp @@ -28,6 +28,7 @@ instead of including this header directly." #include #include #include +#include #include @@ -68,13 +69,49 @@ namespace boost::corosio::detail { op from the table and posts a completion; invoke_handler sees op.cancelled==true and yields capy::cond::canceled. + A poll the provider refuses on account of one descriptor answers + that descriptor's op with the refusal and leaves the rest of the + table polling. Such an error reaches the caller only where nothing + flagged the op first: close() and cancel() set the cancelled flag + before the handle goes, and a flagged op yields canceled whatever + the completion carries. + + A refusal none of the entries accounts for is the end of the + reactor: the error is latched, the ops it still holds are completed + with it, and every later register_wait is completed with it too, + since nothing would drain an op parked after that point. Both + answers name the reason waits can no longer be served; canceled is + reserved for ops close() or cancel() flagged, and for the ops a + stop() drain finds parked. + + The constructor builds the wakeup channel and throws if it cannot: + a reactor that cannot be woken can never report readiness, so + there is no reactor worth handing back. The polling thread is a + separate cost, paid by the first register_wait, so a context that + never waits never carries one. A thread the system refuses costs + only the wait that asked for it: that wait completes with + `resource_unavailable_try_again` and the next one tries again. + Thread-safe: register_wait, cancel_wait, and stop may be called from any thread. */ -class win_wait_reactor +class win_wait_reactor : private win_wsa_init { public: + /** Construct the reactor and its wakeup channel. + + @par Exception Safety + Strong guarantee. A channel that cannot be formed leaves no + socket open. + + @param sched The scheduler synthetic completions are posted to. + + @throws std::system_error If Winsock could not be started or + the wakeup socket pair could not be built. + */ explicit win_wait_reactor(win_scheduler& sched); + + /// Stop the reactor thread and close the wakeup channel. ~win_wait_reactor(); win_wait_reactor(win_wait_reactor const&) = delete; @@ -98,17 +135,33 @@ class win_wait_reactor }; void run(); + bool drop_refused_entries(); + DWORD queue_register(entry const& e); void wake_self() noexcept; - void make_wakeup_pair(); + DWORD make_wakeup_pair() noexcept; void close_wakeup_pair() noexcept; + // A failed call that left a zero last error would answer "no + // error" and put the reactor straight back on the silent path. + static DWORD last_error() noexcept + { + DWORD const err = ::WSAGetLastError(); + return err != 0 ? err : static_cast(WSAEINVAL); + } + static SHORT events_for_wait(wait_type w) noexcept { switch (w) { case wait_type::read: return POLLRDNORM; case wait_type::write: return POLLWRNORM; - default: return POLLPRI; + // The Microsoft provider does not implement POLLPRI and + // refuses the whole call when it is asked for, which would + // take every other registration in the set with it. The band + // it does implement carries the same out-of-band meaning, and + // the error conditions an error wait is really after arrive in + // revents whether or not they were asked for. + default: return POLLRDBAND; } } @@ -122,21 +175,34 @@ class win_wait_reactor case wait_type::write: return (revents & (POLLWRNORM | POLLWRBAND | err_bits)) != 0; default: - return (revents & (POLLPRI | err_bits)) != 0; + return (revents & (POLLRDBAND | err_bits)) != 0; } } win_scheduler& sched_; + // Built by the constructor and closed by the destructor, so every + // other member function can assume a usable channel. SOCKET wakeup_read_ = INVALID_SOCKET; SOCKET wakeup_write_ = INVALID_SOCKET; + // Also guards thread_ against a start racing the stop that joins it. std::mutex mutex_; std::vector pending_register_; std::vector pending_cancel_; std::atomic stop_{false}; std::atomic wake_pending_{false}; + // What the exit told the ops it was still holding, kept so a later + // register_wait can be told the same thing instead of inventing a + // cancellation nobody asked for. Non-zero is also what says the + // polling thread left on its own after a WSAPoll error: stop_ must + // not be used for that exit -- stop() reads it as "already + // stopped" and would skip the join that keeps the thread from + // being destroyed joinable. Nothing latches a zero here, because + // a failed call that left a zero last error is substituted for. + DWORD dead_err_ = 0; + std::vector registered_; // reactor-thread-only std::thread thread_; @@ -145,8 +211,17 @@ class win_wait_reactor inline win_wait_reactor::win_wait_reactor(win_scheduler& sched) : sched_(sched) { - make_wakeup_pair(); - thread_ = std::thread([this] { run(); }); + // The win_wsa_init base is what makes the sockets below legal, and + // holding the reference rather than borrowing someone else's is + // what keeps them legal to the end: a base is constructed before + // this body and released after ~win_wait_reactor has closed the + // pair, so WSACleanup can never land between the two. + // + // A reactor that cannot be woken would park every op it is handed + // forever, so it refuses to exist rather than being handed out + // broken. The polling thread waits for the first register_wait. + if (DWORD const err = make_wakeup_pair(); err != 0) + detail::throw_system_error(make_err(err), "win_wait_reactor"); } inline win_wait_reactor::~win_wait_reactor() @@ -155,15 +230,18 @@ inline win_wait_reactor::~win_wait_reactor() close_wakeup_pair(); } -inline void -win_wait_reactor::make_wakeup_pair() +inline DWORD +win_wait_reactor::make_wakeup_pair() noexcept { // Build a pair of connected loopback sockets to use as a wakeup // channel. Winsock has no socketpair(2), so we listen on // 127.0.0.1:0, connect a peer, then accept it. + // + // Every failure path reads the last error before closing + // anything: closesocket() overwrites it. SOCKET listener = ::socket(AF_INET, SOCK_STREAM, IPPROTO_TCP); if (listener == INVALID_SOCKET) - return; + return last_error(); sockaddr_in addr{}; addr.sin_family = AF_INET; @@ -177,42 +255,52 @@ win_wait_reactor::make_wakeup_pair() ::getsockname(listener, reinterpret_cast(&addr), &len) == SOCKET_ERROR) { + DWORD const err = last_error(); ::closesocket(listener); - return; + return err; } wakeup_write_ = ::socket(AF_INET, SOCK_STREAM, IPPROTO_TCP); if (wakeup_write_ == INVALID_SOCKET) { + DWORD const err = last_error(); ::closesocket(listener); - return; + return err; } if (::connect( wakeup_write_, reinterpret_cast(&addr), len) == SOCKET_ERROR) { + DWORD const err = last_error(); ::closesocket(wakeup_write_); wakeup_write_ = INVALID_SOCKET; ::closesocket(listener); - return; + return err; } wakeup_read_ = ::accept(listener, nullptr, nullptr); - ::closesocket(listener); - if (wakeup_read_ == INVALID_SOCKET) { + DWORD const err = last_error(); + ::closesocket(listener); ::closesocket(wakeup_write_); wakeup_write_ = INVALID_SOCKET; - return; + return err; } + ::closesocket(listener); // The drain loop in run() calls recv() until it returns <= 0. // With a blocking socket that second recv() would block instead // of returning WSAEWOULDBLOCK, deadlocking the reactor thread. u_long non_blocking = 1; - ::ioctlsocket(wakeup_read_, FIONBIO, &non_blocking); + if (::ioctlsocket(wakeup_read_, FIONBIO, &non_blocking) == SOCKET_ERROR) + { + DWORD const err = last_error(); + close_wakeup_pair(); + return err; + } + return 0; } inline void @@ -238,19 +326,17 @@ win_wait_reactor::wake_self() noexcept if (!wake_pending_.compare_exchange_strong( expected, true, std::memory_order_acq_rel)) return; - if (wakeup_write_ != INVALID_SOCKET) + + char b = 0; + if (::send(wakeup_write_, &b, 1, 0) == SOCKET_ERROR) { - char b = 0; - if (::send(wakeup_write_, &b, 1, 0) == SOCKET_ERROR) - { - // The self-pipe byte is the only thing that wakes the reactor - // thread from an indefinite poll(); a coalesced lost wakeup - // would leave wake_pending_ stuck true and hang the op. - // wakeup_write_ is a blocking socket, so a 1-byte send can - // only fail on a hard error -- fatal, mirroring a failed - // PostQueuedCompletionStatus in win_scheduler. - detail::throw_system_error(make_err(::WSAGetLastError())); - } + // The flag is what coalesces later wakes into a byte already + // in the channel; a send that failed put no byte there, so + // leaving it latched would swallow every wake that follows. + // Disarming keeps the cost to the wakes already in flight: the + // next register, cancel or stop sends its own byte and the + // reactor learns about both. + wake_pending_.store(false, std::memory_order_release); } } @@ -268,18 +354,74 @@ win_wait_reactor::register_wait( sched_.on_completion(op, 0, 0); return; } + + if (DWORD const err = queue_register(entry{fd, w, op}); err != 0) { - std::lock_guard lock(mutex_); - pending_register_.push_back(entry{fd, w, op}); + // Nothing would ever drain a parked op, and the refusal knows + // why: the abort a stop drains with, the error the polling + // thread died of, or the system declining a thread. + sched_.on_completion(op, err, 0); + return; } wake_self(); } +inline DWORD +win_wait_reactor::queue_register(entry const& e) +{ + std::lock_guard lock(mutex_); + // stop() sets the flag before it takes the thread out from under + // this lock, and never joins again. Checking the flag and queueing + // in one critical section is what keeps both halves honest: a + // thread started after that join would be destroyed still joinable, + // which ends the process, and an op queued after it would have no + // drainer. Queueing under the flag instead leaves the op for the + // drain run() performs on its way out. + // dead_err_ says the same thing for the other exit: a thread that + // left on a WSAPoll error drained what it held and will not poll + // again, so a register queued after it would wait on nobody. It + // answers with what killed it, so the caller learns why its waits + // stopped being served instead of hearing that something cancelled + // them. + if (stop_.load(std::memory_order_acquire)) + return ERROR_OPERATION_ABORTED; + if (dead_err_) + return dead_err_; + + // A polling thread costs a thread per context, and a context that + // never waits never pays for one; the first wait is what starts it. + // A system that will not give one refuses this wait alone: nothing + // has been queued yet, so the reactor is left exactly as it was and + // the next register_wait asks again. + if (!thread_.joinable()) + { + try + { + thread_ = std::thread([this] { run(); }); + } + catch (...) + { + return ERROR_MAX_THRDS_REACHED; + } + } + + pending_register_.push_back(e); + return 0; +} + inline void win_wait_reactor::cancel_wait(overlapped_op* op) { { std::lock_guard lock(mutex_); + // Same refusal queue_register makes, for the same reason: once + // the polling thread is gone nothing reads pending_cancel_ + // again, so a cancel queued here would sit there for good. It + // has nothing to cancel either -- the drain those exits run + // already answered whatever was parked, and a register that + // arrived after them was refused at its caller. + if (stop_.load(std::memory_order_acquire) || dead_err_) + return; pending_cancel_.push_back(op); } wake_self(); @@ -291,8 +433,42 @@ win_wait_reactor::stop() if (stop_.exchange(true, std::memory_order_acq_rel)) return; wake_self(); - if (thread_.joinable()) - thread_.join(); + // Moved out under the lock, then joined without it: the reactor + // thread takes the same lock on every pass, so joining while + // holding it would deadlock. A context that never waited has no + // thread here at all. + std::thread t; + { + std::lock_guard lock(mutex_); + t = std::move(thread_); + } + if (t.joinable()) + t.join(); +} + +inline bool +win_wait_reactor::drop_refused_entries() +{ + // A descriptor the provider no longer recognises either comes back + // POLLNVAL on its own entry, which the revents walk already + // answers, or refuses the whole call and reaches here. Asking + // about each entry alone covers both without depending on which + // one this provider does. A socket closed under a parked wait is + // the ordinary way to get here: the reactor is told to drop the + // entry, but the handle can go before that ask is read. + bool dropped = false; + for (std::size_t i = registered_.size(); i > 0; --i) + { + auto const& e = registered_[i - 1]; + WSAPOLLFD pfd{e.fd, events_for_wait(e.w), 0}; + if (::WSAPoll(&pfd, 1, 0) != SOCKET_ERROR) + continue; + + sched_.on_completion(e.op, last_error(), 0); + registered_.erase(registered_.begin() + (i - 1)); + dropped = true; + } + return dropped; } inline void @@ -300,6 +476,12 @@ win_wait_reactor::run() { std::vector pollfds; + // What the ops still parked here are told on the way out, and what + // every later register_wait is told too. Ending on a poll the + // provider refused is not a cancellation, and an op that reports + // one hides the reason its wait could not be kept. + DWORD drain_err = ERROR_OPERATION_ABORTED; + while (!stop_.load(std::memory_order_acquire)) { // Drain pending register/cancel under the lock. @@ -316,6 +498,18 @@ win_wait_reactor::run() for (auto* op : to_cancel) { + // A socket's close() and cancel() ask the reactor to drop + // their wait op whether or not one is parked -- open() goes + // through close_socket() before it has a socket at all -- + // and such an ask can outlive the op's next reset(). + // Acting on it then would complete the wait that reset + // started, the moment it is registered. Every real cancel + // flags the op before queueing the ask, and only reset() + // clears the flag, so an unflagged op is one of those + // stale asks. + if (!op->cancelled.load(std::memory_order_acquire)) + continue; + auto it = std::find_if( registered_.begin(), registered_.end(), [op](entry const& e) { return e.op == op; }); @@ -337,16 +531,30 @@ win_wait_reactor::run() pollfds.push_back({e.fd, events_for_wait(e.w), 0}); // Block until the self-pipe (slot 0) is poked by a register, - // cancel, or stop, or a watched socket becomes ready. There is - // no periodic safety-net timeout: a lost self-pipe wakeup is - // fatal in wake_self() (the byte send can only fail on a hard - // error), so an idle reactor consumes no CPU. + // cancel, or stop, or a watched socket becomes ready. No + // periodic timeout, so an idle reactor consumes no CPU: the + // self-pipe is the only thing that ends this wait, and + // wake_self() leaves the channel free for the next poke when + // its own send fails, so a lost wake costs the wakes already in + // flight rather than every wake after it. int n = ::WSAPoll( pollfds.data(), static_cast(pollfds.size()), -1 /* infinite */); if (n == SOCKET_ERROR) - break; + { + // A refusal one entry accounts for costs that entry its + // wait and nothing else; the rest of the set keeps being + // polled, and a later register still finds a live reactor. + // Only a failure no entry explains ends the loop. + DWORD const err = last_error(); + if (registered_.empty() || !drop_refused_entries()) + { + drain_err = err; + break; + } + continue; + } // Drain the wakeup socket so it stops reporting readable. if (pollfds[0].revents != 0) @@ -398,19 +606,25 @@ win_wait_reactor::run() } } - // Drain remaining ops as cancelled on shutdown. This must cover - // both the active set and anything still queued by user threads - // that hasn't been moved into registered_ yet, otherwise those - // ops leak work_started credit and stall scheduler shutdown. + // Drain remaining ops on the way out. This must cover both the + // active set and anything still queued by user threads that hasn't + // been moved into registered_ yet, otherwise those ops leak + // work_started credit and stall scheduler shutdown. { std::lock_guard lock(mutex_); + // Closing the door and taking what is behind it in one critical + // section is what leaves no register in between: one that got + // in is drained here, one that arrives after is refused by + // queue_register and completes with the same code at its + // caller. + dead_err_ = drain_err; for (auto& e : pending_register_) registered_.push_back(e); pending_register_.clear(); pending_cancel_.clear(); } for (auto& e : registered_) - sched_.on_completion(e.op, ERROR_OPERATION_ABORTED, 0); + sched_.on_completion(e.op, drain_err, 0); registered_.clear(); } diff --git a/include/boost/corosio/native/detail/kqueue/kqueue_scheduler.hpp b/include/boost/corosio/native/detail/kqueue/kqueue_scheduler.hpp index 5eb156d40..87c4e7eba 100644 --- a/include/boost/corosio/native/detail/kqueue/kqueue_scheduler.hpp +++ b/include/boost/corosio/native/detail/kqueue/kqueue_scheduler.hpp @@ -289,7 +289,17 @@ kqueue_scheduler::interrupt_reactor() const { struct kevent ev; EV_SET(&ev, 0, EVFILT_USER, 0, NOTE_TRIGGER, 0, nullptr); - ::kevent(kq_fd_, &ev, 1, nullptr, 0, nullptr); + if (::kevent(kq_fd_, &ev, 1, nullptr, 0, nullptr) < 0) + { + // The flag is what coalesces later interrupts into a + // trigger already queued on the kqueue; a kevent that + // failed queued nothing, so leaving it armed would swallow + // every interrupt that follows. Disarming keeps the cost to + // the interrupts already in flight -- the next one arms and + // triggers again, instead of every one after this + // coalescing into a trigger that does not exist. + user_event_armed_.store(false, std::memory_order_release); + } } } diff --git a/include/boost/corosio/native/detail/kqueue/kqueue_traits.hpp b/include/boost/corosio/native/detail/kqueue/kqueue_traits.hpp index 190f3baa9..c858bfff5 100644 --- a/include/boost/corosio/native/detail/kqueue/kqueue_traits.hpp +++ b/include/boost/corosio/native/detail/kqueue/kqueue_traits.hpp @@ -30,8 +30,9 @@ /* kqueue backend traits. Captures the platform-specific behavior of the BSD/macOS kqueue backend: - manual fcntl for O_NONBLOCK/FD_CLOEXEC, mandatory SO_NOSIGPIPE (macOS - lacks MSG_NOSIGNAL), writev() for writes, and accept()+fcntl for + manual fcntl for O_NONBLOCK/FD_CLOEXEC, mandatory SO_NOSIGPIPE + (MSG_NOSIGNAL is not universal across kqueue platforms, and writev() + takes no flags at all), writev() for writes, and accept()+fcntl for accepted connections. */ @@ -83,9 +84,9 @@ struct kqueue_traits return n; } - // Single-buffer fast path. macOS lacks MSG_NOSIGNAL; SIGPIPE is - // suppressed by the mandatory SO_NOSIGPIPE set in accept_policy - // and set_fd_options, so plain write() is safe here. + // Single-buffer fast path. write() carries no flag to suppress + // SIGPIPE; the mandatory SO_NOSIGPIPE set in accept_policy and + // set_fd_options does it per descriptor instead. static ssize_t write_one( int fd, void const* data, std::size_t size) noexcept { @@ -135,9 +136,11 @@ struct kqueue_traits } #ifndef BOOST_COROSIO_MRDOCS - // SO_NOSIGPIPE is mandatory on kqueue platforms (macOS lacks - // MSG_NOSIGNAL). Skipped under MRDOCS so the docs build can - // parse this header on Linux, where SO_NOSIGPIPE is absent. + // SO_NOSIGPIPE is mandatory on kqueue platforms: MSG_NOSIGNAL + // is not universal across them, and the writev() the write + // path uses takes no flags. Skipped under MRDOCS so the docs + // build can parse this header on Linux, where SO_NOSIGPIPE is + // absent. int one = 1; if (::setsockopt( new_fd, SOL_SOCKET, SO_NOSIGPIPE, diff --git a/include/boost/corosio/native/detail/make_err.hpp b/include/boost/corosio/native/detail/make_err.hpp index 75dc01bbe..61028ca5a 100644 --- a/include/boost/corosio/native/detail/make_err.hpp +++ b/include/boost/corosio/native/detail/make_err.hpp @@ -59,8 +59,10 @@ make_err(int errn) noexcept /** Convert a Windows error code to std::error_code. Maps ERROR_OPERATION_ABORTED and ERROR_CANCELLED to - capy::error::canceled, and ERROR_HANDLE_EOF to capy::error::eof. - Every other code passes through std::system_category(). + capy::error::canceled, ERROR_HANDLE_EOF to capy::error::eof, and + the contracted WSA/Win32 codes to the `std::errc` conditions the + library promises. Every other code passes through + std::system_category(). ERROR_NETNAME_DELETED (64) is deliberately not mapped here: IOCP delivers it both for a local closesocket() that cancels pending I/O @@ -100,6 +102,12 @@ make_err(unsigned long dwError) noexcept return std::make_error_code(std::errc::address_not_available); if (dwError == ERROR_NEGATIVE_SEEK) return std::make_error_code(std::errc::invalid_argument); + // A thread the system will not give is the same retryable + // condition POSIX spells EAGAIN, which is what the contract + // promises; no toolchain maps the Win32 spelling to it. + if (dwError == ERROR_MAX_THRDS_REACHED) + return std::make_error_code( + std::errc::resource_unavailable_try_again); return std::error_code(static_cast(dwError), std::system_category()); } diff --git a/include/boost/corosio/native/detail/posix/posix_random_access_file.hpp b/include/boost/corosio/native/detail/posix/posix_random_access_file.hpp index 14171106b..5dc2a961d 100644 --- a/include/boost/corosio/native/detail/posix/posix_random_access_file.hpp +++ b/include/boost/corosio/native/detail/posix/posix_random_access_file.hpp @@ -22,6 +22,7 @@ #include #include #include +#include #include #include #include @@ -75,51 +76,26 @@ class posix_random_access_file final /** Per-operation state, heap-allocated for each async call. - Inherits from scheduler_op (for scheduler completion) and - pool_work_item (for thread-pool dispatch). Linked into the - file's outstanding_ops_ list for cancellation tracking. + Inherits from `coro_op` (for scheduler completion plus the shared + coroutine, cancellation and keepalive machinery) and + `pool_work_item` (for thread-pool dispatch). Linked into the + file's outstanding_ops_ list for cancellation tracking. `coro_op` + leads the base list so a `scheduler_op*` round-trips. */ struct raf_op final - : scheduler_op + : coro_op , pool_work_item , intrusive_list::node { - struct canceller - { - raf_op* op; - void operator()() const noexcept - { - op->cancelled.store(true, std::memory_order_release); - } - }; - - std::coroutine_handle<> h; - capy::executor_ref ex; - - std::error_code* ec_out = nullptr; - std::size_t* bytes_out = nullptr; - iovec iovecs[max_buffers]; int iovec_count = 0; std::uint64_t offset = 0; int errn = 0; std::size_t bytes_transferred = 0; - bool is_read = false; - - std::atomic cancelled{false}; - std::optional> stop_cb; + // Raw back-pointer for the typed work; `impl_ptr` is the keepalive. posix_random_access_file* file_ = nullptr; - std::shared_ptr file_ref; - - void start(std::stop_token const& token) - { - cancelled.store(false, std::memory_order_release); - stop_cb.reset(); - if (token.stop_possible()) - stop_cb.emplace(token, canceller{this}); - } void operator()() override; void destroy() override; @@ -330,7 +306,7 @@ posix_random_access_file::raf_op::operator()() file_->outstanding_ops_.remove(this); } - file_ref.reset(); + impl_ptr.reset(); auto coro = h; ex.on_work_finished(); @@ -348,7 +324,7 @@ posix_random_access_file::raf_op::destroy() std::lock_guard lock(file_->ops_mutex_); file_->outstanding_ops_.remove(this); } - file_ref.reset(); + impl_ptr.reset(); ex.on_work_finished(); delete this; } diff --git a/include/boost/corosio/native/detail/posix/posix_random_access_file_service.hpp b/include/boost/corosio/native/detail/posix/posix_random_access_file_service.hpp index ea048a762..ab285a4dc 100644 --- a/include/boost/corosio/native/detail/posix/posix_random_access_file_service.hpp +++ b/include/boost/corosio/native/detail/posix/posix_random_access_file_service.hpp @@ -33,7 +33,7 @@ class BOOST_COROSIO_DECL posix_random_access_file_service final posix_random_access_file_service( capy::execution_context& ctx, scheduler& sched) : sched_(&sched) - , pool_(get_or_create_pool(ctx)) + , pool_(ctx) { } @@ -123,22 +123,27 @@ class BOOST_COROSIO_DECL posix_random_access_file_service final sched_->work_finished(); } - thread_pool& pool() noexcept - { - return pool_; - } + /** Return the thread pool that runs this service's file work. -private: - static thread_pool& get_or_create_pool(capy::execution_context& ctx) + The pool's service is created on first use, so this can fail + where a plain accessor could not. Its workers start later, on + the first post, and a thread the system refuses there is + reported by that post rather than thrown here. + + @throws std::bad_alloc If the service cannot be allocated. + + @return The context's shared blocking-I/O pool. + + @see thread_pool_ref::get + */ + thread_pool& pool() { - auto* p = ctx.find_service(); - if (p) - return *p; - return ctx.make_service(); + return pool_.get(); } +private: scheduler* sched_; - thread_pool& pool_; + thread_pool_ref pool_; std::mutex mutex_; intrusive_list file_list_; std::unordered_map< @@ -202,7 +207,7 @@ posix_random_access_file::read_some_at( op->ec_out = ec; op->bytes_out = bytes_out; op->file_ = this; - op->file_ref = this->shared_from_this(); + op->impl_ptr = this->shared_from_this(); op->start(token); op->ex.on_work_started(); @@ -213,10 +218,18 @@ posix_random_access_file::read_some_at( } static_cast(op)->func_ = &raf_op::do_work; - if (!svc_.pool().post(static_cast(op))) + if (auto pec = svc_.pool().post(static_cast(op))) { - op->cancelled.store(true, std::memory_order_release); - svc_.post(static_cast(op)); + // The pool is shutting down, or the system refused it a thread. + // Nothing of this read went cross-thread, so it answers here + // like the closed-descriptor and zero-length exits above rather + // than through a completion the scheduler has to carry back. + // destroy() is the discard the op never reaching the queue + // needs: it unlinks, unwinds the work count and frees. + op->destroy(); + *ec = pec; + *bytes_out = 0; + return h; } return std::noop_coroutine(); } @@ -265,7 +278,7 @@ posix_random_access_file::write_some_at( op->ec_out = ec; op->bytes_out = bytes_out; op->file_ = this; - op->file_ref = this->shared_from_this(); + op->impl_ptr = this->shared_from_this(); op->start(token); op->ex.on_work_started(); @@ -276,10 +289,18 @@ posix_random_access_file::write_some_at( } static_cast(op)->func_ = &raf_op::do_work; - if (!svc_.pool().post(static_cast(op))) + if (auto pec = svc_.pool().post(static_cast(op))) { - op->cancelled.store(true, std::memory_order_release); - svc_.post(static_cast(op)); + // The pool is shutting down, or the system refused it a thread. + // Nothing of this write went cross-thread, so it answers here + // like the closed-descriptor and zero-length exits above rather + // than through a completion the scheduler has to carry back. + // destroy() is the discard the op never reaching the queue + // needs: it unlinks, unwinds the work count and frees. + op->destroy(); + *ec = pec; + *bytes_out = 0; + return h; } return std::noop_coroutine(); } diff --git a/include/boost/corosio/native/detail/posix/posix_resolver.hpp b/include/boost/corosio/native/detail/posix/posix_resolver.hpp index 6edd438dc..14da52802 100644 --- a/include/boost/corosio/native/detail/posix/posix_resolver.hpp +++ b/include/boost/corosio/native/detail/posix/posix_resolver.hpp @@ -23,6 +23,7 @@ #include #include #include +#include #include #include @@ -163,26 +164,10 @@ class posix_resolver final public: // resolve_op - operation state for a single DNS resolution - struct resolve_op : scheduler_op + struct resolve_op : coro_op { - struct canceller - { - resolve_op* op; - void operator()() const noexcept - { - op->request_cancel(); - } - }; - - // Coroutine state - std::coroutine_handle<> h; - capy::continuation cont; - capy::executor_ref ex; - posix_resolver* impl = nullptr; - - // Output parameters - std::error_code* ec_out = nullptr; - resolver_results* out = nullptr; + /// Where the endpoints are handed back. + resolver_results* out = nullptr; // Input parameters (owned copies for thread safety) std::string host; @@ -193,40 +178,18 @@ class posix_resolver final resolver_results stored_results; int gai_error = 0; - // Thread coordination - std::atomic cancelled{false}; - std::optional> stop_cb; - resolve_op() = default; void reset() noexcept; void operator()() override; void destroy() override; - void request_cancel() noexcept; - void start(std::stop_token const& token); }; // reverse_resolve_op - operation state for reverse DNS resolution - struct reverse_resolve_op : scheduler_op + struct reverse_resolve_op : coro_op { - struct canceller - { - reverse_resolve_op* op; - void operator()() const noexcept - { - op->request_cancel(); - } - }; - - // Coroutine state - std::coroutine_handle<> h; - capy::continuation cont; - capy::executor_ref ex; - posix_resolver* impl = nullptr; - - // Output parameters - std::error_code* ec_out = nullptr; + /// Where the name is handed back. reverse_resolver_result* result_out = nullptr; // Input parameters @@ -238,17 +201,11 @@ class posix_resolver final std::string stored_service; int gai_error = 0; - // Thread coordination - std::atomic cancelled{false}; - std::optional> stop_cb; - reverse_resolve_op() = default; void reset() noexcept; void operator()() override; void destroy() override; - void request_cancel() noexcept; - void start(std::stop_token const& token); }; /// Embedded pool work item for thread pool dispatch. diff --git a/include/boost/corosio/native/detail/posix/posix_resolver_service.hpp b/include/boost/corosio/native/detail/posix/posix_resolver_service.hpp index ff3e4d268..faf735c3e 100644 --- a/include/boost/corosio/native/detail/posix/posix_resolver_service.hpp +++ b/include/boost/corosio/native/detail/posix/posix_resolver_service.hpp @@ -37,7 +37,7 @@ class BOOST_COROSIO_DECL posix_resolver_service final posix_resolver_service(capy::execution_context& ctx, scheduler& sched) : sched_(&sched) - , pool_(ctx.use_service()) + , pool_(ctx) { } @@ -59,13 +59,23 @@ class BOOST_COROSIO_DECL posix_resolver_service final void destroy_impl(posix_resolver& impl); void post(scheduler_op* op); - void work_started() noexcept; - void work_finished() noexcept; - /** Return the resolver thread pool. */ - thread_pool& pool() noexcept + /** Return the resolver thread pool. + + The pool's service is created on first use, so this can fail + where a plain accessor could not. Its workers start later, on + the first post, and a thread the system refuses there is + reported by that post rather than thrown here. + + @throws std::bad_alloc If the service cannot be allocated. + + @return The context's shared blocking-I/O pool. + + @see thread_pool_ref::get + */ + thread_pool& pool() { - return pool_; + return pool_.get(); } /// True when the resolver thread pool is unavailable: the `unsafe` tier, @@ -78,7 +88,7 @@ class BOOST_COROSIO_DECL posix_resolver_service final private: scheduler* sched_; - thread_pool& pool_; + thread_pool_ref pool_; std::mutex mutex_; intrusive_list resolver_list_; std::unordered_map> @@ -274,7 +284,10 @@ posix_resolver::resolve_op::operator()() if (out && !was_cancelled && gai_error == 0) *out = std::move(stored_results); - impl->svc_.work_finished(); + // Hold the keepalive across the dispatch: it may be the last + // reference to the implementation this op is embedded in. + auto prevent_destroy = std::move(impl_ptr); + ex.on_work_finished(); cont.h = h; dispatch_coro(ex, cont).resume(); } @@ -283,22 +296,10 @@ inline void posix_resolver::resolve_op::destroy() { stop_cb.reset(); -} - -inline void -posix_resolver::resolve_op::request_cancel() noexcept -{ - cancelled.store(true, std::memory_order_release); -} - -inline void -posix_resolver::resolve_op::start(std::stop_token const& token) -{ - cancelled.store(false, std::memory_order_release); - stop_cb.reset(); - - if (token.stop_possible()) - stop_cb.emplace(token, canceller{this}); + auto local_ex = ex; + // May destroy the implementation, and with it this op. + impl_ptr.reset(); + local_ex.on_work_finished(); } // posix_resolver::reverse_resolve_op implementation @@ -340,7 +341,10 @@ posix_resolver::reverse_resolve_op::operator()() ep, std::move(stored_host), std::move(stored_service)); } - impl->svc_.work_finished(); + // Hold the keepalive across the dispatch: it may be the last + // reference to the implementation this op is embedded in. + auto prevent_destroy = std::move(impl_ptr); + ex.on_work_finished(); cont.h = h; dispatch_coro(ex, cont).resume(); } @@ -349,22 +353,10 @@ inline void posix_resolver::reverse_resolve_op::destroy() { stop_cb.reset(); -} - -inline void -posix_resolver::reverse_resolve_op::request_cancel() noexcept -{ - cancelled.store(true, std::memory_order_release); -} - -inline void -posix_resolver::reverse_resolve_op::start(std::stop_token const& token) -{ - cancelled.store(false, std::memory_order_release); - stop_cb.reset(); - - if (token.stop_possible()) - stop_cb.emplace(token, canceller{this}); + auto local_ex = ex; + // May destroy the implementation, and with it this op. + impl_ptr.reset(); + local_ex.on_work_finished(); } // posix_resolver implementation @@ -391,7 +383,6 @@ posix_resolver::resolve( op.reset(); op.h = h; op.ex = ex; - op.impl = this; op.ec_out = ec; op.out = out; op.host = host; @@ -406,12 +397,18 @@ posix_resolver::resolve( resolve_pool_op_.resolver_ = this; resolve_pool_op_.ref_ = this->shared_from_this(); resolve_pool_op_.func_ = &posix_resolver::do_resolve_work; - if (!svc_.pool().post(&resolve_pool_op_)) + if (auto pec = svc_.pool().post(&resolve_pool_op_)) { - // Pool shut down — complete with cancellation + // The pool is shutting down, or the system refused it a thread. + // Nothing of this resolve went cross-thread, so it answers here + // like the no-resolver exit above rather than through a + // completion the scheduler has to carry back. resolve_pool_op_.ref_.reset(); - op.cancelled.store(true, std::memory_order_release); - svc_.post(&op_); + op.stop_cb.reset(); + op.ex.on_work_finished(); + *ec = pec; + op.cont.h = h; + return dispatch_coro(ex, op.cont); } return std::noop_coroutine(); } @@ -437,7 +434,6 @@ posix_resolver::reverse_resolve( op.reset(); op.h = h; op.ex = ex; - op.impl = this; op.ec_out = ec; op.result_out = result_out; op.ep = ep; @@ -451,12 +447,18 @@ posix_resolver::reverse_resolve( reverse_pool_op_.resolver_ = this; reverse_pool_op_.ref_ = this->shared_from_this(); reverse_pool_op_.func_ = &posix_resolver::do_reverse_resolve_work; - if (!svc_.pool().post(&reverse_pool_op_)) + if (auto pec = svc_.pool().post(&reverse_pool_op_)) { - // Pool shut down — complete with cancellation + // The pool is shutting down, or the system refused it a thread. + // Nothing of this resolve went cross-thread, so it answers here + // like the no-resolver exit above rather than through a + // completion the scheduler has to carry back. reverse_pool_op_.ref_.reset(); - op.cancelled.store(true, std::memory_order_release); - svc_.post(&reverse_op_); + op.stop_cb.reset(); + op.ex.on_work_finished(); + *ec = pec; + op.cont.h = h; + return dispatch_coro(ex, op.cont); } return std::noop_coroutine(); } @@ -502,9 +504,10 @@ posix_resolver::do_resolve_work(pool_work_item* w) noexcept if (ai) ::freeaddrinfo(ai); - // Move ref to stack before post — post may trigger destroy_impl - // which erases the last shared_ptr, destroying *self (and *pw) - auto ref = std::move(pw->ref_); + // Hand the keepalive to the op: the completion waits in the + // scheduler's queue, and the implementation embedding it must + // outlive that wait. Nothing may touch *self after the post. + self->op_.impl_ptr = std::move(pw->ref_); self->svc_.post(&self->op_); } @@ -552,9 +555,10 @@ posix_resolver::do_reverse_resolve_work(pool_work_item* w) noexcept } } - // Move ref to stack before post — post may trigger destroy_impl - // which erases the last shared_ptr, destroying *self (and *pw) - auto ref = std::move(pw->ref_); + // Hand the keepalive to the op: the completion waits in the + // scheduler's queue, and the implementation embedding it must + // outlive that wait. Nothing may touch *self after the post. + self->reverse_op_.impl_ptr = std::move(pw->ref_); self->svc_.post(&self->reverse_op_); } @@ -607,18 +611,6 @@ posix_resolver_service::post(scheduler_op* op) sched_->post(op); } -inline void -posix_resolver_service::work_started() noexcept -{ - sched_->work_started(); -} - -inline void -posix_resolver_service::work_finished() noexcept -{ - sched_->work_finished(); -} - // Free function to get/create the resolver service inline posix_resolver_service& diff --git a/include/boost/corosio/native/detail/posix/posix_signal_service.hpp b/include/boost/corosio/native/detail/posix/posix_signal_service.hpp index d03afd606..bd65c778d 100644 --- a/include/boost/corosio/native/detail/posix/posix_signal_service.hpp +++ b/include/boost/corosio/native/detail/posix/posix_signal_service.hpp @@ -20,9 +20,11 @@ #include #include #include +#include #include #include +#include #include #include @@ -171,6 +173,11 @@ class BOOST_COROSIO_DECL posix_signal_service final destroy_impl(impl); } + /** Shut down the service. + + Destroys every implementation the service still owns and gives + each of their registrations back to the process-global table. + */ void shutdown() override; void destroy_impl(posix_signal& impl); @@ -316,16 +323,18 @@ flags_compatible(signal_set::flags_t existing, signal_set::flags_t requested) // state->mutex before installing the first signal handler so write_fd is // valid by the time the handler can fire. Both ends are non-blocking and // close-on-exec (mirrors the reactor self-pipe setup in select_scheduler). -// Returns false and leaves the fds at -1 if creation fails. -inline bool +// Returns the failing call's errno and leaves the fds at -1 if creation +// fails: an exhausted descriptor table and a rejected fcntl are different +// problems to the caller of add(). +[[nodiscard]] inline std::error_code open_signal_pipe(signal_state* state) { if (state->read_fd >= 0) - return true; + return {}; int fds[2]; if (::pipe(fds) < 0) - return false; + return make_err(errno); for (int i = 0; i < 2; ++i) { @@ -333,15 +342,16 @@ open_signal_pipe(signal_state* state) if (fl == -1 || ::fcntl(fds[i], F_SETFL, fl | O_NONBLOCK) == -1 || ::fcntl(fds[i], F_SETFD, FD_CLOEXEC) == -1) { + auto ec = make_err(errno); ::close(fds[0]); ::close(fds[1]); - return false; + return ec; } } state->read_fd = fds[0]; state->write_fd = fds[1]; - return true; + return {}; } // C signal handler. Async-signal-safe: it touches only the single global @@ -492,6 +502,9 @@ inline posix_signal_service::~posix_signal_service() inline void posix_signal_service::shutdown() { + posix_signal_detail::signal_state* state = + posix_signal_detail::get_signal_state(); + std::lock_guard state_lock(state->mutex); std::lock_guard lock(mutex_); for (auto* impl = impl_list_.pop_front(); impl != nullptr; @@ -499,11 +512,40 @@ posix_signal_service::shutdown() { while (auto* reg = impl->signals_) { + int const signal_number = reg->signal_number; + + // The registration table outlives every io_context, so a set + // still registered here has to give its count and disposition + // back the way clear() would: otherwise the signal stays + // installed with these flags and the next add() of it is + // refused. The per-node table unlink clear() also does is + // skipped in favour of the wholesale null-out below. + if (state->registration_count[signal_number] == 1) + { + struct sigaction sa = {}; + sa.sa_handler = SIG_DFL; + sigemptyset(&sa.sa_mask); + sa.sa_flags = 0; + std::ignore = ::sigaction(signal_number, &sa, nullptr); + state->registered_flags[signal_number] = signal_set::none; + } + + --state->registration_count[signal_number]; + --registration_count_[signal_number]; + impl->signals_ = reg->next_in_set; delete reg; } delete impl; } + + // Every live registration hung off an implementation in impl_list_, + // so the whole table goes stale at once and can be dropped wholesale + // rather than node by node. It has to be dropped: deliver_signal() + // walks this service until the destructor unlinks it from the global + // list. + for (int i = 0; i < max_signal_number; ++i) + registrations_[i] = nullptr; } inline io_object::implementation* @@ -555,8 +597,8 @@ posix_signal_service::add_signal( // this context race add() from different threads. { std::lock_guard state_lock(state->mutex); - if (!posix_signal_detail::open_signal_pipe(state)) - return make_error_code(std::errc::io_error); + if (auto ec = posix_signal_detail::open_signal_pipe(state)) + return ec; } { // Success-latched so a failed environmental registration diff --git a/include/boost/corosio/native/detail/posix/posix_stream_file.hpp b/include/boost/corosio/native/detail/posix/posix_stream_file.hpp index 73144f722..f827582e8 100644 --- a/include/boost/corosio/native/detail/posix/posix_stream_file.hpp +++ b/include/boost/corosio/native/detail/posix/posix_stream_file.hpp @@ -23,6 +23,7 @@ #include #include #include +#include #include #include #include @@ -90,27 +91,13 @@ class posix_stream_file final public: static constexpr std::size_t max_buffers = 16; - /** Operation state for a single file read or write. */ - struct file_op : scheduler_op - { - struct canceller - { - file_op* op; - void operator()() const noexcept - { - op->request_cancel(); - } - }; - - // Coroutine state - std::coroutine_handle<> h; - capy::continuation cont; - capy::executor_ref ex; - - // Output pointers - std::error_code* ec_out = nullptr; - std::size_t* bytes_out = nullptr; + /** Operation state for a single file read or write. + The coroutine, cancellation and keepalive machinery is inherited + from `coro_op`; only the pool-path result state lives here. + */ + struct file_op : coro_op + { // Buffer data (copied from buffer_param at submission time) iovec iovecs[max_buffers]; int iovec_count = 0; @@ -118,14 +105,6 @@ class posix_stream_file final // Result storage (populated by worker thread) int errn = 0; std::size_t bytes_transferred = 0; - bool is_read = false; - - // Thread coordination - std::atomic cancelled{false}; - std::optional> stop_cb; - - /// Prevents use-after-free when file is closed with pending ops. - std::shared_ptr impl_ref; file_op() = default; @@ -137,26 +116,13 @@ class posix_stream_file final is_read = false; cancelled.store(false, std::memory_order_relaxed); stop_cb.reset(); - impl_ref.reset(); + impl_ptr.reset(); ec_out = nullptr; bytes_out = nullptr; } void operator()() override; void destroy() override; - - void request_cancel() noexcept - { - cancelled.store(true, std::memory_order_release); - } - - void start(std::stop_token const& token) - { - cancelled.store(false, std::memory_order_release); - stop_cb.reset(); - if (token.stop_possible()) - stop_cb.emplace(token, canceller{this}); - } }; /** Pool work item for thread pool dispatch. */ @@ -428,10 +394,10 @@ posix_stream_file::file_op::operator()() if (bytes_out) *bytes_out = was_cancelled ? 0 : bytes_transferred; - // Move impl_ref to a local so members remain valid through - // dispatch — impl_ref may be the last shared_ptr keeping + // Move impl_ptr to a local so members remain valid through + // dispatch — impl_ptr may be the last shared_ptr keeping // the parent posix_stream_file (which embeds this file_op) alive. - auto prevent_destroy = std::move(impl_ref); + auto prevent_destroy = std::move(impl_ptr); ex.on_work_finished(); cont.h = h; dispatch_coro(ex, cont).resume(); @@ -442,7 +408,7 @@ posix_stream_file::file_op::destroy() { stop_cb.reset(); auto local_ex = ex; - impl_ref.reset(); + impl_ptr.reset(); local_ex.on_work_finished(); } diff --git a/include/boost/corosio/native/detail/posix/posix_stream_file_service.hpp b/include/boost/corosio/native/detail/posix/posix_stream_file_service.hpp index 39b6a4027..44045f9ae 100644 --- a/include/boost/corosio/native/detail/posix/posix_stream_file_service.hpp +++ b/include/boost/corosio/native/detail/posix/posix_stream_file_service.hpp @@ -36,7 +36,7 @@ class BOOST_COROSIO_DECL posix_stream_file_service final posix_stream_file_service( capy::execution_context& ctx, scheduler& sched) : sched_(&sched) - , pool_(get_or_create_pool(ctx)) + , pool_(ctx) { } @@ -123,22 +123,27 @@ class BOOST_COROSIO_DECL posix_stream_file_service final sched_->work_finished(); } - thread_pool& pool() noexcept - { - return pool_; - } + /** Return the thread pool that runs this service's file work. -private: - static thread_pool& get_or_create_pool(capy::execution_context& ctx) + The pool's service is created on first use, so this can fail + where a plain accessor could not. Its workers start later, on + the first post, and a thread the system refuses there is + reported by that post rather than thrown here. + + @throws std::bad_alloc If the service cannot be allocated. + + @return The context's shared blocking-I/O pool. + + @see thread_pool_ref::get + */ + thread_pool& pool() { - auto* p = ctx.find_service(); - if (p) - return *p; - return ctx.make_service(); + return pool_.get(); } +private: scheduler* sched_; - thread_pool& pool_; + thread_pool_ref pool_; std::mutex mutex_; intrusive_list file_list_; std::unordered_map> @@ -206,11 +211,19 @@ posix_stream_file::read_some( read_pool_op_.file_ = this; read_pool_op_.ref_ = this->shared_from_this(); read_pool_op_.func_ = &posix_stream_file::do_read_work; - if (!svc_.pool().post(&read_pool_op_)) + if (auto pec = svc_.pool().post(&read_pool_op_)) { - op.impl_ref = std::move(read_pool_op_.ref_); - op.cancelled.store(true, std::memory_order_release); - svc_.post(&read_op_); + // The pool is shutting down, or the system refused it a thread. + // Nothing of this read went cross-thread, so it answers here + // like the closed-descriptor and zero-length exits above rather + // than through a completion the scheduler has to carry back. + read_pool_op_.ref_.reset(); + op.stop_cb.reset(); + op.ex.on_work_finished(); + *ec = pec; + *bytes_out = 0; + op.cont.h = h; + return dispatch_coro(ex, op.cont); } return std::noop_coroutine(); } @@ -245,7 +258,7 @@ posix_stream_file::do_read_work(pool_work_item* w) noexcept } } - op.impl_ref = std::move(pw->ref_); + op.impl_ptr = std::move(pw->ref_); self->svc_.post(&op); } @@ -299,11 +312,19 @@ posix_stream_file::write_some( write_pool_op_.file_ = this; write_pool_op_.ref_ = this->shared_from_this(); write_pool_op_.func_ = &posix_stream_file::do_write_work; - if (!svc_.pool().post(&write_pool_op_)) + if (auto pec = svc_.pool().post(&write_pool_op_)) { - op.impl_ref = std::move(write_pool_op_.ref_); - op.cancelled.store(true, std::memory_order_release); - svc_.post(&write_op_); + // The pool is shutting down, or the system refused it a thread. + // Nothing of this write went cross-thread, so it answers here + // like the closed-descriptor and zero-length exits above rather + // than through a completion the scheduler has to carry back. + write_pool_op_.ref_.reset(); + op.stop_cb.reset(); + op.ex.on_work_finished(); + *ec = pec; + *bytes_out = 0; + op.cont.h = h; + return dispatch_coro(ex, op.cont); } return std::noop_coroutine(); } @@ -338,7 +359,7 @@ posix_stream_file::do_write_work(pool_work_item* w) noexcept } } - op.impl_ref = std::move(pw->ref_); + op.impl_ptr = std::move(pw->ref_); self->svc_.post(&op); } diff --git a/include/boost/corosio/native/detail/reactor/reactor_descriptor_state.hpp b/include/boost/corosio/native/detail/reactor/reactor_descriptor_state.hpp index 679cfd3fd..cf0a0c41f 100644 --- a/include/boost/corosio/native/detail/reactor/reactor_descriptor_state.hpp +++ b/include/boost/corosio/native/detail/reactor/reactor_descriptor_state.hpp @@ -171,8 +171,12 @@ reactor_descriptor_state::invoke_deferred_io() socklen_t len = sizeof(err); if (::getsockopt(fd, SOL_SOCKET, SO_ERROR, &err, &len) < 0) err = errno; - if (err == 0) - err = EIO; + // select raises its exceptional set for out-of-band/urgent + // data as well as for genuine faults; on a healthy socket the + // probe then reads SO_ERROR == 0. Faulting a pending read or + // write on that is wrong, so an I/O operation completes only + // on a real (non-zero) error. wait(error) still names a code + // below. } if (ev & reactor_event_read) @@ -295,7 +299,11 @@ reactor_descriptor_state::invoke_deferred_io() { if (wait_error_op) { - wait_error_op->complete(err, 0); + // wait(error) fired on the exceptional condition; name a + // code even when the kernel exposed none (e.g. urgent + // data leaves SO_ERROR == 0). + int const werr = err ? err : EIO; + wait_error_op->complete(werr, 0); local_ops.push(std::exchange(wait_error_op, nullptr)); } } diff --git a/include/boost/corosio/native/detail/reactor/reactor_scheduler.hpp b/include/boost/corosio/native/detail/reactor/reactor_scheduler.hpp index 92b5fd919..aa0d74833 100644 --- a/include/boost/corosio/native/detail/reactor/reactor_scheduler.hpp +++ b/include/boost/corosio/native/detail/reactor/reactor_scheduler.hpp @@ -331,7 +331,14 @@ class reactor_scheduler }; task_op task_op_; - /// Run the platform-specific reactor poll. + /** Run the platform-specific reactor poll. + + @par Postconditions + `lock` is owned on return, however the poll ended. An + implementation that unlocks around the blocking call owes the + caller a matching re-acquire on every path out, including the + errors it retries rather than reports. + */ virtual void run_task(lock_type& lock, context_type* ctx, long timeout_us) = 0; diff --git a/include/boost/corosio/native/detail/select/select_scheduler.hpp b/include/boost/corosio/native/detail/select/select_scheduler.hpp index 470765342..a885a96e7 100644 --- a/include/boost/corosio/native/detail/select/select_scheduler.hpp +++ b/include/boost/corosio/native/detail/select/select_scheduler.hpp @@ -402,11 +402,14 @@ select_scheduler::run_task( // EINTR: signal interrupted select(), just retry. // EBADF: an fd was closed between snapshot and select(); retry // with a fresh snapshot from registered_descs_. + // Both fall through with no ready descriptors rather than + // returning: the caller handed this function an owned lock that + // only the epilogue below re-acquires. if (ready < 0) { - if (errno == EINTR || errno == EBADF) - return; - detail::throw_system_error(make_err(errno), "select"); + if (errno != EINTR && errno != EBADF) + detail::throw_system_error(make_err(errno), "select"); + ready = 0; } // Process timers outside the lock diff --git a/include/boost/corosio/native/detail/select/select_traits.hpp b/include/boost/corosio/native/detail/select/select_traits.hpp index 33fca49af..b36e0274e 100644 --- a/include/boost/corosio/native/detail/select/select_traits.hpp +++ b/include/boost/corosio/native/detail/select/select_traits.hpp @@ -128,7 +128,7 @@ struct select_traits if (new_fd >= FD_SETSIZE) { ::close(new_fd); - errno = EINVAL; + errno = EMFILE; return -1; } @@ -158,9 +158,11 @@ struct select_traits } #ifdef SO_NOSIGPIPE - // SO_NOSIGPIPE is the only SIGPIPE guard on platforms that - // lack MSG_NOSIGNAL (macOS/BSD). Treat failure as fatal, - // matching the kqueue backend and Boost.Asio. + // MSG_NOSIGNAL is not universal across the platforms this + // portable backend covers, and the write() the fast path + // falls back to there takes no flag at all; SO_NOSIGPIPE is + // the per-descriptor guard that covers both. Treat failure + // as fatal, matching the kqueue backend. int one = 1; if (::setsockopt( new_fd, SOL_SOCKET, SO_NOSIGPIPE, @@ -199,9 +201,11 @@ struct select_traits return make_err(EMFILE); #ifdef SO_NOSIGPIPE - // SO_NOSIGPIPE is the only SIGPIPE guard on platforms that lack - // MSG_NOSIGNAL (macOS/BSD). Treat failure as fatal, matching the - // kqueue backend and Boost.Asio. Caller closes fd on error. + // MSG_NOSIGNAL is not universal across the platforms this + // portable backend covers, and the write() the fast path falls + // back to there takes no flag at all; SO_NOSIGPIPE is the + // per-descriptor guard that covers both. Treat failure as fatal, + // matching the kqueue backend. Caller closes fd on error. { int one = 1; if (::setsockopt( diff --git a/src/corosio/src/host_name.cpp b/src/corosio/src/host_name.cpp index 41f82e201..37ee623f0 100644 --- a/src/corosio/src/host_name.cpp +++ b/src/corosio/src/host_name.cpp @@ -9,6 +9,7 @@ #include #include +#include #include #include @@ -32,7 +33,7 @@ host_name() // every mainstream OS's actual cap (Linux 64, macOS/BSD 255). char buf[256]; if (::gethostname(buf, sizeof(buf)) != 0) - return {std::error_code(errno, std::system_category()), {}}; + return {detail::make_err(errno), {}}; // POSIX does not guarantee NUL termination on truncation. if (std::memchr(buf, '\0', sizeof(buf)) == nullptr) @@ -60,7 +61,7 @@ host_name() } if (err != ERROR_MORE_DATA) return { - std::error_code(static_cast(err), std::system_category()), + detail::make_err(static_cast(err)), {}}; // On success, GetComputerNameExW rewrites `size` to the count @@ -69,8 +70,8 @@ host_name() if (!::GetComputerNameExW( ComputerNameDnsHostname, wide.data(), &size)) return { - std::error_code( - static_cast(::GetLastError()), std::system_category()), + detail::make_err( + static_cast(::GetLastError())), {}}; wide.resize(size); @@ -79,8 +80,8 @@ host_name() nullptr, 0, nullptr, nullptr); if (needed <= 0) return { - std::error_code( - static_cast(::GetLastError()), std::system_category()), + detail::make_err( + static_cast(::GetLastError())), {}}; std::string out(static_cast(needed), '\0'); @@ -89,8 +90,8 @@ host_name() out.data(), needed, nullptr, nullptr); if (written != needed) return { - std::error_code( - static_cast(::GetLastError()), std::system_category()), + detail::make_err( + static_cast(::GetLastError())), {}}; return {std::error_code{}, std::move(out)}; } diff --git a/src/corosio/src/io_context.cpp b/src/corosio/src/io_context.cpp index 7957cb8ac..200635c05 100644 --- a/src/corosio/src/io_context.cpp +++ b/src/corosio/src/io_context.cpp @@ -148,24 +148,41 @@ io_uring_t::construct(capy::execution_context& ctx, unsigned concurrency_hint) namespace { -// Pre-create services that must exist before construct() runs. +// Reject options that construct() would otherwise act on. void -pre_create_services( - [[maybe_unused]] capy::execution_context& ctx, - [[maybe_unused]] io_context_options const& opts) +check_options([[maybe_unused]] io_context_options const& opts) { #if BOOST_COROSIO_POSIX if (opts.thread_pool_size < 1) throw std::invalid_argument( "thread_pool_size must be at least 1"); - // Pre-create the shared thread pool with the configured size. - // This must happen before construct() because the scheduler - // constructor creates file and resolver services that call - // get_or_create_pool(), which would create a 1-thread pool. - if (opts.thread_pool_size != 1) - ctx.make_service(opts.thread_pool_size); #endif +} +// Create the shared pool that runs blocking file and DNS work. Runs +// after construct() so the pool is newer than the scheduler its work +// items post completions to: services shut down newest first, and the +// pool must join its workers while that scheduler can still drain what +// the last of them posted. Only the service is built here; its workers +// wait for a first post, so a context that hands off no blocking work +// carries no thread for the pool it holds. +// +// Every io_context constructor has to reach here, and reach it before +// anything can call thread_pool_ref::get(): that is what keeps the +// binding from ever constructing a pool on an initiator's thread, and +// make_service throws on a duplicate if get() got there first. +void +create_thread_pool( + capy::execution_context& ctx, + [[maybe_unused]] io_context_options const& opts) +{ +#if BOOST_COROSIO_POSIX + ctx.make_service(opts.thread_pool_size); +#else + // thread_pool_size is a POSIX file-service option; the IOCP + // backend uses the pool for DNS alone. + ctx.make_service(); +#endif } // Map the locking tier to the scheduler's threading facilities. one_thread is @@ -238,6 +255,20 @@ apply_scheduler_options( } +// Bring up backend infrastructure whose setup depends on the options +// applied above. Runs last in every constructor: an io_context that +// constructs is usable, so a kernel that refuses the infrastructure is +// reported from the constructor and not from the first operation. +void +finish_construction([[maybe_unused]] detail::scheduler& sched) +{ +#if BOOST_COROSIO_HAS_IO_URING + if (auto* uring_sched = + dynamic_cast(&sched)) + uring_sched->init_ring(); +#endif +} + detail::scheduler& construct_default(capy::execution_context& ctx, unsigned concurrency_hint) { @@ -274,19 +305,19 @@ io_context::io_context( : capy::execution_context(this) , sched_(nullptr) { - pre_create_services(*this, opts_in); + apply_options_pre_(opts_in); // Computed before construct_default so IOCP's completion port is created // with the effective concurrency. unsigned const eff = detail::effective_concurrency_hint(opts_in, concurrency_hint); sched_ = &construct_default(*this, eff); - apply_scheduler_options(*sched_, opts_in, eff); + apply_options_post_(opts_in, eff); } void io_context::apply_options_pre_(io_context_options const& opts) { - pre_create_services(*this, opts); + check_options(opts); } void @@ -294,13 +325,17 @@ io_context::apply_options_post_( io_context_options const& opts_in, unsigned concurrency_hint) { + create_thread_pool(*this, opts_in); apply_scheduler_options(*sched_, opts_in, concurrency_hint); + finish_construction(*sched_); } void io_context::apply_threading_(io_context_options const& opts_in) { + create_thread_pool(*this, opts_in); sched_->configure_threading(make_threading_config(opts_in)); + finish_construction(*sched_); } io_context::~io_context() diff --git a/src/corosio/src/local_connect_pair.cpp b/src/corosio/src/local_connect_pair.cpp index 1a7a631d3..f156c0a85 100644 --- a/src/corosio/src/local_connect_pair.cpp +++ b/src/corosio/src/local_connect_pair.cpp @@ -22,6 +22,7 @@ #include #include +#include #include #include #include @@ -178,40 +179,96 @@ make_pair_sockets(SOCKET& a_sock, SOCKET& b_sock) noexcept return ec; } - SOCKET worker_sock = INVALID_SOCKET; - std::error_code worker_ec; + // A worker that fails before connecting produces no connection at + // all, so the accept below must be able to give up. Poll the + // listener instead of blocking in accept() forever. + u_long non_blocking = 1; + if (::ioctlsocket(listen_sock, FIONBIO, &non_blocking) == SOCKET_ERROR) + { + auto ec = detail::make_err(::WSAGetLastError()); + ::closesocket(listen_sock); + remove_pair_path(dir, path); + return ec; + } + SOCKET worker_sock = INVALID_SOCKET; + std::error_code worker_ec; + std::atomic worker_done{false}; + + // One exit, so worker_done is published on every path: the accept + // below waits on it, and a path that skipped it would hang. std::thread worker([&] { worker_sock = ::WSASocketW( AF_UNIX, SOCK_STREAM, 0, nullptr, 0, WSA_FLAG_OVERLAPPED); if (worker_sock == INVALID_SOCKET) { worker_ec = detail::make_err(::WSAGetLastError()); - return; } - - detail::un_sa_t caddr{}; - caddr.sun_family = AF_UNIX; - std::memcpy( - caddr.sun_path, path.c_str(), - (std::min)(path.size(), sizeof(caddr.sun_path) - 1)); - int caddr_len = static_cast( - offsetof(detail::un_sa_t, sun_path) + path.size() + 1); - - if (::connect( - worker_sock, reinterpret_cast(&caddr), caddr_len) - == SOCKET_ERROR) + else { - worker_ec = detail::make_err(::WSAGetLastError()); - ::closesocket(worker_sock); - worker_sock = INVALID_SOCKET; + detail::un_sa_t caddr{}; + caddr.sun_family = AF_UNIX; + std::memcpy( + caddr.sun_path, path.c_str(), + (std::min)(path.size(), sizeof(caddr.sun_path) - 1)); + int caddr_len = static_cast( + offsetof(detail::un_sa_t, sun_path) + path.size() + 1); + + if (::connect( + worker_sock, + reinterpret_cast(&caddr), caddr_len) + == SOCKET_ERROR) + { + worker_ec = detail::make_err(::WSAGetLastError()); + ::closesocket(worker_sock); + worker_sock = INVALID_SOCKET; + } } + // Released last so a reader that sees it also sees worker_ec. + worker_done.store(true, std::memory_order_release); }); - SOCKET accept_sock = ::accept(listen_sock, nullptr, nullptr); + SOCKET accept_sock = INVALID_SOCKET; std::error_code accept_ec; - if (accept_sock == INVALID_SOCKET) - accept_ec = detail::make_err(::WSAGetLastError()); + for (;;) + { + // A worker that succeeded has left a connection in the + // backlog, so only a failed one means nothing is coming. + if (worker_done.load(std::memory_order_acquire) && worker_ec) + break; + + WSAPOLLFD pfd{listen_sock, POLLRDNORM, 0}; + int const n = ::WSAPoll(&pfd, 1, 100); + if (n == SOCKET_ERROR) + { + accept_ec = detail::make_err(::WSAGetLastError()); + break; + } + if (n == 0) + continue; + + // Readiness that is not "a connection is waiting" is an error + // condition on the listener; accepting on it would spin. The + // condition carries no retrievable code, so this one is + // corosio's own and has to compare equal on every toolchain. + if ((pfd.revents & POLLRDNORM) == 0) + { + accept_ec = + std::make_error_code(std::errc::connection_aborted); + break; + } + + accept_sock = ::accept(listen_sock, nullptr, nullptr); + if (accept_sock != INVALID_SOCKET) + break; + DWORD const err = ::WSAGetLastError(); + // A readiness report with nothing left to accept: keep + // waiting for the worker's connection. + if (err == WSAEWOULDBLOCK) + continue; + accept_ec = detail::make_err(err); + break; + } worker.join(); @@ -226,10 +283,23 @@ make_pair_sockets(SOCKET& a_sock, SOCKET& b_sock) noexcept } if (worker_ec) { - ::closesocket(accept_sock); + if (accept_sock != INVALID_SOCKET) + ::closesocket(accept_sock); return worker_ec; } + // accept() inherits the listener's non-blocking mode; the rest of + // the IOCP backend hands out blocking sockets and drives them + // through overlapped I/O. + non_blocking = 0; + if (::ioctlsocket(accept_sock, FIONBIO, &non_blocking) == SOCKET_ERROR) + { + auto ec = detail::make_err(::WSAGetLastError()); + ::closesocket(accept_sock); + ::closesocket(worker_sock); + return ec; + } + a_sock = accept_sock; b_sock = worker_sock; return {}; diff --git a/src/corosio/src/local_datagram_socket.cpp b/src/corosio/src/local_datagram_socket.cpp index 80d0e12fc..9d9cf734f 100644 --- a/src/corosio/src/local_datagram_socket.cpp +++ b/src/corosio/src/local_datagram_socket.cpp @@ -14,6 +14,7 @@ #include #include #include +#include #include @@ -119,7 +120,7 @@ local_datagram_socket::available() const int value = 0; if (::ioctl(native_handle(), FIONREAD, &value) < 0) detail::throw_system_error( - std::error_code(errno, std::system_category()), + detail::make_err(errno), "local_datagram_socket::available"); return static_cast(value); } diff --git a/src/corosio/src/local_stream_socket.cpp b/src/corosio/src/local_stream_socket.cpp index 8bee574a7..a5feab293 100644 --- a/src/corosio/src/local_stream_socket.cpp +++ b/src/corosio/src/local_stream_socket.cpp @@ -14,6 +14,7 @@ #include #include #include +#include #if BOOST_COROSIO_POSIX #include @@ -118,14 +119,15 @@ local_stream_socket::available() const if (::ioctlsocket( static_cast(native_handle()), FIONREAD, &value) != 0) detail::throw_system_error( - std::error_code(::WSAGetLastError(), std::system_category()), + detail::make_err( + static_cast(::WSAGetLastError())), "local_stream_socket::available"); return static_cast(value); #else int value = 0; if (::ioctl(native_handle(), FIONREAD, &value) < 0) detail::throw_system_error( - std::error_code(errno, std::system_category()), + detail::make_err(errno), "local_stream_socket::available"); return static_cast(value); #endif diff --git a/test/unit/CMakeLists.txt b/test/unit/CMakeLists.txt index a8fe4bbf3..208d74afe 100644 --- a/test/unit/CMakeLists.txt +++ b/test/unit/CMakeLists.txt @@ -10,6 +10,9 @@ file(GLOB_RECURSE PFILES CONFIGURE_DEPENDS *.cpp *.hpp) +# The fault harness shadows libc symbols; it links into its own target. +list(FILTER PFILES EXCLUDE REGEX "/fault/") + if (NOT OpenSSL_FOUND AND NOT WolfSSL_FOUND) list(FILTER PFILES EXCLUDE REGEX "tls_stream_stress\\.cpp$") endif() @@ -49,3 +52,5 @@ boost_capy_test_suite_discover_tests(boost_corosio_tests) # Add the main test target to Boost's test suite add_dependencies(tests boost_corosio_tests) + +add_subdirectory(fault) diff --git a/test/unit/Jamfile b/test/unit/Jamfile index 6bc31a5ea..261697224 100644 --- a/test/unit/Jamfile +++ b/test/unit/Jamfile @@ -33,12 +33,18 @@ project boost/corosio/test/unit gcc:-Wno-maybe-uninitialized ; -# Non-TLS tests (recurses into test/, native/, etc.) -for local f in [ glob-tree-ex . : *.cpp : openssl_stream.cpp wolfssl_stream.cpp cross_ssl_stream.cpp tls_stream.cpp tls_stream_stress.cpp iocp_shutdown.cpp iocp_error_map.cpp openssl_engine.cpp wolfssl_engine.cpp cross_engine.cpp ] +# Non-TLS tests (recurses into test/, native/, etc.). `fault` excludes +# the whole directory rather than its sources one by one: glob-tree-ex +# applies the exclusion patterns to directory entries too and does not +# descend into a match. Those targets need the hook translation unit +# linked in, which only fault/Jamfile does. +for local f in [ glob-tree-ex . : *.cpp : openssl_stream.cpp wolfssl_stream.cpp cross_ssl_stream.cpp tls_stream.cpp tls_stream_stress.cpp iocp_shutdown.cpp iocp_error_map.cpp openssl_engine.cpp wolfssl_engine.cpp cross_engine.cpp fault ] { run $(f) ; } +build-project fault ; + # IOCP-specific native tests (Windows host only; skip on other hosts to # avoid triggering a cross-compile attempt). if [ os.name ] = NT diff --git a/test/unit/fault/CMakeLists.txt b/test/unit/fault/CMakeLists.txt new file mode 100644 index 000000000..afd08c48d --- /dev/null +++ b/test/unit/fault/CMakeLists.txt @@ -0,0 +1,106 @@ +# +# Copyright (c) 2026 Steve Gerbino +# +# Distributed under the Boost Software License, Version 1.0. (See accompanying +# file LICENSE_1_0.txt or copy at http://www.boost.org/LICENSE_1_0.txt) +# +# Official repository: https://github.com/cppalliance/corosio +# + +# The POSIX shadows are written against glibc, Darwin libc and the +# FreeBSD libc and the Windows hooks against the PE import table; every +# other platform spells its entry points differently and has no +# coverage here. +if(NOT (WIN32 OR APPLE OR CMAKE_SYSTEM_NAME STREQUAL "Linux" + OR CMAKE_SYSTEM_NAME STREQUAL "FreeBSD")) + return() +endif() + +# TSan's runtime defines the same libc symbols the harness shadows and +# models fd ordering inside them, so the interposers never see the call. +# TSan reaches this tree only through CMAKE_CXX_FLAGS: there is no +# sanitizer option of our own to key off. +if(CMAKE_CXX_FLAGS MATCHES "-fsanitize=thread") + message(STATUS "corosio: fault tests skipped (ThreadSanitizer)") + return() +endif() + +# Windows ASan interposes by patching the very import thunks this +# harness owns, so the two cannot both hold a symbol. The Jamfile gates +# the same configuration off. +if(WIN32 AND CMAKE_CXX_FLAGS MATCHES "fsanitize=address") + message(STATUS "corosio: fault tests skipped (AddressSanitizer)") + return() +endif() + +file(GLOB FAULT_FILES CONFIGURE_DEPENDS *.cpp *.hpp) +if(WIN32) + # The POSIX sources include and friends unconditionally, + # so on Windows the list is named rather than filtered. + list(FILTER FAULT_FILES EXCLUDE REGEX "\\.cpp$") + list(APPEND FAULT_FILES + ${CMAKE_CURRENT_SOURCE_DIR}/fault_arm.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/fault_win.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/self_test.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/win_faults.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/iocp_faults.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/iocp_dissociate_faults.cpp) +else() + list(FILTER FAULT_FILES EXCLUDE REGEX "fault_win\\.cpp$") + if(NOT BOOST_COROSIO_HAVE_LIBURING) + list(FILTER FAULT_FILES EXCLUDE REGEX "uring") + endif() +endif() +# No per-platform filter beyond those: every backend suite is gated on +# its own BOOST_COROSIO_HAS_* macro, so the ones that do not apply here +# compile away to an empty translation unit. +list(APPEND FAULT_FILES CMakeLists.txt Jamfile) +source_group(TREE ${CMAKE_CURRENT_SOURCE_DIR} PREFIX "" FILES ${FAULT_FILES}) + +add_executable(boost_corosio_fault_tests ${FAULT_FILES}) +target_link_libraries(boost_corosio_fault_tests PRIVATE + Boost::capy_test_suite_main + Boost::corosio) +target_include_directories(boost_corosio_fault_tests PRIVATE + . .. ../../../ ../../../src/corosio) + +# in_child's forked child looks up libgcov's __gcov_dump and calls it +# before _Exit, but nothing else in the program references that symbol, +# so the archive member holding it is never pulled in and the lookup +# would answer null. Ask the linker for it where the tree is +# instrumented. +# +# The name is the linker's spelling, not C's: Mach-O prefixes every C +# symbol with an underscore. Windows needs none of this: it has no +# fork, so in_child runs the body directly. +# +# The guard reads the flags the coverage jobs actually set. A tree +# instrumented some other way -- a toolchain file, or per-configuration +# flags -- goes undetected, and the cost of that is a forked child's +# counters going unrecorded again. +if(NOT WIN32 AND CMAKE_CXX_FLAGS MATCHES "--coverage|-fprofile-arcs") + if(APPLE) + target_link_options(boost_corosio_fault_tests PRIVATE + "LINKER:-u,___gcov_dump") + else() + target_link_options(boost_corosio_fault_tests PRIVATE + "LINKER:-u,__gcov_dump") + endif() +endif() + +if(NOT WIN32) + target_link_libraries(boost_corosio_fault_tests PRIVATE ${CMAKE_DL_LIBS}) + # Interposition only works if the shadows land in the executable's + # dynamic symbol table. A superproject that builds everything with + # hidden visibility would strip them, and the readback then refuses + # to run rather than reporting faults that never fire. Windows + # rewrites import thunks instead and needs no exported shadows. + set_target_properties(boost_corosio_fault_tests PROPERTIES + C_VISIBILITY_PRESET default + CXX_VISIBILITY_PRESET default + VISIBILITY_INLINES_HIDDEN OFF + ENABLE_EXPORTS ON) +endif() + +boost_capy_test_suite_discover_tests(boost_corosio_fault_tests) +add_dependencies(tests boost_corosio_fault_tests) diff --git a/test/unit/fault/Jamfile b/test/unit/fault/Jamfile new file mode 100644 index 000000000..0d24b99a0 --- /dev/null +++ b/test/unit/fault/Jamfile @@ -0,0 +1,109 @@ +# +# Copyright (c) 2026 Steve Gerbino +# +# Distributed under the Boost Software License, Version 1.0. (See accompanying +# file LICENSE_1_0.txt or copy at http://www.boost.org/LICENSE_1_0.txt) +# +# Official repository: https://github.com/cppalliance/corosio +# + +import configure ; +import os ; +import testing ; + +project boost/corosio/test/unit/fault + : requirements + /boost/corosio//boost_corosio + ../../../../capy/extra/test_suite/test_main.cpp + ../../../../capy/extra/test_suite/test_suite.cpp + ../../../../capy/extra/test_suite + . + .. + ../../.. + ../../../src/corosio + windows:_WIN32_WINNT=0x0602 + extra + on + gcc:-Wno-maybe-uninitialized + linux:-ldl + # Interposition only works if the shadows land in the executable's + # dynamic symbol table; Boost builds tests with hidden visibility. + # so the flag stays on these targets instead of + # propagating into a private rebuild of corosio and capy. + global + # Static sanitizer runtimes define the same libc symbols the + # harness shadows; TSan also models fd ordering in them. + on:no + norecover:no + clang,on:no + clang,norecover:no + # Windows ASan interposes by patching the very import thunks this + # harness owns, so the two cannot both hold a symbol. + msvc,on:no + msvc,norecover:no + clang-win,on:no + clang-win,norecover:no + ; + +# A forked child ends with _Exit, so its counters only reach disk if +# the harness can call __gcov_dump itself. --coverage arrives as a raw +# linkflag rather than on, so probe for libgcov by linking +# against it and only then force the entry point into the executable +# and into its dynamic symbol table, where the runtime lookup reads. +exe gcov_dump_check : check/gcov_dump.cpp ; +explicit gcov_dump_check ; +local gcov-dump = [ check-target-builds gcov_dump_check "gcov dump linkable" + : linux:-Wl,-u,__gcov_dump + linux:-Wl,--export-dynamic-symbol=__gcov_dump + freebsd:-Wl,-u,__gcov_dump + freebsd:-Wl,--export-dynamic-symbol=__gcov_dump + darwin:-Wl,-u,___gcov_dump + : ] ; + +# The POSIX shadows are written against glibc, Darwin libc and the +# FreeBSD libc and the Windows hooks against the PE import table, so +# nothing is built anywhere else; the CMake side gates the same way. +# Darwin and FreeBSD drop only epoll_faults.cpp and uring_faults.cpp, +# which have no counterpart there. +# fault_arm.cpp holds the arm model every hook translation unit shares. +# The two signal sources and iocp_dissociate_faults.cpp are separate +# because the state they fault is created once per process: b2 builds +# one executable per source, which is the isolation they need. +local hooks = fault_arm.cpp fault_posix.cpp ; +local tests ; +local uring-hook ; +if [ os.name ] = NT +{ + hooks = fault_arm.cpp fault_win.cpp ; + tests = self_test.cpp win_faults.cpp iocp_faults.cpp + iocp_dissociate_faults.cpp ; +} +else if [ os.name ] = MACOSX +{ + tests = self_test.cpp posix_faults.cpp select_faults.cpp + kqueue_faults.cpp reactor_faults.cpp + signal_pipe_faults.cpp signal_sigaction_faults.cpp ; +} +else if [ os.name ] = FREEBSD +{ + tests = self_test.cpp posix_faults.cpp select_faults.cpp + kqueue_faults.cpp reactor_faults.cpp + signal_pipe_faults.cpp signal_sigaction_faults.cpp ; +} +else if [ os.name ] = LINUX +{ + tests = self_test.cpp posix_faults.cpp select_faults.cpp + epoll_faults.cpp reactor_faults.cpp uring_faults.cpp + signal_pipe_faults.cpp signal_sigaction_faults.cpp ; + # The io_uring hook includes unconditionally, so it can + # only build where the library's own probe found liburing. Without + # it uring_faults.cpp compiles away behind BOOST_COROSIO_HAS_IO_URING + # and the program still runs; CMake drops that source instead. + uring-hook = [ check-target-builds /boost/corosio//has_liburing + : fault_uring.cpp : ] ; +} + +for local f in $(tests) +{ + run $(f) $(hooks) : : : $(uring-hook) $(gcov-dump) ; +} diff --git a/test/unit/fault/check/gcov_dump.cpp b/test/unit/fault/check/gcov_dump.cpp new file mode 100644 index 000000000..409b3b9c3 --- /dev/null +++ b/test/unit/fault/check/gcov_dump.cpp @@ -0,0 +1,17 @@ +// +// Copyright (c) 2026 Steve Gerbino +// +// Distributed under the Boost Software License, Version 1.0. (See accompanying +// file LICENSE_1_0.txt or copy at http://www.boost.org/LICENSE_1_0.txt) +// +// Official repository: https://github.com/cppalliance/corosio +// + +// Links only when the build carries --coverage, since nothing else +// provides libgcov's dump entry point. The test runner supplies main. +extern "C" void __gcov_dump(); + +void gcov_dump_check() +{ + __gcov_dump(); +} diff --git a/test/unit/fault/epoll_faults.cpp b/test/unit/fault/epoll_faults.cpp new file mode 100644 index 000000000..e1765daae --- /dev/null +++ b/test/unit/fault/epoll_faults.cpp @@ -0,0 +1,401 @@ +// +// Copyright (c) 2026 Steve Gerbino +// +// Distributed under the Boost Software License, Version 1.0. (See accompanying +// file LICENSE_1_0.txt or copy at http://www.boost.org/LICENSE_1_0.txt) +// +// Official repository: https://github.com/cppalliance/corosio +// + +#include "fault.hpp" +#include "fault_test_utils.hpp" +#include "context.hpp" +#include "test_suite.hpp" +#include "test_utils.hpp" + +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include + +#include +#include + +#if BOOST_COROSIO_HAS_EPOLL + +namespace boost::corosio::test::fault { + +namespace { + +endpoint loopback() +{ + return endpoint(ipv4_address::loopback(), 0); +} + +} // namespace + +struct epoll_faults +{ + void testConstructorFails() + { + auto expect_throw = [](sys s, unsigned nth, int err, std::errc code) + { + fault_scope f(s, err, nth); + expect_system_error([&]{ io_context ioc(epoll); }, code); + BOOST_TEST(f.fired()); + }; + expect_throw(sys::epoll_create1, 1, EMFILE, + std::errc::too_many_files_open); + expect_throw(sys::eventfd, 1, EMFILE, + std::errc::too_many_files_open); + expect_throw(sys::timerfd_create, 1, EMFILE, + std::errc::too_many_files_open); + // 1 registers the eventfd, 2 the timerfd. + expect_throw(sys::epoll_ctl, 1, ENOMEM, + std::errc::not_enough_memory); + expect_throw(sys::epoll_ctl, 2, ENOMEM, + std::errc::not_enough_memory); + } + + void testOpenFails() + { + io_context ioc(epoll); + { + tcp_socket s(ioc); + fault_scope f(sys::socket, EMFILE); + auto ec = s.open(tcp::v4()); + BOOST_TEST(f.fired()); + BOOST_TEST(ec == std::errc::too_many_files_open); + BOOST_TEST(!s.is_open()); + } + { + // The socket already exists when registration fails, so the + // failure path owns closing it. + int before = open_fds(); + tcp_socket s(ioc); + fault_scope f(sys::epoll_ctl, ENOMEM); + auto ec = s.open(tcp::v4()); + BOOST_TEST(f.fired()); + BOOST_TEST(ec == std::errc::not_enough_memory); + BOOST_TEST(!s.is_open()); + BOOST_TEST_EQ(open_fds(), before); + } + } + + void testAcceptorRegisterFails() + { + io_context ioc(epoll); + tcp_acceptor acc(ioc); + BOOST_TEST(!acc.open()); + BOOST_TEST(!acc.bind(loopback())); + // listen is where an acceptor first reaches register_descriptor. + fault_scope f(sys::epoll_ctl, ENOMEM); + BOOST_TEST(acc.listen() == std::errc::not_enough_memory); + BOOST_TEST(f.fired()); + // Not latched: the descriptor is still unregistered, so a + // second listen registers it. + BOOST_TEST(!acc.listen()); + } + + void testAcceptFails() + { + io_context ioc(epoll); + tcp_acceptor acc(ioc, loopback()); + tcp_socket client(ioc), server(ioc); + std::error_code aec, aec2; + auto body = [&]() -> capy::task<> + { + { + auto [ec] = co_await client.connect(acc.local_endpoint()); + BOOST_TEST(!ec); + } + { + // EINTR is retried inside accept_policy, then the real + // accept4 succeeds. + fault_scope f(sys::accept4, EINTR); + auto [ec] = co_await acc.accept(server); + BOOST_TEST(f.fired()); + BOOST_TEST(!ec); + } + server.close(); + client.close(); + tcp_socket client2(ioc); + { + auto [ec] = co_await client2.connect(acc.local_endpoint()); + BOOST_TEST(!ec); + } + { + fault_scope f(sys::accept4, ECONNABORTED); + auto [ec] = co_await acc.accept(server); + aec = ec; + BOOST_TEST(f.fired()); + } + { + auto [ec] = co_await acc.accept(server); + BOOST_TEST(!ec); + } + server.close(); + client2.close(); + tcp_socket client3(ioc); + { + auto [ec] = co_await client3.connect(acc.local_endpoint()); + BOOST_TEST(!ec); + } + { + // The accepted fd fails to register: the impl is + // destroyed, which closes it, and the error is reported. + int before = open_fds(); + fault_scope f(sys::epoll_ctl, ENOMEM); + auto [ec] = co_await acc.accept(server); + aec2 = ec; + BOOST_TEST(f.fired()); + BOOST_TEST_EQ(open_fds(), before); + } + client3.close(); + }; + capy::run_async(ioc.get_executor())(body()); + ioc.run(); + BOOST_TEST(aec == std::errc::connection_aborted); + BOOST_TEST(aec2 == std::errc::not_enough_memory); + BOOST_TEST(!server.is_open()); + } + + void testRunLoopFaults() + { + { + io_context ioc(epoll); + fault_scope f(sys::epoll_wait, EINTR); + bool done = false; + auto body = [&]() -> capy::task<> + { + std::ignore = co_await corosio::delay( + std::chrono::milliseconds(1)); + done = true; + }; + capy::run_async(ioc.get_executor())(body()); + ioc.run(); + BOOST_TEST(f.fired()); + BOOST_TEST(done); + } + { + io_context ioc(epoll); + fault_scope f(sys::epoll_wait, EBADF); + auto body = [&]() -> capy::task<> + { + std::ignore = co_await corosio::delay( + std::chrono::milliseconds(1)); + }; + capy::run_async(ioc.get_executor())(body()); + expect_system_error([&]{ ioc.run(); }, + std::errc::bad_file_descriptor); + BOOST_TEST(f.fired()); + } + { + io_context ioc(epoll); + fault_scope f(sys::timerfd_settime, EINVAL); + auto body = [&]() -> capy::task<> + { + std::ignore = co_await corosio::delay( + std::chrono::milliseconds(1)); + }; + capy::run_async(ioc.get_executor())(body()); + expect_system_error([&]{ ioc.run(); }, + std::errc::invalid_argument); + BOOST_TEST(f.fired()); + } + } + + /* A wake that never left has to be retried, not swallowed. + + `eventfd_armed_` is set before the write, so it stands for a byte + in the eventfd. A write that failed put none there: leaving the + flag set would coalesce every later interrupt into a wake that + does not exist, and a wait only an interrupt could end would + never end. The second arm is the observable — it can only fire if + the second interrupt actually reached `write`. + */ + void testInterruptWriteFails() + { + io_context ioc(epoll); + { + // stop() is the one interrupt that reaches the eventfd + // without a reactor thread (reactor_scheduler::stop), and + // it interrupts only on the transition, so each stop below + // is one write. + fault_scope first(sys::write, EIO, 1); + fault_scope second(sys::write, EIO, 2); + ioc.stop(); + BOOST_TEST(first.fired()); + BOOST_TEST(!second.fired()); + ioc.restart(); + ioc.stop(); + BOOST_TEST(second.fired()); + } + // And the loop the failed wakes were aimed at still runs: timed + // work fires and a stop from inside ends run(). + ioc.restart(); + bool done = false; + auto body = [&]() -> capy::task<> + { + std::ignore = co_await corosio::delay( + std::chrono::milliseconds(1)); + done = true; + ioc.stop(); + }; + capy::run_async(ioc.get_executor())(body()); + ioc.run(); + BOOST_TEST(done); + } + + /* Adoption is the acceptor's other way into the reactor. + + `listen()` registers a descriptor the library made; `assign()` + registers one the caller made, and only that path leaves the + caller still owning it when the reactor refuses. The acceptor + has to come back closed with the descriptor untouched. + */ + void testAcceptorAssignRegisterFails() + { + io_context ioc(epoll); + auto h = make_native_socket(AF_INET, SOCK_STREAM); + BOOST_TEST(static_cast(h) >= 0); + make_native_adoptable(h); + sockaddr_in sa{}; + sa.sin_family = AF_INET; + sa.sin_addr.s_addr = htonl(INADDR_LOOPBACK); + BOOST_TEST_EQ(::bind(static_cast(h), + reinterpret_cast(&sa), sizeof(sa)), 0); + BOOST_TEST_EQ(::listen(static_cast(h), 1), 0); + + int const before = open_fds(); + tcp_acceptor acc(ioc); + { + fault_scope f(sys::epoll_ctl, ENOMEM); + auto ec = acc.assign(h); + BOOST_TEST(f.fired()); + BOOST_TEST_EQ(f.count(), 1u); + BOOST_TEST(ec == std::errc::not_enough_memory); + } + BOOST_TEST(!acc.is_open()); + BOOST_TEST(native_socket_valid(h)); + BOOST_TEST_EQ(open_fds(), before); + // Not latched: the descriptor is still unregistered. + BOOST_TEST(!acc.assign(h)); + acc.close(); + } + + /* The accept that the reactor dispatches, rather than the one the + initiator speculates on. + + A first accept4 with no peer waiting reports EAGAIN and parks the + op, so the second call is the reactor's retry — a different code + path with its own error and completion arms, and the one an + accepted descriptor is registered from. + */ + void testPostedAcceptFails() + { + io_context ioc(epoll); + tcp_acceptor acc(ioc, loopback()); + tcp_socket client(ioc), server(ioc); + // Opened before any arm so its own registration is not counted. + BOOST_TEST(!client.open(tcp::v4())); + std::error_code aec; + std::stop_source guard; + auto accept_body = [&]() -> capy::task<> + { + { + // 1 is the speculative accept, which finds nothing. + fault_scope f(sys::accept4, EMFILE, 2); + auto [ec] = co_await acc.accept(server); + aec = ec; + BOOST_TEST(f.fired()); + } + // A refused accept dequeues nothing, so the acceptor is + // still open and the connection still pending. + auto [ec] = co_await acc.accept(server); + BOOST_TEST(!ec); + guard.request_stop(); + }; + auto connect_body = [&]() -> capy::task<> + { + auto [ec] = co_await client.connect(acc.local_endpoint()); + BOOST_TEST(!ec); + }; + // The second accept only resolves if the refused one left the + // pending connection alone; a run loop that never returns + // would otherwise read as a job timeout rather than a failure. + bool expired = false; + capy::run_async(ioc.get_executor())(accept_body()); + capy::run_async(ioc.get_executor())(connect_body()); + capy::run_async(ioc.get_executor(), guard.get_token())( + stop_guard(ioc, expired)); + ioc.run(); + BOOST_TEST(!expired); + BOOST_TEST(aec == std::errc::too_many_files_open); + BOOST_TEST(server.is_open()); + } + + void testPostedAcceptRegisterFails() + { + io_context ioc(epoll); + tcp_acceptor acc(ioc, loopback()); + tcp_socket client(ioc), server(ioc); + BOOST_TEST(!client.open(tcp::v4())); + std::error_code aec; + int leaked = 0; + auto accept_body = [&]() -> capy::task<> + { + int const before = open_fds(); + fault_scope f(sys::epoll_ctl, ENOMEM); + auto [ec] = co_await acc.accept(server); + aec = ec; + BOOST_TEST(f.fired()); + BOOST_TEST_EQ(f.count(), 1u); + // The peer implementation owns the accepted descriptor by + // then, so destroying it is what closes it. + leaked = open_fds() - before; + }; + auto connect_body = [&]() -> capy::task<> + { + auto [ec] = co_await client.connect(acc.local_endpoint()); + BOOST_TEST(!ec); + }; + capy::run_async(ioc.get_executor())(accept_body()); + capy::run_async(ioc.get_executor())(connect_body()); + ioc.run(); + BOOST_TEST(aec == std::errc::not_enough_memory); + BOOST_TEST(!server.is_open()); + BOOST_TEST_EQ(leaked, 0); + } + + void run() + { + if(skip_under_valgrind()) + return; + testConstructorFails(); + testOpenFails(); + testAcceptorRegisterFails(); + testAcceptFails(); + testRunLoopFaults(); + testInterruptWriteFails(); + testAcceptorAssignRegisterFails(); + testPostedAcceptFails(); + testPostedAcceptRegisterFails(); + } +}; + +TEST_SUITE(epoll_faults, "boost.corosio.fault.epoll"); + +} // boost::corosio::test::fault + +#endif diff --git a/test/unit/fault/fault.hpp b/test/unit/fault/fault.hpp new file mode 100644 index 000000000..bdeda8d86 --- /dev/null +++ b/test/unit/fault/fault.hpp @@ -0,0 +1,297 @@ +// +// Copyright (c) 2026 Steve Gerbino +// +// Distributed under the Boost Software License, Version 1.0. (See accompanying +// file LICENSE_1_0.txt or copy at http://www.boost.org/LICENSE_1_0.txt) +// +// Official repository: https://github.com/cppalliance/corosio +// + +#ifndef BOOST_COROSIO_TEST_FAULT_HPP +#define BOOST_COROSIO_TEST_FAULT_HPP + +#include + +namespace boost::corosio::test::fault { + +/** OS entry points the harness can fail. + + Names equal the libc / liburing / Win32 symbol. Every enumerator + exists on every platform so a portable test can name one behind a + `#if`; arming a symbol the running platform has no shadow for + simply never fires. The Berkeley-socket names (`socket`, `bind`, + `listen`, `accept`, `connect`, `shutdown`, `getsockname`, + `getpeername`, `getsockopt`, `setsockopt`, `send`, `recv`) name the + ws2_32 entry point of the same name on Windows. The + `uring_sqe_full` enumerator is not a symbol: arming it clamps the + next ring to one SQE and turns `io_uring_submit` into a no-op so + `io_uring_get_sqe` returns null on the second acquisition. + `kevent_register` is not a symbol either: it names the subset of + `kevent` calls that add a descriptor to the kqueue, so a test can + reach a registration without counting the waits the run loop makes + on its way there. An arm on `kevent` still counts every call, + registrations included. +*/ +enum class sys +{ + socket, socketpair, bind, listen, accept, accept4, connect, + getsockname, getpeername, getsockopt, setsockopt, shutdown, close, + read, write, writev, readv, preadv, pwritev, recv, send, recvmsg, + sendmsg, + poll, pipe, fcntl, ioctl, open, fstat, lseek, ftruncate, fsync, + fdatasync, posix_fadvise, unlink, sigaction, getaddrinfo, + freeaddrinfo, getnameinfo, gethostname, + epoll_create1, epoll_ctl, epoll_wait, eventfd, timerfd_create, + timerfd_settime, select, kqueue, kevent, kevent_register, + io_uring_queue_init_params, io_uring_queue_exit, io_uring_submit, + io_uring_submit_and_wait_timeout, io_uring_submit_and_get_events, + io_uring_wait_cqe_timeout, uring_sqe_full, + WSASocketW, WSAConnect, WSARecv, WSASend, WSARecvFrom, WSASendTo, + WSAPoll, WSAIoctl, WSAStartup, WSACleanup, closesocket, ioctlsocket, + GetAddrInfoExW, GetAddrInfoExCancel, FreeAddrInfoExW, GetNameInfoW, + CreateIoCompletionPort, GetQueuedCompletionStatus, + PostQueuedCompletionStatus, CancelIoEx, CloseHandle, CreateFileW, + ReadFile, WriteFile, SetFilePointerEx, GetFileSizeEx, SetEndOfFile, + FlushFileBuffers, DeleteFileA, CreateWaitableTimerW, SetWaitableTimer, + WaitForSingleObject, GetComputerNameExW, GetModuleHandleA, + GetModuleHandleW, GetProcAddress, MultiByteToWideChar, + WideCharToMultiByte, signal, + // Reached through a pointer the OS hands out rather than through an + // import: the WSAIoctl and GetProcAddress hooks substitute a wrapper + // for the pointer the library caches. + AcceptEx, ConnectEx, NtSetInformationFile, NtFlushBuffersFileEx, + count_ +}; + +/** Tag selecting the process-wide arm mode. + + A thread-local arm watching the same symbol shadows the + process-wide one for the thread that holds it, in either mode: that + thread's calls are counted and claimed by its own arm and never + reach the process-wide arm, even after the thread-local one has + fired or when its `nth` is out of reach. + + @see fault_scope +*/ +inline constexpr struct any_thread_t {} any_thread{}; + +/** Fail one OS call, by default on the current thread. + + While the scope is alive the `nth` call to `which` returns its + documented failure value with `errno` set to `err` (liburing + shadows return `-err`; on Windows `err` is a `WSA*`/`ERROR_*` code + published through `SetLastError`, and the handful of entry points + that report through their return value return it directly). Calls + before the nth and all calls after it forward to the real + function. The arm is thread-local unless + the scope is created with the `any_thread` overload, so by + default other threads never observe the fault. + + Up to four thread-local scopes may be alive at once, each with its + own counter: a deferred-path test parks an operation with one and + fails its reactor retry with another. Every live arm watching a + symbol counts each call to it, so two arms on the same symbol are + distinguished by `nth`. + + @par Preconditions + Fewer than four `fault_scope` objects are alive on this thread; + a fifth aborts. A scope must be constructed and destroyed on the + same thread, since it owns one of that thread's arms by index: a + scope held across a `co_await` therefore requires an + `io_context` run by a single thread, or the destructor releases + an arm belonging to some other thread. A process-wide scope has + its own one-at-a-time rule, described on the `any_thread` + overload. + + @par Example + @code + fault_scope f(sys::epoll_ctl, EPERM); + tcp_socket s(ioc); + auto ec = s.open(tcp::v4()); + BOOST_TEST(f.fired()); + BOOST_TEST(ec == std::errc::operation_not_permitted); + @endcode +*/ +class fault_scope +{ +public: + /// Disarm, even if the fault never fired. + ~fault_scope(); + + /// Arm `which` to fail with `err` on its `nth` call. + fault_scope(sys which, int err, unsigned nth = 1); + + /** Arm `which` on every thread rather than just this one. + + The library runs file I/O and name resolution on a thread + pool, where the thread-local arms are never consulted. This + overload publishes the arm process-wide; a thread with an arm + of its own watching `which` still uses that one. + + @par Preconditions + No other process-wide scope is alive; nesting aborts. The test + must keep at most one call to `which` in flight at a time, or + the shared counter races. + */ + fault_scope(sys which, int err, unsigned nth, any_thread_t); + + /** Create a scope that shortens the `nth` call instead of failing it. + + For the byte-moving calls (`read`, `write`, `writev`, `readv`, + `preadv`, `pwritev`, `recv`, `send`, `recvmsg`, `sendmsg`) the real + function is invoked with its length clamped to `count`, so + bytes genuinely move. `count == 0` forwards nothing and returns + 0, which reads as EOF on the read side. + */ + static fault_scope returning(sys which, std::size_t count, + unsigned nth = 1); + + /** Create a `returning` scope armed on every thread. + + @par Preconditions + No other process-wide scope is alive, and at most one call to + `which` is in flight at a time. + */ + static fault_scope returning_any_thread(sys which, std::size_t count, + unsigned nth = 1); + + /// Return true once the armed call has been intercepted. + bool fired() const noexcept; + + /** Return how many calls to the armed symbol this arm has counted. + + Every live arm watching a symbol counts each call to it, so this + is the arm's own view of the call ordinal an `nth` selects. A + test that has to reach past calls the library makes on the way + in can arm a counting scope first and read the number off it + instead of hard-coding one. + + @return Calls seen since the scope was constructed, including + the one that fired. + */ + unsigned count() const noexcept; + + fault_scope(fault_scope const&) = delete; + fault_scope& operator=(fault_scope const&) = delete; + +private: + struct short_tag {}; + fault_scope(short_tag, sys which, std::size_t count, unsigned nth, + bool global); + + bool global_ = false; + // Index of the claimed thread-local arm; -1 for a process-wide scope. + int idx_ = -1; +}; + +/** Rewrite one io_uring completion before corosio sees it. + + Matches the first unsubmitted SQE whose `fd` and `opcode` + (`IORING_OP_*`) equal the arguments, remembers its `user_data`, + and overwrites `res` on the CQE carrying that `user_data` when it + becomes visible. An `fd` of -1 matches on the opcode alone, for + the polls the library arms on descriptors it never hands out. + Only meaningful with the io_uring backend; the scope is inert on + the reactor backends. +*/ +class cqe_fault_scope +{ +public: + /// Destroy the scope, releasing the CQE arm it claimed. + ~cqe_fault_scope(); + + /** Construct a scope that rewrites the matched CQE's `res`. + + @param fd The descriptor the SQE was prepared on, or -1 to + match on `opcode` alone. + @param opcode The `IORING_OP_*` the SQE carries. + @param res The value to write into the CQE's `res`. + */ + cqe_fault_scope(int fd, int opcode, int res); + + /** Construct a scope that also clears `flags_to_clear` on the CQE. + + The multishot re-arm paths key off `IORING_CQE_F_MORE`, which + the kernel clears only when it terminates the multishot; there + is no way to provoke that from userspace, so the bit is cleared + here instead. + + @param fd The descriptor the SQE was prepared on, or -1 to + match on `opcode` alone. + @param opcode The `IORING_OP_*` the SQE carries. + @param res The value to write into the CQE's `res`. + @param flags_to_clear Bits to clear in the CQE's `flags`. + */ + cqe_fault_scope(int fd, int opcode, int res, unsigned flags_to_clear); + + /// Return true once a CQE has been rewritten. + bool fired() const noexcept; + + cqe_fault_scope(cqe_fault_scope const&) = delete; + cqe_fault_scope& operator=(cqe_fault_scope const&) = delete; +}; + +/** Fail one IOCP completion before corosio sees it. + + The `GetQueuedCompletionStatus` hook forwards, and for the `nth` + dequeue that yields a non-null `OVERLAPPED` reports failure with + `GetLastError()` set to `err`. That is the one way to reach the + error branches of the completion handlers: the kernel result of an + overlapped operation cannot be armed at the call that started it. + Windows only; on other platforms nothing defines this scope. + + @par Preconditions + No other completion fault is armed on this thread. +*/ +class completion_fault_scope +{ +public: + /// Destroy the scope, disarming the completion fault. + ~completion_fault_scope(); + + /** Construct a scope that fails one dequeued completion. + + @param err The Win32 error the dequeue reports. + @param nth Which completion carrying an `OVERLAPPED` to fail. + */ + completion_fault_scope(unsigned long err, unsigned nth = 1); + + /// Return true once a completion has been failed. + bool fired() const noexcept; + + completion_fault_scope(completion_fault_scope const&) = delete; + completion_fault_scope& operator=(completion_fault_scope const&) = delete; +}; + +/** Return true if arming `which` can still fire. + + Windows reaches its entry points through an import table, and what + a program imports is decided when it is linked: a name no module + references has no thunk to patch and no arm on it will ever fire. + The harness reports those at startup; a test asks here rather than + driving a hook that cannot fire. + + On POSIX the answer comes from the census: `which` has a shadow on + this platform, and — in a shared build — the readback found the + loader binding the library's call to it. A symbol this platform + does not spell (`kevent` on Linux, `accept4` on Darwin) answers + false, which is what lets one portable test skip loudly instead of + asserting `fired()` on an arm that can never fire. + + @param which The entry point to ask about. On Windows the four + reached through a pointer the OS hands out (`AcceptEx`, + `ConnectEx`, `NtSetInformationFile`, `NtFlushBuffersFileEx`) + answer for the hook that substitutes that pointer; + `uring_sqe_full` is not a symbol and answers for the liburing + shadows it works through. + + @return `true` if a hook for `which` is installed and reachable. +*/ +bool hook_is_live(sys which) noexcept; + +/// Return true if the executable links corosio as a shared library. +bool corosio_is_shared() noexcept; + +} // boost::corosio::test::fault + +#endif diff --git a/test/unit/fault/fault_arm.cpp b/test/unit/fault/fault_arm.cpp new file mode 100644 index 000000000..8b907efae --- /dev/null +++ b/test/unit/fault/fault_arm.cpp @@ -0,0 +1,224 @@ +// +// Copyright (c) 2026 Steve Gerbino +// +// Distributed under the Boost Software License, Version 1.0. (See accompanying +// file LICENSE_1_0.txt or copy at http://www.boost.org/LICENSE_1_0.txt) +// +// Official repository: https://github.com/cppalliance/corosio +// + +// The arm model is the same wherever the hooks are: only the way a +// failing call publishes its error code differs, and that is +// publish_error. Keeping this one copy is what stops the POSIX and +// Windows harnesses from drifting apart on rules a test relies on -- +// which arm claims a call, when an arm stays reserved, what a fifth +// scope does. + +#include "fault.hpp" +#include "fault_slot.hpp" + +#include +#include +#include + +namespace boost::corosio::test::fault { + +thread_local arm_set tls_arms; +std::atomic global_slot{nullptr}; + +[[noreturn]] void die(char const* msg) noexcept +{ + std::fputs(msg, stderr); + std::fputc('\n', stderr); + std::abort(); +} + +namespace { + +// Storage behind the process-wide arm; only one scope may own it. +slot global_storage; + +// Single place that claims an arm, shared by every constructor. +// Returns the arm index, or -1 for the process-wide slot. +int arm(slot desired, bool global) noexcept +{ + desired.owned = true; + desired.armed = true; + if(!global) + { + for(int i = 0; i < arm_set::max_arms; ++i) + { + if(tls_arms.arms[i].owned) + continue; + tls_arms.arms[i] = desired; + return i; + } + char msg[96]; + std::snprintf(msg, sizeof(msg), + "fault_scope: all %d fault arms are in use on this thread", + arm_set::max_arms); + die(msg); + } + if(global_slot.load(std::memory_order_acquire)) + die("fault_scope: a process-wide fault is already armed"); + global_storage = desired; + global_slot.store(&global_storage, std::memory_order_release); + return -1; +} + +} // namespace + +slot* armed_arm(sys which) noexcept +{ + for(auto& s : tls_arms.arms) + { + if(s.armed && s.which == which) + return &s; + } + return nullptr; +} + +namespace { + +// Every arm watching `which` counts the call, so two arms on the same +// symbol can claim different occurrences of it. Only one arm may claim +// a given call: the lowest-indexed one whose count just reached its +// nth. A later arm that reaches its nth on that same call has its +// count rolled back instead of being marked fired, so it stays armed +// and claims the next call rather than being spent on a fault that was +// never delivered. Returns null when the call must forward, and reports +// through `matched` whether this thread watches `which` at all, which +// is what keeps the process-wide slot a fallback rather than a second +// chance. +slot* claim_arm(sys which, bool short_mode, bool& matched) noexcept +{ + slot* won = nullptr; + for(auto& s : tls_arms.arms) + { + if(!s.armed || s.which != which) + continue; + matched = true; + if(s.short_mode != short_mode) + continue; + if(++s.seen != s.nth) + continue; + if(won) + { + --s.seen; + continue; + } + s.armed = false; + s.fired = true; + won = &s; + } + return won; +} + +// The process-wide slot, if it is armed for `which` in this mode. +slot* claim_global(sys which, bool short_mode) noexcept +{ + slot* p = global_slot.load(std::memory_order_acquire); + if(!p) + return nullptr; + auto& s = *p; + if(!s.armed || s.which != which || s.short_mode != short_mode) + return nullptr; + if(++s.seen != s.nth) + return nullptr; + s.armed = false; + s.fired = true; + return &s; +} + +} // namespace + +bool should_fail(sys which) noexcept +{ + bool matched = false; + slot* s = claim_arm(which, false, matched); + if(!s && !matched) + s = claim_global(which, false); + if(!s) + return false; + publish_error(s->err); + return true; +} + +bool should_shorten(sys which, std::size_t& count) noexcept +{ + bool matched = false; + slot* s = claim_arm(which, true, matched); + if(!s && !matched) + s = claim_global(which, true); + if(!s) + return false; + count = s->count; + return true; +} + +fault_scope::fault_scope(sys which, int err, unsigned nth) +{ + slot s; + s.which = which; + s.err = err; + s.nth = nth; + idx_ = arm(s, false); +} + +fault_scope::fault_scope(sys which, int err, unsigned nth, any_thread_t) + : global_(true) +{ + slot s; + s.which = which; + s.err = err; + s.nth = nth; + idx_ = arm(s, true); +} + +fault_scope::fault_scope(short_tag, sys which, std::size_t count, + unsigned nth, bool global) + : global_(global) +{ + slot s; + s.which = which; + s.count = count; + s.short_mode = true; + s.nth = nth; + idx_ = arm(s, global); +} + +fault_scope fault_scope::returning(sys which, std::size_t count, unsigned nth) +{ + return fault_scope(short_tag{}, which, count, nth, false); +} + +fault_scope fault_scope::returning_any_thread(sys which, std::size_t count, + unsigned nth) +{ + return fault_scope(short_tag{}, which, count, nth, true); +} + +fault_scope::~fault_scope() +{ + if(global_) + { + global_slot.store(nullptr, std::memory_order_release); + global_storage.armed = false; + global_storage.owned = false; + return; + } + tls_arms.arms[idx_].armed = false; + tls_arms.arms[idx_].owned = false; +} + +bool fault_scope::fired() const noexcept +{ + return global_ ? global_storage.fired : tls_arms.arms[idx_].fired; +} + +unsigned fault_scope::count() const noexcept +{ + return global_ ? global_storage.seen : tls_arms.arms[idx_].seen; +} + +} // boost::corosio::test::fault diff --git a/test/unit/fault/fault_posix.cpp b/test/unit/fault/fault_posix.cpp new file mode 100644 index 000000000..09e3c2905 --- /dev/null +++ b/test/unit/fault/fault_posix.cpp @@ -0,0 +1,1075 @@ +// +// Copyright (c) 2026 Steve Gerbino +// +// Distributed under the Boost Software License, Version 1.0. (See accompanying +// file LICENSE_1_0.txt or copy at http://www.boost.org/LICENSE_1_0.txt) +// +// Official repository: https://github.com/cppalliance/corosio +// + +#include "fault.hpp" +#include "fault_slot.hpp" + +#include +#include + +#if BOOST_COROSIO_HAVE_LIBURING +#include +#endif + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#if defined(__linux__) +#include +#include +#include +#endif + +#if defined(__APPLE__) || defined(__FreeBSD__) +#include +#endif + +#if defined(__APPLE__) +#include +#include +#include +#include +#endif + +// Linux-only open flag; folding it to zero keeps the mode test in the +// `open` shadow one expression on every platform. +#if !defined(O_TMPFILE) +#define O_TMPFILE 0 +#endif + +namespace boost::corosio::test::fault { + +thread_local cqe_slot tls_cqe; + +// The arm machinery lives in fault_arm.cpp; this is the one piece of +// it that has to know what platform it is on. +void publish_error(int err) noexcept +{ + errno = err; +} + +void* real_symbol(char const* name) noexcept +{ + void* p = ::dlsym(RTLD_NEXT, name); + if(!p) + { + char msg[160]; + std::snprintf(msg, sizeof(msg), + "fault harness: %s has no implementation behind the shadow", + name); + die(msg); + } + return p; +} + +namespace { + +// Copy the prefix of `in` holding at most `count` bytes into `out`. +// Returns the new iovec count. Corosio never passes more than a +// handful of buffers; 64 is a hard ceiling checked at runtime. +int truncate_iov(iovec const* in, int n, std::size_t count, iovec* out) noexcept +{ + if(n > 64) + die("fault harness: iovec count exceeds 64"); + int m = 0; + for(; m < n && count > 0; ++m) + { + out[m] = in[m]; + if(out[m].iov_len > count) + out[m].iov_len = count; + count -= out[m].iov_len; + } + return m; +} + +} // namespace + +cqe_fault_scope::cqe_fault_scope(int fd, int opcode, int res) + : cqe_fault_scope(fd, opcode, res, 0u) +{ +} + +cqe_fault_scope::cqe_fault_scope(int fd, int opcode, int res, + unsigned flags_to_clear) +{ + claim_completion_slot(tls_cqe, + "cqe_fault_scope: a completion fault is already armed on this thread"); + tls_cqe.fd = fd; + tls_cqe.opcode = opcode; + tls_cqe.res = res; + tls_cqe.flags_clear = flags_to_clear; +} + +cqe_fault_scope::~cqe_fault_scope() +{ + release_completion_slot(tls_cqe); +} + +bool cqe_fault_scope::fired() const noexcept +{ + return tls_cqe.fired; +} + +} // boost::corosio::test::fault + +using namespace boost::corosio::test::fault; + +// Each shadow resolves the real function on first use. dlsym may +// allocate, which is fine: malloc is not shadowed. +#define COROSIO_FAULT_REAL(name, sig) \ + static auto const real = reinterpret_cast(real_symbol(#name)) + +// glibc marks part of the census __THROW, which C++ reads as noexcept; +// Darwin's headers carry no exception specification at all and a +// redeclaration that adds one is ill-formed, so the spec is per-OS. +#if defined(__linux__) +#define COROSIO_FAULT_NOTHROW noexcept +#else +#define COROSIO_FAULT_NOTHROW +#endif + +extern "C" int socket(int domain, int type, int protocol) COROSIO_FAULT_NOTHROW +{ + COROSIO_FAULT_REAL(socket, int(*)(int, int, int)); + if(should_fail(sys::socket)) + return -1; + return real(domain, type, protocol); +} + +// glibc marks part of the census __THROW and leaves the rest with no +// exception spec; the shadow has to match exactly or -Werror rejects +// the redeclaration, so there are two macros rather than one. On +// Darwin COROSIO_FAULT_NOTHROW is empty and the two coincide, since no +// entry point there carries a specification to match. +#define COROSIO_FAULT_HOOK(name, ret, failval, params, args) \ + extern "C" ret name params \ + { \ + COROSIO_FAULT_REAL(name, ret(*)params); \ + if(should_fail(sys::name)) \ + return failval; \ + return real args; \ + } + +#define COROSIO_FAULT_HOOK_NX(name, ret, failval, params, args) \ + extern "C" ret name params COROSIO_FAULT_NOTHROW \ + { \ + COROSIO_FAULT_REAL(name, ret(*)params); \ + if(should_fail(sys::name)) \ + return failval; \ + return real args; \ + } + +COROSIO_FAULT_HOOK_NX(socketpair, int, -1, (int d, int t, int p, int* sv), (d, t, p, sv)) +COROSIO_FAULT_HOOK_NX(bind, int, -1, (int fd, sockaddr const* a, socklen_t l), (fd, a, l)) +COROSIO_FAULT_HOOK_NX(listen, int, -1, (int fd, int n), (fd, n)) +COROSIO_FAULT_HOOK(accept, int, -1, (int fd, sockaddr* a, socklen_t* l), (fd, a, l)) +COROSIO_FAULT_HOOK(connect, int, -1, (int fd, sockaddr const* a, socklen_t l), (fd, a, l)) +COROSIO_FAULT_HOOK_NX(getsockname, int, -1, (int fd, sockaddr* a, socklen_t* l), (fd, a, l)) +COROSIO_FAULT_HOOK_NX(getpeername, int, -1, (int fd, sockaddr* a, socklen_t* l), (fd, a, l)) +COROSIO_FAULT_HOOK_NX(getsockopt, int, -1, (int fd, int lv, int on, void* v, socklen_t* l), (fd, lv, on, v, l)) +COROSIO_FAULT_HOOK_NX(setsockopt, int, -1, (int fd, int lv, int on, void const* v, socklen_t l), (fd, lv, on, v, l)) +COROSIO_FAULT_HOOK_NX(shutdown, int, -1, (int fd, int how), (fd, how)) +COROSIO_FAULT_HOOK(close, int, -1, (int fd), (fd)) +COROSIO_FAULT_HOOK(poll, int, -1, (pollfd* p, nfds_t n, int t), (p, n, t)) +COROSIO_FAULT_HOOK_NX(pipe, int, -1, (int* p), (p)) +COROSIO_FAULT_HOOK_NX(fstat, int, -1, (int fd, struct stat* st), (fd, st)) +COROSIO_FAULT_HOOK_NX(lseek, off_t, -1, (int fd, off_t off, int wh), (fd, off, wh)) +COROSIO_FAULT_HOOK_NX(ftruncate, int, -1, (int fd, off_t len), (fd, len)) +COROSIO_FAULT_HOOK(fsync, int, -1, (int fd), (fd)) +COROSIO_FAULT_HOOK_NX(unlink, int, -1, (char const* p), (p)) +COROSIO_FAULT_HOOK_NX(sigaction, int, -1, (int sig, struct sigaction const* a, struct sigaction* o), (sig, a, o)) +COROSIO_FAULT_HOOK_NX(gethostname, int, -1, (char* n, size_t l), (n, l)) + +// Linux and FreeBSD both publish these; Darwin has neither, and +// sync_data() lowers to fsync there instead. +#if defined(__linux__) || defined(__FreeBSD__) +COROSIO_FAULT_HOOK(fdatasync, int, -1, (int fd), (fd)) + +// Return the armed errno directly: this reports failure through the +// return value, not through -1 + errno. +extern "C" int posix_fadvise(int fd, off_t off, off_t len, int advice) + COROSIO_FAULT_NOTHROW +{ + COROSIO_FAULT_REAL(posix_fadvise, int(*)(int, off_t, off_t, int)); + if(should_fail(sys::posix_fadvise)) + return errno; + return real(fd, off, len, advice); +} +#endif + +#if defined(__linux__) +COROSIO_FAULT_HOOK(accept4, int, -1, (int fd, sockaddr* a, socklen_t* l, int f), (fd, a, l, f)) +COROSIO_FAULT_HOOK_NX(epoll_create1, int, -1, (int f), (f)) +COROSIO_FAULT_HOOK_NX(epoll_ctl, int, -1, (int ep, int op, int fd, epoll_event* ev), (ep, op, fd, ev)) +COROSIO_FAULT_HOOK(epoll_wait, int, -1, (int ep, epoll_event* ev, int n, int t), (ep, ev, n, t)) +COROSIO_FAULT_HOOK_NX(eventfd, int, -1, (unsigned v, int f), (v, f)) +COROSIO_FAULT_HOOK_NX(timerfd_create, int, -1, (int c, int f), (c, f)) +COROSIO_FAULT_HOOK_NX(timerfd_settime, int, -1, (int fd, int f, itimerspec const* n, itimerspec* o), (fd, f, n, o)) +#endif + +#if defined(__APPLE__) || defined(__FreeBSD__) +COROSIO_FAULT_HOOK(kqueue, int, -1, (), ()) +// Hand-written rather than COROSIO_FAULT_HOOK: a registration and a +// wait are the same symbol, so the changelist is the only thing that +// tells them apart. Both arms are consulted on every call so their +// counters stay in step whichever one fires. +extern "C" int kevent( + int kq, struct kevent const* ch, int nch, struct kevent* ev, int nev, + timespec const* ts) +{ + COROSIO_FAULT_REAL(kevent, int(*)(int, struct kevent const*, int, + struct kevent*, int, timespec const*)); + bool adds = false; + for(int i = 0; i < nch; ++i) + { + if(ch[i].flags & EV_ADD) + adds = true; + } + // A call that satisfies both arms fails once: the plain kevent arm + // publishes last, so its errno is the one the caller sees. + bool const fail_add = adds && should_fail(sys::kevent_register); + bool const fail_any = should_fail(sys::kevent); + if(fail_add || fail_any) + return -1; + return real(kq, ch, nch, ev, nev, ts); +} +#endif + +#if defined(__APPLE__) +// Darwin's spells select as `select$DARWIN_EXTSN` under +// _DARWIN_C_SOURCE and plain `_select` otherwise, and each corosio +// translation unit picks its spelling independently of this one. +// Defining both by asm label shadows the call either way; the plain +// COROSIO_FAULT_HOOK cannot, because the header's own asm label would +// rename it to whichever single spelling this file happens to see. +// The library references the plain spelling today, so the suffixed one +// is a census alias rather than a symbol anything binds to. +extern "C" { +int corosio_fault_select(int, fd_set*, fd_set*, fd_set*, timeval*) + __asm__("_select"); +int corosio_fault_select_extsn(int, fd_set*, fd_set*, fd_set*, timeval*) + __asm__("_select$DARWIN_EXTSN"); +} + +namespace { + +using select_fn = int(*)(int, fd_set*, fd_set*, fd_set*, timeval*); + +// libSystem exports both spellings, but only the plain one is +// guaranteed; fall back so a missing alias cannot leave `real` null. +select_fn real_select(char const* name) noexcept +{ + auto p = reinterpret_cast(::dlsym(RTLD_NEXT, name)); + if(!p) + p = reinterpret_cast(real_symbol("select")); + return p; +} + +} // namespace + +extern "C" int corosio_fault_select(int n, fd_set* r, fd_set* w, fd_set* e, + timeval* t) +{ + static auto const real = real_select("select"); + if(should_fail(sys::select)) + return -1; + return real(n, r, w, e, t); +} + +extern "C" int corosio_fault_select_extsn(int n, fd_set* r, fd_set* w, + fd_set* e, timeval* t) +{ + static auto const real = real_select("select$DARWIN_EXTSN"); + if(should_fail(sys::select)) + return -1; + return real(n, r, w, e, t); +} +#else +COROSIO_FAULT_HOOK(select, int, -1, (int n, fd_set* r, fd_set* w, fd_set* e, timeval* t), (n, r, w, e, t)) +#endif + +// Both report failure through the return value, not -1 + errno. +extern "C" int getaddrinfo(char const* node, char const* service, + addrinfo const* hints, addrinfo** res) +{ + COROSIO_FAULT_REAL(getaddrinfo, int(*)(char const*, char const*, addrinfo const*, addrinfo**)); + if(should_fail(sys::getaddrinfo)) + return errno; + return real(node, service, hints, res); +} + +// No failure mode of its own: swallowing the release would leak the +// list the lookup allocated. The arm still counts the call, which is +// what a census of the resolver's teardown path needs. +extern "C" void freeaddrinfo(addrinfo* ai) COROSIO_FAULT_NOTHROW +{ + COROSIO_FAULT_REAL(freeaddrinfo, void(*)(addrinfo*)); + std::ignore = should_fail(sys::freeaddrinfo); + real(ai); +} + +// FreeBSD sizes the host and service buffers with size_t where glibc +// and Darwin spell them socklen_t; the shadow has to match its own +// header exactly or the redeclaration is rejected. +#if defined(__FreeBSD__) +#define COROSIO_FAULT_NI_LEN size_t +#else +#define COROSIO_FAULT_NI_LEN socklen_t +#endif + +extern "C" int getnameinfo(sockaddr const* sa, socklen_t salen, char* host, + COROSIO_FAULT_NI_LEN hostlen, char* serv, COROSIO_FAULT_NI_LEN servlen, + int flags) +{ + COROSIO_FAULT_REAL(getnameinfo, int(*)(sockaddr const*, socklen_t, char*, + COROSIO_FAULT_NI_LEN, char*, COROSIO_FAULT_NI_LEN, int)); + if(should_fail(sys::getnameinfo)) + return errno; + return real(sa, salen, host, hostlen, serv, servlen, flags); +} + +extern "C" int fcntl(int fd, int cmd, ...) +{ + COROSIO_FAULT_REAL(fcntl, int(*)(int, int, ...)); + va_list ap; + va_start(ap, cmd); + // Every corosio use passes an int or nothing; a long covers both on + // the SysV and AAPCS64 ABIs. + long arg = va_arg(ap, long); + va_end(ap); + if(should_fail(sys::fcntl)) + return -1; + return real(fd, cmd, arg); +} + +extern "C" int ioctl(int fd, unsigned long req, ...) COROSIO_FAULT_NOTHROW +{ + COROSIO_FAULT_REAL(ioctl, int(*)(int, unsigned long, ...)); + va_list ap; + va_start(ap, req); + void* arg = va_arg(ap, void*); + va_end(ap); + if(should_fail(sys::ioctl)) + return -1; + return real(fd, req, arg); +} + +extern "C" int open(char const* path, int flags, ...) +{ + COROSIO_FAULT_REAL(open, int(*)(char const*, int, ...)); + unsigned mode = 0; + if(flags & (O_CREAT | O_TMPFILE)) + { + va_list ap; + va_start(ap, flags); + mode = va_arg(ap, unsigned); + va_end(ap); + } + if(should_fail(sys::open)) + return -1; + return real(path, flags, mode); +} + +// The byte-moving census entries additionally consult should_shorten: +// a shortened call still reaches the real function, clamped to the +// armed count, so bytes genuinely move instead of being dropped. +extern "C" ssize_t read(int fd, void* b, size_t n) +{ + COROSIO_FAULT_REAL(read, ssize_t(*)(int, void*, size_t)); + if(should_fail(sys::read)) + return -1; + std::size_t c; + if(should_shorten(sys::read, c)) + return c == 0 ? 0 : real(fd, b, c < n ? c : n); + return real(fd, b, n); +} + +extern "C" ssize_t write(int fd, void const* b, size_t n) +{ + COROSIO_FAULT_REAL(write, ssize_t(*)(int, void const*, size_t)); + if(should_fail(sys::write)) + return -1; + std::size_t c; + if(should_shorten(sys::write, c)) + return c == 0 ? 0 : real(fd, b, c < n ? c : n); + return real(fd, b, n); +} + +// Defined everywhere though only the kqueue write policy calls it; a +// shadow nothing references costs one forward on the rare libc caller. +extern "C" ssize_t writev(int fd, iovec const* v, int n) +{ + COROSIO_FAULT_REAL(writev, ssize_t(*)(int, iovec const*, int)); + if(should_fail(sys::writev)) + return -1; + std::size_t c; + if(should_shorten(sys::writev, c)) + { + if(c == 0) + return 0; + iovec t[64]; + return real(fd, t, truncate_iov(v, n, c, t)); + } + return real(fd, v, n); +} + +extern "C" ssize_t recv(int fd, void* b, size_t n, int f) +{ + COROSIO_FAULT_REAL(recv, ssize_t(*)(int, void*, size_t, int)); + if(should_fail(sys::recv)) + return -1; + std::size_t c; + if(should_shorten(sys::recv, c)) + return c == 0 ? 0 : real(fd, b, c < n ? c : n, f); + return real(fd, b, n, f); +} + +extern "C" ssize_t send(int fd, void const* b, size_t n, int f) +{ + COROSIO_FAULT_REAL(send, ssize_t(*)(int, void const*, size_t, int)); + if(should_fail(sys::send)) + return -1; + std::size_t c; + if(should_shorten(sys::send, c)) + return c == 0 ? 0 : real(fd, b, c < n ? c : n, f); + return real(fd, b, n, f); +} + +extern "C" ssize_t readv(int fd, iovec const* v, int n) +{ + COROSIO_FAULT_REAL(readv, ssize_t(*)(int, iovec const*, int)); + if(should_fail(sys::readv)) + return -1; + std::size_t c; + if(should_shorten(sys::readv, c)) + { + if(c == 0) + return 0; + iovec t[64]; + return real(fd, t, truncate_iov(v, n, c, t)); + } + return real(fd, v, n); +} + +extern "C" ssize_t preadv(int fd, iovec const* v, int n, off_t o) +{ + COROSIO_FAULT_REAL(preadv, ssize_t(*)(int, iovec const*, int, off_t)); + if(should_fail(sys::preadv)) + return -1; + std::size_t c; + if(should_shorten(sys::preadv, c)) + { + if(c == 0) + return 0; + iovec t[64]; + return real(fd, t, truncate_iov(v, n, c, t), o); + } + return real(fd, v, n, o); +} + +extern "C" ssize_t pwritev(int fd, iovec const* v, int n, off_t o) +{ + COROSIO_FAULT_REAL(pwritev, ssize_t(*)(int, iovec const*, int, off_t)); + if(should_fail(sys::pwritev)) + return -1; + std::size_t c; + if(should_shorten(sys::pwritev, c)) + { + if(c == 0) + return 0; + iovec t[64]; + return real(fd, t, truncate_iov(v, n, c, t), o); + } + return real(fd, v, n, o); +} + +extern "C" ssize_t recvmsg(int fd, msghdr* m, int f) +{ + COROSIO_FAULT_REAL(recvmsg, ssize_t(*)(int, msghdr*, int)); + if(should_fail(sys::recvmsg)) + return -1; + std::size_t c; + if(should_shorten(sys::recvmsg, c)) + { + if(c == 0) + return 0; + iovec t[64]; + msghdr mh = *m; + mh.msg_iov = t; + mh.msg_iovlen = truncate_iov(m->msg_iov, (int)m->msg_iovlen, c, t); + ssize_t r = real(fd, &mh, f); + m->msg_namelen = mh.msg_namelen; + m->msg_flags = mh.msg_flags; + m->msg_controllen = mh.msg_controllen; + return r; + } + return real(fd, m, f); +} + +extern "C" ssize_t sendmsg(int fd, msghdr const* m, int f) +{ + COROSIO_FAULT_REAL(sendmsg, ssize_t(*)(int, msghdr const*, int)); + if(should_fail(sys::sendmsg)) + return -1; + std::size_t c; + if(should_shorten(sys::sendmsg, c)) + { + if(c == 0) + return 0; + iovec t[64]; + msghdr mh = *m; + mh.msg_iov = t; + mh.msg_iovlen = truncate_iov(m->msg_iov, (int)m->msg_iovlen, c, t); + return real(fd, &mh, f); + } + return real(fd, m, f); +} + +#if defined(__linux__) +// _FORTIFY_SOURCE routes these through the *_chk entry points, which +// would otherwise reach libc's read/recv/poll directly. +extern "C" ssize_t __read_chk(int fd, void* b, size_t n, size_t) +{ + return ::read(fd, b, n); +} + +extern "C" ssize_t __recv_chk(int fd, void* b, size_t n, size_t, int f) +{ + return ::recv(fd, b, n, f); +} + +extern "C" ssize_t __recvfrom_chk(int fd, void* b, size_t n, size_t, int f, + sockaddr* a, socklen_t* l) +{ + COROSIO_FAULT_REAL(recvfrom, ssize_t(*)(int, void*, size_t, int, sockaddr*, socklen_t*)); + if(should_fail(sys::recv)) + return -1; + return real(fd, b, n, f, a, l); +} + +extern "C" int __poll_chk(pollfd* p, nfds_t n, int t, size_t) +{ + return ::poll(p, n, t); +} + +extern "C" ssize_t __pread64_chk(int fd, void* b, size_t n, off64_t o, size_t) +{ + COROSIO_FAULT_REAL(pread64, ssize_t(*)(int, void*, size_t, off64_t)); + if(should_fail(sys::read)) + return -1; + return real(fd, b, n, o); +} + +// __open_2 is called with the mode omitted, but our `open` shadow is +// variadic and reads a mode via va_arg when O_CREAT/O_TMPFILE is set; +// passing an explicit dummy 0 keeps that read well-defined instead of +// pulling an unsupplied argument. glibc's own __open_2 aborts if +// O_CREAT is set without a mode — no corosio call site does that. +extern "C" int __open_2(char const* path, int flags) +{ + return ::open(path, flags, 0); +} + +extern "C" int __gethostname_chk(char* b, size_t n, size_t) noexcept +{ + return ::gethostname(b, n); +} +#endif + +namespace boost::corosio::test::fault { + +#if defined(__APPLE__) +namespace { + +// dyld's index for the loaded corosio dylib, or -1 in a static build. +// Taking the address of a dylib function from the executable can yield +// a stub in this image, so dladdr reports both addresses in the same +// image and cannot tell a shared build from a static one; ask dyld +// directly instead. Index 0 is the executable, and only a leading +// basename counts: a static build whose own path happens to contain +// the library name must not read as shared. +int corosio_image_index() noexcept +{ + static constexpr char prefix[] = "libboost_corosio"; + for(std::uint32_t i = 1, n = ::_dyld_image_count(); i < n; ++i) + { + char const* path = ::_dyld_get_image_name(i); + if(!path) + continue; + char const* slash = std::strrchr(path, '/'); + char const* base = slash ? slash + 1 : path; + if(std::strncmp(base, prefix, sizeof(prefix) - 1) == 0) + return static_cast(i); + } + return -1; +} + +} // namespace +#endif + +bool corosio_is_shared() noexcept +{ +#if defined(__APPLE__) + return corosio_image_index() >= 0; +#else + Dl_info lib{}, exe{}; + // host_name is an ordinary exported corosio function; the hook + // lives in the executable by construction. + ::dladdr(reinterpret_cast(&boost::corosio::host_name), &lib); + ::dladdr(reinterpret_cast(&::socket), &exe); + return lib.dli_fbase != exe.dli_fbase; +#endif +} + +namespace { + +struct census_entry +{ + char const* name; + void const* hook; + // The enumerator a test arms to reach this shadow, or count_ for a + // second spelling of a symbol that already has one. + sys id; +}; + +#define COROSIO_FAULT_CENSUS(name) \ + { #name, reinterpret_cast(&::name), sys::name } + +// A second spelling (the glibc fortify wrappers, the Darwin `$` +// suffixes) shares the arm of the name it wraps, so it carries no +// enumerator of its own. +#define COROSIO_FAULT_CENSUS_ALIAS(name) \ + { #name, reinterpret_cast(&::name), sys::count_ } + +} // namespace + +#if defined(__linux__) +// Not declared by any header we include in a non-fortified build (glibc +// only exposes these under __USE_FORTIFY_LEVEL > 0); the definitions +// above are the only declaration these need. +extern "C" ssize_t __read_chk(int, void*, size_t, size_t); +extern "C" ssize_t __recv_chk(int, void*, size_t, size_t, int); +extern "C" ssize_t __recvfrom_chk(int, void*, size_t, size_t, int, sockaddr*, socklen_t*); +extern "C" int __poll_chk(pollfd*, nfds_t, int, size_t); +extern "C" ssize_t __pread64_chk(int, void*, size_t, off64_t, size_t); +extern "C" int __open_2(char const*, int); +extern "C" int __gethostname_chk(char*, size_t, size_t) noexcept; +#endif + +namespace { + +// One entry per OS symbol the library is expected to reference on this +// platform, read off `nm -u` of the built library. It is the ledger the +// shared-build readback checks, so a symbol no backend here calls does +// not belong in it even when the shadow exists: the Darwin build never +// seeks with lseek, and the epoll/timerfd/eventfd family and the glibc +// fortify aliases have no Darwin counterpart at all. +[[maybe_unused]] census_entry const census[] = { + COROSIO_FAULT_CENSUS(socket), COROSIO_FAULT_CENSUS(socketpair), + COROSIO_FAULT_CENSUS(bind), COROSIO_FAULT_CENSUS(listen), + COROSIO_FAULT_CENSUS(accept), + COROSIO_FAULT_CENSUS(connect), COROSIO_FAULT_CENSUS(getsockname), + COROSIO_FAULT_CENSUS(getpeername), COROSIO_FAULT_CENSUS(getsockopt), + COROSIO_FAULT_CENSUS(setsockopt), COROSIO_FAULT_CENSUS(shutdown), + COROSIO_FAULT_CENSUS(close), COROSIO_FAULT_CENSUS(read), + COROSIO_FAULT_CENSUS(write), COROSIO_FAULT_CENSUS(readv), + COROSIO_FAULT_CENSUS(preadv), COROSIO_FAULT_CENSUS(pwritev), + COROSIO_FAULT_CENSUS(recv), COROSIO_FAULT_CENSUS(send), + COROSIO_FAULT_CENSUS(recvmsg), COROSIO_FAULT_CENSUS(sendmsg), + COROSIO_FAULT_CENSUS(poll), COROSIO_FAULT_CENSUS(pipe), + COROSIO_FAULT_CENSUS(fcntl), COROSIO_FAULT_CENSUS(ioctl), + COROSIO_FAULT_CENSUS(open), COROSIO_FAULT_CENSUS(fstat), + COROSIO_FAULT_CENSUS(ftruncate), + COROSIO_FAULT_CENSUS(fsync), COROSIO_FAULT_CENSUS(unlink), + COROSIO_FAULT_CENSUS(sigaction), COROSIO_FAULT_CENSUS(getaddrinfo), + COROSIO_FAULT_CENSUS(freeaddrinfo), + COROSIO_FAULT_CENSUS(getnameinfo), COROSIO_FAULT_CENSUS(gethostname), +#if defined(__linux__) || defined(__FreeBSD__) + COROSIO_FAULT_CENSUS(fdatasync), COROSIO_FAULT_CENSUS(posix_fadvise), +#endif + // Darwin spells select with an asm label and gets its two aliases + // below instead of the plain name. +#if !defined(__APPLE__) + COROSIO_FAULT_CENSUS(select), +#endif +#if defined(__linux__) + COROSIO_FAULT_CENSUS(accept4), COROSIO_FAULT_CENSUS(lseek), + COROSIO_FAULT_CENSUS(epoll_create1), COROSIO_FAULT_CENSUS(epoll_ctl), + COROSIO_FAULT_CENSUS(epoll_wait), COROSIO_FAULT_CENSUS(eventfd), + COROSIO_FAULT_CENSUS(timerfd_create), COROSIO_FAULT_CENSUS(timerfd_settime), + COROSIO_FAULT_CENSUS_ALIAS(__read_chk), COROSIO_FAULT_CENSUS_ALIAS(__recv_chk), + COROSIO_FAULT_CENSUS_ALIAS(__recvfrom_chk), COROSIO_FAULT_CENSUS_ALIAS(__poll_chk), + COROSIO_FAULT_CENSUS_ALIAS(__pread64_chk), + COROSIO_FAULT_CENSUS_ALIAS(__open_2), COROSIO_FAULT_CENSUS_ALIAS(__gethostname_chk), +#endif +#if defined(__APPLE__) || defined(__FreeBSD__) + COROSIO_FAULT_CENSUS(writev), COROSIO_FAULT_CENSUS(kqueue), + COROSIO_FAULT_CENSUS(kevent), +#endif +#if defined(__APPLE__) + { "select", reinterpret_cast(&::corosio_fault_select), + sys::select }, + { "select$DARWIN_EXTSN", + reinterpret_cast(&::corosio_fault_select_extsn), + sys::count_ }, +#endif +#if BOOST_COROSIO_HAVE_LIBURING + COROSIO_FAULT_CENSUS(io_uring_queue_init_params), + COROSIO_FAULT_CENSUS(io_uring_queue_exit), + COROSIO_FAULT_CENSUS(io_uring_submit), + COROSIO_FAULT_CENSUS(io_uring_submit_and_wait_timeout), + COROSIO_FAULT_CENSUS(io_uring_submit_and_get_events), + COROSIO_FAULT_CENSUS(io_uring_wait_cqe_timeout), +#endif +}; + +constexpr std::size_t census_count = sizeof(census) / sizeof(census[0]); + +// What the readback below concluded, per census entry: whether a call +// the library makes actually lands in the shadow. Written once during +// static initialisation, read by hook_is_live. +bool census_live[census_count]; + +// Alias entries name a second spelling of a symbol the library may or +// may not have been built to call: the glibc fortify wrappers and the +// Darwin `$` suffixes. +[[maybe_unused]] bool is_alias_entry(char const* name) noexcept +{ + return std::strncmp(name, "__", 2) == 0 || std::strchr(name, '$'); +} + +#if defined(__APPLE__) + +// One census symbol whose import slot in the dylib is to be rewritten. +// Mach-O's two-level namespace records libSystem as the source of the +// dylib's imports at link time, so a bound slot holds exactly +// libSystem's entry point for the symbol and can be recognised by its +// value alone. That reads the same in a classic lazy-pointer section +// and in the `__got` of a chained-fixups image, and needs neither the +// indirect symbol table nor the chained-fixup imports table, both of +// which vary with the linker that produced the dylib. +struct rebind_target +{ + char const* name; + void const* hook; + void const* real; + unsigned rebound; + unsigned unbound; +}; + +// Visit every section of `hdr` that can hold a bound import pointer: +// the classic lazy and non-lazy pointer sections, plus the `__got` +// family a chained-fixups image uses in their place. +template +void for_each_import_section(mach_header_64 const* hdr, std::intptr_t slide, + F&& f) noexcept +{ + auto const* cmd = reinterpret_cast(hdr + 1); + for(std::uint32_t i = 0; i < hdr->ncmds; ++i) + { + if(cmd->cmd == LC_SEGMENT_64) + { + auto const* seg = reinterpret_cast(cmd); + auto const* sec = reinterpret_cast(seg + 1); + for(std::uint32_t j = 0; j < seg->nsects; ++j, ++sec) + { + std::uint32_t const type = sec->flags & SECTION_TYPE; + if(type != S_NON_LAZY_SYMBOL_POINTERS && + type != S_LAZY_SYMBOL_POINTERS && + std::strncmp(sec->sectname, "__got", 16) != 0 && + std::strncmp(sec->sectname, "__auth_got", 16) != 0) + continue; + std::size_t const count = + static_cast(sec->size) / sizeof(void*); + if(count == 0) + continue; + f(seg, sec, reinterpret_cast( + static_cast(sec->addr) + slide), count); + } + } + cmd = reinterpret_cast( + reinterpret_cast(cmd) + cmd->cmdsize); + } +} + +// Ask the kernel for `prot` over the pages covering the byte range. +// VM_PROT_COPY asks for a private copy of a file-backed mapping, which +// is what a plain write request is refused on. +bool protect_pages(void* addr, std::size_t bytes, vm_prot_t prot) noexcept +{ + auto const page = static_cast(::getpagesize()); + auto const start = reinterpret_cast(addr); + vm_address_t const begin = start & ~(page - 1); + vm_size_t const len = ((start + bytes + page - 1) & ~(page - 1)) - begin; + if(::vm_protect(mach_task_self(), begin, len, FALSE, prot) + == KERN_SUCCESS) + return true; + return ::vm_protect(mach_task_self(), begin, len, FALSE, + prot | VM_PROT_COPY) == KERN_SUCCESS; +} + +// True for a segment dyld maps read-only again once it has applied its +// fixups, which is the one that has to be reprotected after the write. +bool is_const_segment(segment_command_64 const* seg) noexcept +{ + char name[17] = {}; + std::memcpy(name, seg->segname, 16); + return std::strstr(name, "_CONST") != nullptr; +} + +// Point every matching import slot at the shadow. Reports through +// `ok`, and leaves the counting to the verification pass so that what +// is checked is the memory as it stands afterwards, not what this +// pass believes it wrote. +void rebind_imports(mach_header_64 const* hdr, std::intptr_t slide, + rebind_target const* targets, std::size_t n, bool& ok) noexcept +{ + for_each_import_section(hdr, slide, + [&](segment_command_64 const* seg, section_64 const* sec, + void** slots, std::size_t count) + { + std::size_t hits = 0; + for(std::size_t i = 0; i < count; ++i) + { + for(std::size_t k = 0; k < n; ++k) + { + if(slots[i] == targets[k].real) + ++hits; + } + } + if(hits == 0) + return; + if(!protect_pages(slots, count * sizeof(void*), + VM_PROT_READ | VM_PROT_WRITE)) + { + std::fprintf(stderr, + "fault harness: %.16s,%.16s refused to become writable\n", + seg->segname, sec->sectname); + ok = false; + return; + } + for(std::size_t i = 0; i < count; ++i) + { + for(std::size_t k = 0; k < n; ++k) + { + if(slots[i] != targets[k].real) + continue; + slots[i] = const_cast(targets[k].hook); + break; + } + } + if(is_const_segment(seg)) + std::ignore = protect_pages(slots, count * sizeof(void*), + VM_PROT_READ); + }); +} + +// Count, per symbol, the slots that now hold the shadow and the slots +// that still hold libSystem's entry point. `scanned` carries the size +// of the search, which is what separates a rewrite that missed a +// symbol from a Mach-O layout this walk does not recognise at all. +void tally_imports(mach_header_64 const* hdr, std::intptr_t slide, + rebind_target* targets, std::size_t n, std::size_t& sections, + std::size_t& scanned) noexcept +{ + for_each_import_section(hdr, slide, + [&](segment_command_64 const*, section_64 const*, void** slots, + std::size_t count) + { + ++sections; + scanned += count; + for(std::size_t i = 0; i < count; ++i) + { + for(std::size_t k = 0; k < n; ++k) + { + if(slots[i] == targets[k].hook) + ++targets[k].rebound; + else if(slots[i] == targets[k].real) + ++targets[k].unbound; + } + } + }); +} + +// Rewrite the loaded corosio dylib's import slots so that library code +// reaches the shadows. dyld applies `__DATA,__interpose` only from +// dylibs, never from the main executable, and the two-level namespace +// leaves an executable-defined shadow out of the search entirely, so +// this is the only interposition available to a test binary. +void interpose_corosio_dylib() noexcept +{ +#if defined(__has_feature) +#if __has_feature(ptrauth_calls) + // An arm64e slot holds a signed pointer; a plain store would + // install a value the caller's authenticated branch rejects. + die("fault harness: pointer-authenticated import slots cannot be " + "rebound by a plain store"); +#endif +#endif + int const image = corosio_image_index(); + if(image < 0) + die("fault harness: the corosio dylib left dyld's image list"); + auto const* hdr = reinterpret_cast( + ::_dyld_get_image_header(static_cast(image))); + if(!hdr || hdr->magic != MH_MAGIC_64) + die("fault harness: the corosio dylib is not a 64-bit Mach-O image"); + auto const slide = + ::_dyld_get_image_vmaddr_slide(static_cast(image)); + + rebind_target targets[sizeof(census) / sizeof(census[0])] = {}; + std::size_t n = 0; + for(auto const& e : census) + { + // An alias is a second spelling the library is not known to + // bind; libSystem may even give both spellings one entry + // point, which a value match cannot tell apart. + if(is_alias_entry(e.name)) + continue; + void* real = ::dlsym(RTLD_NEXT, e.name); + if(!real) + { + char msg[160]; + std::snprintf(msg, sizeof(msg), + "fault harness: %s has no implementation behind the shadow", + e.name); + die(msg); + } + for(std::size_t k = 0; k < n; ++k) + { + if(targets[k].real != real) + continue; + char msg[192]; + std::snprintf(msg, sizeof(msg), + "fault harness: %s and %s share one libSystem entry point", + targets[k].name, e.name); + die(msg); + } + targets[n].name = e.name; + targets[n].hook = e.hook; + targets[n].real = real; + ++n; + } + + bool ok = true; + rebind_imports(hdr, slide, targets, n, ok); + std::size_t sections = 0, scanned = 0; + tally_imports(hdr, slide, targets, n, sections, scanned); + for(std::size_t k = 0; k < n; ++k) + { + auto const& t = targets[k]; + if(t.unbound != 0) + { + std::fprintf(stderr, "fault harness: %s still reaches libSystem " + "through %u of the dylib's import slots\n", t.name, t.unbound); + ok = false; + } + // A census name with no slot at all is as much a defect as one + // that refused to move: every arm on it would sit dead. + if(t.rebound == 0) + { + std::fprintf(stderr, + "fault harness: %s is not among the dylib's imports\n", + t.name); + ok = false; + } + } + if(!ok) + { + std::fprintf(stderr, "fault harness: %zu import sections, %zu slots " + "scanned in %s\n", sections, scanned, + ::_dyld_get_image_name(static_cast(image))); + die("fault harness: the corosio dylib's imports were not rebound"); + } +} +#endif + +// A shared build only reaches the shadows through the dynamic loader, +// and a loader that resolved the library's calls elsewhere would leave +// every fault silently dead. Prove the binding instead of assuming it: +// on ELF the library binds through the executable's dynamic symbol +// table, which -Bsymbolic or -fno-plt would bypass; on Mach-O the +// binding has to be installed here first. +int const readback = [] +{ + // A static build resolved the calls at link time, so every shadow + // this platform has is reached by construction. + for(auto& live : census_live) + live = true; + if(!corosio_is_shared()) + return 0; +#if defined(__APPLE__) + interpose_corosio_dylib(); + // It dies unless every non-alias name was rebound, so surviving it + // settles those; the aliases it skipped bind nothing. + for(std::size_t i = 0; i < census_count; ++i) + census_live[i] = !is_alias_entry(census[i].name); +#else + bool ok = true; + for(std::size_t i = 0; i < census_count; ++i) + { + auto const& e = census[i]; + void* bound = ::dlsym(RTLD_DEFAULT, e.name); + census_live[i] = (bound == e.hook); + if(bound == e.hook) + continue; + // A null binding means this libc does not expose the name as a + // dynamic symbol at all: an old-glibc inline like `fstat` that + // redirects to `__fxstat`, so the library never reaches it + // through a PLT slot and the shadow is simply inapplicable. An + // alias is likewise a name the library was not built to call. + // Neither is a broken interposition, so leave the entry not live. + if(bound == nullptr || is_alias_entry(e.name)) + continue; + // A non-null binding to something other than our hook is the real + // failure: the call resolved to the actual libc function, + // bypassing the shadow (-Bsymbolic, -fno-plt). + std::fprintf(stderr, "fault harness: %s is bound to %p, hook is %p\n", + e.name, bound, e.hook); + ok = false; + } + if(!ok) + die("fault harness: shadows are not interposing libboost_corosio.so"); +#endif + return 0; +}(); + +} // namespace + +bool hook_is_live(sys which) noexcept +{ + // Not a symbol: it works by clamping the ring liburing's own + // shadows drive, so it lives exactly when they do. + if(which == sys::uring_sqe_full) + which = sys::io_uring_submit; + // Not a symbol either: it is a slice of the kevent shadow. + if(which == sys::kevent_register) + which = sys::kevent; + for(std::size_t i = 0; i < census_count; ++i) + { + if(census[i].id == which && census_live[i]) + return true; + } + return false; +} + +} // boost::corosio::test::fault diff --git a/test/unit/fault/fault_slot.hpp b/test/unit/fault/fault_slot.hpp new file mode 100644 index 000000000..bac7ea24b --- /dev/null +++ b/test/unit/fault/fault_slot.hpp @@ -0,0 +1,155 @@ +// +// Copyright (c) 2026 Steve Gerbino +// +// Distributed under the Boost Software License, Version 1.0. (See accompanying +// file LICENSE_1_0.txt or copy at http://www.boost.org/LICENSE_1_0.txt) +// +// Official repository: https://github.com/cppalliance/corosio +// + +#ifndef BOOST_COROSIO_TEST_FAULT_SLOT_HPP +#define BOOST_COROSIO_TEST_FAULT_SLOT_HPP + +#include "fault.hpp" + +#include +#include + +namespace boost::corosio::test::fault { + +// Per-thread arm state. Plain aggregate so the hooks touch nothing +// that could allocate or lock. +struct slot +{ + sys which = sys::count_; + int err = 0; + std::size_t count = 0; + unsigned nth = 1; + unsigned seen = 0; + bool short_mode = false; + bool fired = false; + bool armed = false; + // Tracks the owning fault_scope's lifetime, independent of `armed`: + // should_fail/should_shorten clear `armed` the moment the fault + // fires, but the scope object is still alive and keeps its arm + // reserved until destroyed. + bool owned = false; +}; + +// Four independent arms per thread. A deferred-path test parks an +// operation with one fault and fails its reactor retry with another, +// and every armed arm counts calls on its own, so two arms may target +// different occurrences of the same symbol. +struct arm_set +{ + static constexpr int max_arms = 4; + slot arms[max_arms]; +}; + +extern thread_local arm_set tls_arms; + +// Return the first arm still armed for `which` on this thread, or null. +slot* armed_arm(sys which) noexcept; + +// Process-wide fallback, consulted only when the calling thread has no +// arm watching the symbol being called; an arm watching some other +// symbol does not shadow it. Work the library hands to its thread pool +// (file I/O, name resolution) cannot see the test thread's arms, so a +// scope armed with `any_thread` publishes itself here instead. +extern std::atomic global_slot; + +// Publish `err` where a caller of the failing entry point will read it: +// errno on POSIX, the last-error slots on Windows. The one part of the +// arm model that has to know which harness it was linked into, which +// is why fault_arm.cpp can be shared verbatim. +void publish_error(int err) noexcept; + +// Return true when the current call must fail; the error is already +// published. +bool should_fail(sys which) noexcept; + +// Return true when the current call must be shortened to `count`. +bool should_shorten(sys which, std::size_t& count) noexcept; + +// Print `msg` and abort. Defined in fault_arm.cpp so every hook +// translation unit enforces contract violations the same way. +[[noreturn]] void die(char const* msg) noexcept; + +#if !defined(_WIN32) +// Resolve the real entry point behind a shadow. A symbol that resolves +// to null would otherwise only surface as a crash inside the shadow, +// with nothing to say which one; name it instead. The Windows hooks +// keep the real entry point from the import thunk they overwrote and +// never look a symbol up by name. +void* real_symbol(char const* name) noexcept; +#endif + +// Second slot for CQE rewriting; independent of `slot` so a test can +// pair an SQ-full fault with a completion rewrite. +struct cqe_slot +{ + // -1 matches any fd: the multishot polls the harness has to reach + // are armed on descriptors the library never hands out. + int fd = -1; + int opcode = -1; + int res = 0; + unsigned flags_clear = 0; + unsigned long long user_data = 0; + bool have_user_data = false; + bool fired = false; + bool armed = false; + // Tracks the owning scope's lifetime, independent of `armed`: the + // rewrite clears `armed` the moment it fires, so nesting has to be + // refused on this instead or a second scope would quietly take + // over the slot a live one still reads `fired()` from. + bool owned = false; +}; + +extern thread_local cqe_slot tls_cqe; + +#if defined(_WIN32) +// The IOCP twin of cqe_slot: a completion carries no fd or opcode to +// match on, only its ordinal among the dequeues that produced an +// OVERLAPPED, so the two cannot share one aggregate. +struct completion_slot +{ + unsigned long err = 0; + unsigned nth = 1; + unsigned seen = 0; + bool fired = false; + bool armed = false; + // See cqe_slot::owned; the rule is the same. + bool owned = false; +}; + +extern thread_local completion_slot tls_completion; + +// Return true when the current dequeue must be turned into a failure, +// reporting the armed error through `err`. +bool completion_should_fail(unsigned long& err) noexcept; +#endif + +// Claim the one completion-side slot of its kind for a scope, or die +// naming `who`. Shared by cqe_fault_scope and completion_fault_scope, +// whose slots hold different things but reserve them by the same rule. +template +void claim_completion_slot(Slot& s, char const* who) noexcept +{ + if(s.owned) + die(who); + s = Slot{}; + s.armed = true; + s.owned = true; +} + +// Release the slot claimed by claim_completion_slot. +template +void release_completion_slot(Slot& s) noexcept +{ + s.armed = false; + s.owned = false; +} + +} // boost::corosio::test::fault + +#endif diff --git a/test/unit/fault/fault_test_utils.hpp b/test/unit/fault/fault_test_utils.hpp new file mode 100644 index 000000000..bb4f27b01 --- /dev/null +++ b/test/unit/fault/fault_test_utils.hpp @@ -0,0 +1,467 @@ +// +// Copyright (c) 2026 Steve Gerbino +// +// Distributed under the Boost Software License, Version 1.0. (See accompanying +// file LICENSE_1_0.txt or copy at http://www.boost.org/LICENSE_1_0.txt) +// +// Official repository: https://github.com/cppalliance/corosio +// + +#ifndef BOOST_COROSIO_TEST_FAULT_TEST_UTILS_HPP +#define BOOST_COROSIO_TEST_FAULT_TEST_UTILS_HPP + +#include "fault.hpp" +#include "test_suite.hpp" + +#include +#include +#if !defined(_WIN32) +#include +#include +#include +#include +#include +#endif + +#if defined(__FreeBSD__) +// real_symbol: the descriptor scan below must not spend a live `fcntl` +// arm on its own probing. +#include "fault_slot.hpp" +#endif + +#include +#include +#include +#include +#include +#include + +#if defined(_WIN32) +#ifndef WIN32_LEAN_AND_MEAN +#define WIN32_LEAN_AND_MEAN +#endif +#ifndef NOMINMAX +#define NOMINMAX +#endif +#include +#include +#else +#include +#include +#include +#include +#include +#include +#include +#endif + +namespace boost::corosio::test::fault { + +/* The one backend a process-wide suite may use. + + The signal self-pipe and its handlers are created once per process, + so a suite that faults their creation cannot be instantiated per + backend: the first instantiation would install exactly what the rest + were meant to fault. Such a suite picks the platform's native + reactor and names the others explicitly where it needs them. +*/ +#if BOOST_COROSIO_HAS_EPOLL +inline constexpr auto one_backend = corosio::epoll; +#elif BOOST_COROSIO_HAS_KQUEUE +inline constexpr auto one_backend = corosio::kqueue; +#elif BOOST_COROSIO_HAS_IOCP +inline constexpr auto one_backend = corosio::iocp; +#else +inline constexpr auto one_backend = corosio::select; +#endif + +#if defined(_WIN32) + +// The handle count is Windows' answer to the descriptor count: a +// socket, a file and a completion port all show up in it, which is +// what the leak assertions need. It moves on its own as the CRT and +// the thread pool come and go, so only differences taken across a +// short failing call mean anything. +inline int open_fds() +{ + DWORD n = 0; + if(!::GetProcessHandleCount(::GetCurrentProcess(), &n)) + return -1; + return static_cast(n); +} + +// Windows has no fork, so there is no isolated process to run `body` +// in: it runs here. Faults that install process-wide state — the +// signal handlers — therefore have to live in a suite of their own, +// where nothing else has installed that state first. +template +void in_child(F&& body) +{ + BOOST_TEST(body()); +} + +// Assert that a repeatedly failing call releases what it creates. +// +// GetProcessHandleCount is process-wide and moves on its own — a +// Winsock provider loading on the first socket of a family, a runtime +// thread coming or going — and it has been observed drifting in both +// directions across a single pair of samples. An exact before/after +// comparison is therefore not a leak signal at all. Instead `fn` runs +// once to absorb the first-use cost, and the count is taken across +// enough repetitions that a per-call leak, which grows the count once +// per call, separates from that ambient noise. +template +void expect_no_handle_leak(F&& fn, int reps, int max_growth) +{ + fn(); + int const before = open_fds(); + // open_fds() answers -1 when the count cannot be read, which would + // otherwise satisfy the comparison below on its own. + BOOST_TEST(before >= 0); + for(int i = 0; i < reps; ++i) + fn(); + int const after = open_fds(); + // A -1 here would satisfy the growth comparison on its own. + BOOST_TEST(after >= 0); + BOOST_TEST(after - before < max_growth); +} + +// The default shape: eight repetitions, and a leak of one handle per +// call lands exactly on the threshold. A call site whose leak is +// exactly one handle should ask for more repetitions than it allows +// growth, so the two are not decided by a single ambient handle. +template +void expect_no_handle_leak(F&& fn) +{ + expect_no_handle_leak(fn, 8, 8); +} + +// The Win32 and Winsock codes the library hands back unchanged compare +// equal only to themselves: which of them a toolchain's system_category +// also matches to a std::errc condition differs between MSVC and MinGW, +// so a test spells such an expectation as the raw code. +inline std::error_code win_err(DWORD e) +{ + return std::error_code(static_cast(e), std::system_category()); +} + +inline std::string temp_path(char const* tag) +{ + char dir[MAX_PATH + 1] = {}; + DWORD const n = ::GetTempPathA(sizeof(dir), dir); + // An unusable TEMP is not worth a fallback that might be + // unwritable; the test that opens the path reports it. + std::string base(dir, n); + return base + "corosio_fault_" + tag + "_" + + std::to_string(::GetCurrentProcessId()); +} + +#else + +// Count open descriptors so a test can prove the failure path released +// what it created. +inline int open_fds() +{ +#if defined(__FreeBSD__) + // FreeBSD mounts neither /proc nor fdescfs by default, and /dev/fd + // without fdescfs lists only 0-2, so the table is probed one entry + // at a time. The probe goes through the real fcntl rather than the + // shadow: a test that samples the count inside a live `fcntl` arm + // would otherwise spend that arm on the scan. + static auto const real_fcntl = + reinterpret_cast(real_symbol("fcntl")); + long const lim = ::sysconf(_SC_OPEN_MAX); + // 65536 bounds the scan on a host with an enormous rlimit; nothing + // here opens anywhere near that many descriptors. + long const stop = (lim < 0 || lim > 65536) ? 65536 : lim; + int n = 0; + for(long fd = 0; fd < stop; ++fd) + { + if(real_fcntl(static_cast(fd), F_GETFD) != -1) + ++n; + } + return n; +#else +#if defined(__APPLE__) + // Darwin has no /proc; /dev/fd is the same per-process listing. + DIR* d = ::opendir("/dev/fd"); +#else + DIR* d = ::opendir("/proc/self/fd"); +#endif + // -1 rather than 0: an unreadable /proc/self/fd or /dev/fd must + // break the leak assertions, not satisfy them. + if(!d) + return -1; + int n = 0; + while(::readdir(d)) + ++n; + ::closedir(d); + return n; +#endif +} + +// Write out this binary's coverage counters, if it has any. The child +// below ends with _Exit, which runs no atexit handler, so an +// instrumented child would discard everything it counted and every +// branch only it reached would measure as unreached however many times +// the test passed. +// +// Looked up rather than declared. An optional symbol is spelled +// differently by each linker -- ELF takes a weak undefined reference, +// ld64 rejects one outright and does not accept weak_import for a +// symbol no library provides -- and an uninstrumented build has to +// link either way. A lookup leaves nothing undefined at link time and +// answers null where there is no libgcov, which is the same thing the +// weak reference was meant to say. The CMake side is what keeps the +// symbol in an instrumented binary for this to find. +inline void flush_coverage_counters() +{ + static auto const dump = + reinterpret_cast(::dlsym(RTLD_DEFAULT, "__gcov_dump")); + if(dump) + dump(); +} + +// Run `body` in a forked child and assert it returned true. Process-wide +// state that is created once — the signal self-pipe and its sigaction +// handlers — can only be faulted in a fresh process, and installing it +// in this one would silently disarm every other test that faults it. +template +void in_child(F&& body) +{ + pid_t pid = ::fork(); + BOOST_TEST(pid >= 0); + if(pid < 0) + return; + if(pid == 0) + { + bool const ok = body(); + flush_coverage_counters(); + std::_Exit(ok ? 0 : 1); + } + int status = 0; + ::waitpid(pid, &status, 0); + BOOST_TEST(WIFEXITED(status) && WEXITSTATUS(status) == 0); +} + +inline std::string temp_path(char const* tag) +{ + // A build that confines the process to its own scratch directory + // sets TMPDIR; /tmp may not even be writable there. + char const* dir = std::getenv("TMPDIR"); + std::string base = (dir && *dir) ? dir : "/tmp"; + if(base.back() != '/') + base += '/'; + return base + "corosio_fault_" + tag + "_" + std::to_string(::getpid()); +} + +#endif + +#if !defined(_WIN32) + +/* Bound a run loop an assertion failure could leave running. + + A test that parks an operation and expects something else to + complete it has no way to fail on its own: if the completion never + comes, run() does not return and the job dies on the CI timeout with + nothing to say which test was waiting. Spawn one of these under a + stop source alongside, request the stop where the test finishes, and + check `expired` after the run. + + The stop source is what keeps the guard from becoming the thing the + run loop waits for: a pending delay is outstanding work, so a guard + that is never cancelled costs its full timeout on every test that + passes. The IOCP suites keep a copy of their own. + + @param ioc The context to stop if the timeout is reached. + @param expired Set when the guard fired rather than being cancelled. +*/ +inline capy::task<> stop_guard(io_context& ioc, bool& expired) +{ + auto [ec] = co_await corosio::delay(std::chrono::seconds(2)); + // Cancelled: the test finished and asked the guard to stand down. + if(ec) + co_return; + expired = true; + ioc.stop(); +} + +#endif + +// Return true if the process runs under Valgrind. Valgrind always maps +// its preload library, so scanning the map is enough and does not need +// valgrind.h on the include path. +inline bool running_under_valgrind() noexcept +{ +#if defined(_WIN32) + return false; +#else + std::FILE* f = std::fopen("/proc/self/maps", "re"); + if(!f) + return false; + bool found = false; + char line[4096]; + while(std::fgets(line, sizeof(line), f)) + { + if(std::strstr(line, "vgpreload")) + { + found = true; + break; + } + } + std::fclose(f); + return found; +#endif +} + +// Loud-skip a whole fault suite under Valgrind. Valgrind redirects the +// same libc entry points the shadows interpose, so the arms cannot fire +// and would report spurious failures. Interposition-based fault +// injection is not meaningful there, exactly as it is not under the +// sanitizers the Jamfile already excludes. Report the reason so the leg +// passes with a visible cause rather than a silent one. +inline bool skip_under_valgrind() noexcept +{ + if(!running_under_valgrind()) + return false; + std::fprintf(stderr, + "fault harness: running under Valgrind, which redirects the libc " + "entry points the shadows interpose; skipping this fault suite\n"); + return true; +} + +// Report a hook that has no import to patch in this executable. A +// silent `return` from a test would read as a pass; name the symbol so +// the log says which coverage the run did not have. +inline void skip_dead_hook(char const* name) +{ + std::fprintf(stderr, + "fault harness: %s not imported by this executable; skipping the " + "test that arms it\n", name); +} + +#if !defined(_WIN32) + +// Raise the soft descriptor limit to `want` if it is lower, so a +// descriptor numbered at or above FD_SETSIZE can exist at all. The +// raise is not undone: the descriptors it permits outlive the call +// that asked for it, and lowering the limit under them is what would +// be surprising. +inline bool raise_fd_limit(rlim_t want) +{ + rlimit rl{}; + if(::getrlimit(RLIMIT_NOFILE, &rl) != 0) + return false; + if(rl.rlim_cur >= want) + return true; + if(rl.rlim_max != RLIM_INFINITY && rl.rlim_max < want) + return false; + rl.rlim_cur = want; + return ::setrlimit(RLIMIT_NOFILE, &rl) == 0; +} + +// Report a descriptor table this process is not allowed to grow. Reads +// like skip_dead_hook: the run had a reason not to take the coverage, +// and the log has to say so rather than pass silently. +inline void skip_no_high_fd(char const* what) +{ + std::fprintf(stderr, + "fault harness: this process cannot hold a descriptor at or above " + "FD_SETSIZE; skipping %s\n", what); +} + +// Duplicate `fd` onto a descriptor number above FD_SETSIZE. Returns -1 +// when the limit forbids it, which the caller reports through +// skip_no_high_fd. The select backend rejects such a descriptor rather +// than letting FD_SET clobber unrelated memory, and that rejection is +// what the assign tests are after. +inline int dup_above_fd_setsize(int fd) +{ + constexpr int target = FD_SETSIZE + 8; + if(!raise_fd_limit(static_cast(target) + 8)) + return -1; + if(::dup2(fd, target) != target) + return -1; + return target; +} + +/* Hold every free descriptor number below FD_SETSIZE. + + While one of these is alive the kernel has no low number left to + hand out, so the next socket, pipe or accepted connection lands at + or above FD_SETSIZE. That is the only way to watch the select + backend reject a descriptor it cannot represent, since a descriptor + number is not something a caller chooses. + + Construct it after the io_context: the select scheduler's own + self-pipe has to be representable too. +*/ +class fd_wall +{ +public: + ~fd_wall() + { + for(auto it = held_.rbegin(); it != held_.rend(); ++it) + ::close(*it); + } + + fd_wall() + { + if(!raise_fd_limit(FD_SETSIZE + 64)) + return; + // /dev/null rather than a dup of 0: a test runner may hand the + // process a closed or non-duplicable stdin. + int const seed = ::open("/dev/null", O_RDONLY | O_CLOEXEC); + if(seed < 0) + return; + held_.push_back(seed); + for(;;) + { + int const fd = ::fcntl(seed, F_DUPFD_CLOEXEC, 0); + if(fd < 0) + return; + held_.push_back(fd); + if(fd >= FD_SETSIZE - 1) + break; + } + raised_ = true; + } + + /// Return true if the next descriptor the process opens lands above. + bool ok() const noexcept + { + return raised_; + } + + fd_wall(fd_wall const&) = delete; + fd_wall& operator=(fd_wall const&) = delete; + +private: + std::vector held_; + bool raised_ = false; +}; + +#endif + +// BOOST_TEST_THROWS accepts any std::system_error, which would pass +// even if the library reported an error the fault never injected. +// `Expected` is a std::errc where the library normalizes the code and a +// std::error_code where it hands back the raw platform value. +template +void expect_system_error(F&& fn, Expected expected) +{ + std::error_code caught; + try + { + fn(); + } + catch(std::system_error const& e) + { + caught = e.code(); + } + BOOST_TEST(caught == expected); +} + +} // boost::corosio::test::fault + +#endif diff --git a/test/unit/fault/fault_uring.cpp b/test/unit/fault/fault_uring.cpp new file mode 100644 index 000000000..d8b263602 --- /dev/null +++ b/test/unit/fault/fault_uring.cpp @@ -0,0 +1,184 @@ +// +// Copyright (c) 2026 Steve Gerbino +// +// Distributed under the Boost Software License, Version 1.0. (See accompanying +// file LICENSE_1_0.txt or copy at http://www.boost.org/LICENSE_1_0.txt) +// +// Official repository: https://github.com/cppalliance/corosio +// + +#include "fault_slot.hpp" + +#include +#include +#include +#include + +// liburing 2.6 and later declare their API noexcept in C++; older +// headers leave it unspecified, and a shadow that adds a specification +// the header does not have is ill-formed. +#if !defined(LIBURING_NOEXCEPT) +#define LIBURING_NOEXCEPT +#endif + +using namespace boost::corosio::test::fault; + +namespace { + +// An archive-linked liburing leaves no body behind the shadow: the +// executable's own definition already satisfies the reference, so the +// archive member is never pulled in and RTLD_NEXT has nothing left to +// find. The distro's shared object supplies one. Every ring call in +// the process goes through these shadows, so the ring is still driven +// by a single implementation. +void* uring_real_symbol(char const* name) noexcept +{ + if(void* p = ::dlsym(RTLD_NEXT, name)) + return p; + static void* const lib = ::dlopen("liburing.so.2", RTLD_NOW | RTLD_LOCAL); + if(lib) + { + if(void* p = ::dlsym(lib, name)) + return p; + } + return real_symbol(name); +} + +#define COROSIO_FAULT_REAL(name, sig) \ + static auto const real = reinterpret_cast(uring_real_symbol(#name)) + +// Fail with a negative errno, liburing style. +bool uring_fail(sys which, int& rc) noexcept +{ + if(!should_fail(which)) + return false; + rc = -errno; + return true; +} + +slot* sqe_full_armed() noexcept +{ + return armed_arm(sys::uring_sqe_full); +} + +// Record the user_data of the first pending SQE matching the armed +// fd/opcode. The SQ array is user memory, so this is a plain read. +void scan_pending_sqes(io_uring* ring) noexcept +{ + auto& c = tls_cqe; + if(!c.armed || c.have_user_data) + return; + for(unsigned i = ring->sq.sqe_head; i != ring->sq.sqe_tail; ++i) + { + auto const& sqe = ring->sq.sqes[i & ring->sq.ring_mask]; + if(int(sqe.opcode) == c.opcode && (c.fd < 0 || sqe.fd == c.fd)) + { + c.user_data = sqe.user_data; + c.have_user_data = true; + return; + } + } +} + +// Overwrite `res` on the visible CQE carrying the recorded user_data. +void rewrite_visible_cqes(io_uring* ring) noexcept +{ + auto& c = tls_cqe; + if(!c.armed || !c.have_user_data) + return; + unsigned head; + io_uring_cqe* cqe; + io_uring_for_each_cqe(ring, head, cqe) + { + if(cqe->user_data == c.user_data) + { + cqe->res = c.res; + cqe->flags &= ~c.flags_clear; + c.fired = true; + c.armed = false; + return; + } + } +} + +} // namespace + +extern "C" int io_uring_queue_init_params(unsigned entries, io_uring* ring, + io_uring_params* p) LIBURING_NOEXCEPT +{ + COROSIO_FAULT_REAL(io_uring_queue_init_params, int(*)(unsigned, io_uring*, io_uring_params*)); + int rc; + if(uring_fail(sys::io_uring_queue_init_params, rc)) + return rc; + if(sqe_full_armed()) + entries = 1; + return real(entries, ring, p); +} + +extern "C" void io_uring_queue_exit(io_uring* ring) LIBURING_NOEXCEPT +{ + COROSIO_FAULT_REAL(io_uring_queue_exit, void(*)(io_uring*)); + // Cannot fail; counted so a test can assert teardown reached it. + std::ignore = should_fail(sys::io_uring_queue_exit); + real(ring); +} + +extern "C" int io_uring_submit(io_uring* ring) LIBURING_NOEXCEPT +{ + COROSIO_FAULT_REAL(io_uring_submit, int(*)(io_uring*)); + int rc; + if(uring_fail(sys::io_uring_submit, rc)) + return rc; + if(auto* s = sqe_full_armed()) + { + s->fired = true; + return 0; + } + scan_pending_sqes(ring); + rc = real(ring); + // A buffered write can complete inside this io_uring_enter, so the + // CQE the arm is waiting for may already be visible when it + // returns. Rewriting here as well as in the waiting entry points + // is what keeps the arm from depending on which call the kernel + // chose to finish the op in. + rewrite_visible_cqes(ring); + return rc; +} + +extern "C" int io_uring_submit_and_wait_timeout(io_uring* ring, io_uring_cqe** cqe, + unsigned wait_nr, __kernel_timespec* ts, sigset_t* sigmask) LIBURING_NOEXCEPT +{ + COROSIO_FAULT_REAL(io_uring_submit_and_wait_timeout, + int(*)(io_uring*, io_uring_cqe**, unsigned, __kernel_timespec*, sigset_t*)); + int rc; + if(uring_fail(sys::io_uring_submit_and_wait_timeout, rc)) + return rc; + scan_pending_sqes(ring); + rc = real(ring, cqe, wait_nr, ts, sigmask); + rewrite_visible_cqes(ring); + return rc; +} + +extern "C" int io_uring_submit_and_get_events(io_uring* ring) LIBURING_NOEXCEPT +{ + COROSIO_FAULT_REAL(io_uring_submit_and_get_events, int(*)(io_uring*)); + int rc; + if(uring_fail(sys::io_uring_submit_and_get_events, rc)) + return rc; + scan_pending_sqes(ring); + rc = real(ring); + rewrite_visible_cqes(ring); + return rc; +} + +extern "C" int io_uring_wait_cqe_timeout(io_uring* ring, io_uring_cqe** cqe, + __kernel_timespec* ts) LIBURING_NOEXCEPT +{ + COROSIO_FAULT_REAL(io_uring_wait_cqe_timeout, int(*)(io_uring*, io_uring_cqe**, __kernel_timespec*)); + int rc; + if(uring_fail(sys::io_uring_wait_cqe_timeout, rc)) + return rc; + rc = real(ring, cqe, ts); + rewrite_visible_cqes(ring); + return rc; +} diff --git a/test/unit/fault/fault_win.cpp b/test/unit/fault/fault_win.cpp new file mode 100644 index 000000000..a66a49e72 --- /dev/null +++ b/test/unit/fault/fault_win.cpp @@ -0,0 +1,867 @@ +// +// Copyright (c) 2026 Steve Gerbino +// +// Distributed under the Boost Software License, Version 1.0. (See accompanying +// file LICENSE_1_0.txt or copy at http://www.boost.org/LICENSE_1_0.txt) +// +// Official repository: https://github.com/cppalliance/corosio +// + +#include "fault.hpp" +#include "fault_slot.hpp" + +#if defined(_WIN32) + +#include + +#ifndef WIN32_LEAN_AND_MEAN +#define WIN32_LEAN_AND_MEAN +#endif +#ifndef NOMINMAX +#define NOMINMAX +#endif + +#include +#include + +#include +#include +#include +#include + +#include +#include +#include +#include + +namespace boost::corosio::test::fault { + +thread_local completion_slot tls_completion; + +// The arm machinery lives in fault_arm.cpp; this is the one piece of +// it that has to know what platform it is on. Winsock and the kernel +// share the per-thread error slot, but the two setters are separate +// entry points and a hook cannot know which one its caller will read; +// publishing through both costs nothing on a path already failing. +void publish_error(int err) noexcept +{ + ::SetLastError(static_cast(err)); + ::WSASetLastError(err); +} + +bool completion_should_fail(unsigned long& err) noexcept +{ + auto& c = tls_completion; + if(!c.armed) + return false; + if(++c.seen != c.nth) + return false; + c.armed = false; + c.fired = true; + err = c.err; + return true; +} + +completion_fault_scope::completion_fault_scope(unsigned long err, unsigned nth) +{ + claim_completion_slot(tls_completion, + "completion_fault_scope: a completion fault is already armed " + "on this thread"); + tls_completion.err = err; + tls_completion.nth = nth; +} + +completion_fault_scope::~completion_fault_scope() +{ + release_completion_slot(tls_completion); +} + +bool completion_fault_scope::fired() const noexcept +{ + return tls_completion.fired; +} + +namespace { + +// A generic function pointer. Every cast between an entry point's real +// type and this one goes through void(*)(), the one function type that +// -Wcast-function-type accepts in both directions. +using proc_t = void (*)(); + +// The entry points reached through an import table. Every hook is +// generated from this list: the id, the forwarding pointer slot, the +// hook itself and the table row all keep the list's order. +// +// `failval` is evaluated only after should_fail has published the +// armed error, which is what lets the entry points that report through +// their return value hand back ::GetLastError(). +#define COROSIO_FAULT_WIN_SIMPLE(X) \ + X(socket, SOCKET, INVALID_SOCKET, WSAAPI, \ + (int af, int type, int protocol), (af, type, protocol)) \ + X(WSASocketW, SOCKET, INVALID_SOCKET, WSAAPI, \ + (int af, int type, int protocol, LPWSAPROTOCOL_INFOW pi, GROUP g, \ + DWORD flags), \ + (af, type, protocol, pi, g, flags)) \ + X(bind, int, SOCKET_ERROR, WSAAPI, \ + (SOCKET s, sockaddr const* a, int len), (s, a, len)) \ + X(listen, int, SOCKET_ERROR, WSAAPI, (SOCKET s, int backlog), \ + (s, backlog)) \ + X(accept, SOCKET, INVALID_SOCKET, WSAAPI, \ + (SOCKET s, sockaddr* a, int* len), (s, a, len)) \ + X(connect, int, SOCKET_ERROR, WSAAPI, \ + (SOCKET s, sockaddr const* a, int len), (s, a, len)) \ + X(shutdown, int, SOCKET_ERROR, WSAAPI, (SOCKET s, int how), (s, how)) \ + X(closesocket, int, SOCKET_ERROR, WSAAPI, (SOCKET s), (s)) \ + X(ioctlsocket, int, SOCKET_ERROR, WSAAPI, \ + (SOCKET s, long cmd, u_long* argp), (s, cmd, argp)) \ + X(getsockname, int, SOCKET_ERROR, WSAAPI, \ + (SOCKET s, sockaddr* a, int* len), (s, a, len)) \ + X(getpeername, int, SOCKET_ERROR, WSAAPI, \ + (SOCKET s, sockaddr* a, int* len), (s, a, len)) \ + X(getsockopt, int, SOCKET_ERROR, WSAAPI, \ + (SOCKET s, int lvl, int name, char* val, int* len), \ + (s, lvl, name, val, len)) \ + X(setsockopt, int, SOCKET_ERROR, WSAAPI, \ + (SOCKET s, int lvl, int name, char const* val, int len), \ + (s, lvl, name, val, len)) \ + X(WSAConnect, int, SOCKET_ERROR, WSAAPI, \ + (SOCKET s, sockaddr const* a, int len, LPWSABUF cd, LPWSABUF ud, \ + LPQOS sq, LPQOS gq), \ + (s, a, len, cd, ud, sq, gq)) \ + X(WSARecvFrom, int, SOCKET_ERROR, WSAAPI, \ + (SOCKET s, LPWSABUF bufs, DWORD n, LPDWORD got, LPDWORD flags, \ + sockaddr* from, LPINT fromlen, LPWSAOVERLAPPED ov, \ + LPWSAOVERLAPPED_COMPLETION_ROUTINE cr), \ + (s, bufs, n, got, flags, from, fromlen, ov, cr)) \ + X(WSASendTo, int, SOCKET_ERROR, WSAAPI, \ + (SOCKET s, LPWSABUF bufs, DWORD n, LPDWORD sent, DWORD flags, \ + sockaddr const* to, int tolen, LPWSAOVERLAPPED ov, \ + LPWSAOVERLAPPED_COMPLETION_ROUTINE cr), \ + (s, bufs, n, sent, flags, to, tolen, ov, cr)) \ + X(WSAPoll, int, SOCKET_ERROR, WSAAPI, \ + (LPWSAPOLLFD fds, ULONG n, INT timeout), (fds, n, timeout)) \ + X(WSAStartup, int, static_cast(::GetLastError()), WSAAPI, \ + (WORD ver, LPWSADATA data), (ver, data)) \ + X(WSACleanup, int, SOCKET_ERROR, WSAAPI, (), ()) \ + X(GetAddrInfoExW, INT, static_cast(::GetLastError()), WSAAPI, \ + (PCWSTR name, PCWSTR service, DWORD ns, LPGUID nsid, \ + ADDRINFOEXW const* hints, PADDRINFOEXW* res, timeval* timeout, \ + LPOVERLAPPED ov, LPLOOKUPSERVICE_COMPLETION_ROUTINE cr, \ + LPHANDLE handle), \ + (name, service, ns, nsid, hints, res, timeout, ov, cr, handle)) \ + X(GetAddrInfoExCancel, INT, static_cast(::GetLastError()), WSAAPI, \ + (LPHANDLE handle), (handle)) \ + X(GetNameInfoW, INT, static_cast(::GetLastError()), WSAAPI, \ + (sockaddr const* sa, int salen, wchar_t* node, DWORD nodelen, \ + wchar_t* service, DWORD servicelen, INT flags), \ + (sa, salen, node, nodelen, service, servicelen, flags)) \ + X(CreateIoCompletionPort, HANDLE, nullptr, WINAPI, \ + (HANDLE file, HANDLE port, ULONG_PTR key, DWORD threads), \ + (file, port, key, threads)) \ + X(PostQueuedCompletionStatus, BOOL, FALSE, WINAPI, \ + (HANDLE port, DWORD bytes, ULONG_PTR key, LPOVERLAPPED ov), \ + (port, bytes, key, ov)) \ + X(CancelIoEx, BOOL, FALSE, WINAPI, (HANDLE h, LPOVERLAPPED ov), (h, ov)) \ + X(CloseHandle, BOOL, FALSE, WINAPI, (HANDLE h), (h)) \ + X(CreateFileW, HANDLE, INVALID_HANDLE_VALUE, WINAPI, \ + (LPCWSTR name, DWORD access, DWORD share, \ + LPSECURITY_ATTRIBUTES sa, DWORD disp, DWORD flags, \ + HANDLE tmpl), \ + (name, access, share, sa, disp, flags, tmpl)) \ + X(SetFilePointerEx, BOOL, FALSE, WINAPI, \ + (HANDLE h, LARGE_INTEGER dist, PLARGE_INTEGER out, DWORD method), \ + (h, dist, out, method)) \ + X(GetFileSizeEx, BOOL, FALSE, WINAPI, \ + (HANDLE h, PLARGE_INTEGER size), (h, size)) \ + X(SetEndOfFile, BOOL, FALSE, WINAPI, (HANDLE h), (h)) \ + X(FlushFileBuffers, BOOL, FALSE, WINAPI, (HANDLE h), (h)) \ + X(DeleteFileA, BOOL, FALSE, WINAPI, (LPCSTR name), (name)) \ + X(CreateWaitableTimerW, HANDLE, nullptr, WINAPI, \ + (LPSECURITY_ATTRIBUTES sa, BOOL manual, LPCWSTR name), \ + (sa, manual, name)) \ + X(SetWaitableTimer, BOOL, FALSE, WINAPI, \ + (HANDLE h, LARGE_INTEGER const* due, LONG period, \ + PTIMERAPCROUTINE apc, LPVOID arg, BOOL resume), \ + (h, due, period, apc, arg, resume)) \ + X(WaitForSingleObject, DWORD, WAIT_FAILED, WINAPI, \ + (HANDLE h, DWORD ms), (h, ms)) \ + X(GetComputerNameExW, BOOL, FALSE, WINAPI, \ + (COMPUTER_NAME_FORMAT kind, LPWSTR buf, LPDWORD size), \ + (kind, buf, size)) \ + X(GetModuleHandleA, HMODULE, nullptr, WINAPI, (LPCSTR name), (name)) \ + X(GetModuleHandleW, HMODULE, nullptr, WINAPI, (LPCWSTR name), (name)) \ + X(MultiByteToWideChar, int, 0, WINAPI, \ + (UINT cp, DWORD flags, char const* in, int inlen, wchar_t* out, \ + int outlen), \ + (cp, flags, in, inlen, out, outlen)) \ + X(WideCharToMultiByte, int, 0, WINAPI, \ + (UINT cp, DWORD flags, wchar_t const* in, int inlen, char* out, \ + int outlen, char const* dflt, LPBOOL used), \ + (cp, flags, in, inlen, out, outlen, dflt, used)) + +// Entry points whose hook does more than fail: it substitutes a +// pointer, rewrites a completion, or clamps a transfer. +#define COROSIO_FAULT_WIN_MANUAL(X) \ + X(recv) X(send) X(WSARecv) X(WSASend) X(ReadFile) X(WriteFile) \ + X(WSAIoctl) X(GetQueuedCompletionStatus) X(GetProcAddress) \ + X(FreeAddrInfoExW) X(signal) + +#define COROSIO_FAULT_WIN_ID(name, ret, failval, cc, params, args) h_##name, +#define COROSIO_FAULT_WIN_ID1(name) h_##name, + +enum hook_id +{ + COROSIO_FAULT_WIN_SIMPLE(COROSIO_FAULT_WIN_ID) + COROSIO_FAULT_WIN_MANUAL(COROSIO_FAULT_WIN_ID1) + hook_count +}; + +// Filled in from the first module patched; every hook forwards through +// it. Kept out of the table so a hook body needs no forward reference +// to the table's type. +proc_t reals[hook_count] = {}; + +#define COROSIO_FAULT_WIN_CALL(name, ret, cc, params) \ + reinterpret_cast(reals[h_##name]) + +#define COROSIO_FAULT_WIN_HOOK(name, ret, failval, cc, params, args) \ + ret cc hooked_##name params \ + { \ + if(should_fail(sys::name)) \ + return failval; \ + return COROSIO_FAULT_WIN_CALL(name, ret, cc, params) args; \ + } + +COROSIO_FAULT_WIN_SIMPLE(COROSIO_FAULT_WIN_HOOK) + +// Copy the prefix of `in` holding at most `count` bytes into `out`. +// Corosio never passes more than a handful of buffers; 64 is a hard +// ceiling checked at runtime. +DWORD truncate_wsabuf(WSABUF const* in, DWORD n, std::size_t count, + WSABUF* out) noexcept +{ + if(n > 64u) + die("fault harness: WSABUF count exceeds 64"); + DWORD m = 0; + for(; m < n && count > 0; ++m) + { + out[m] = in[m]; + if(out[m].len > count) + out[m].len = static_cast(count); + count -= out[m].len; + } + return m; +} + +int WSAAPI hooked_recv(SOCKET s, char* buf, int len, int flags) +{ + if(should_fail(sys::recv)) + return SOCKET_ERROR; + auto const real = COROSIO_FAULT_WIN_CALL(recv, int, WSAAPI, + (SOCKET, char*, int, int)); + std::size_t c = 0; + if(should_shorten(sys::recv, c)) + { + if(c == 0) + return 0; + return real(s, buf, static_cast(c) < len ? static_cast(c) + : len, flags); + } + return real(s, buf, len, flags); +} + +int WSAAPI hooked_send(SOCKET s, char const* buf, int len, int flags) +{ + if(should_fail(sys::send)) + return SOCKET_ERROR; + auto const real = COROSIO_FAULT_WIN_CALL(send, int, WSAAPI, + (SOCKET, char const*, int, int)); + std::size_t c = 0; + if(should_shorten(sys::send, c)) + { + if(c == 0) + return 0; + return real(s, buf, static_cast(c) < len ? static_cast(c) + : len, flags); + } + return real(s, buf, len, flags); +} + +int WSAAPI hooked_WSARecv(SOCKET s, LPWSABUF bufs, DWORD n, LPDWORD got, + LPDWORD flags, LPWSAOVERLAPPED ov, LPWSAOVERLAPPED_COMPLETION_ROUTINE cr) +{ + if(should_fail(sys::WSARecv)) + return SOCKET_ERROR; + auto const real = COROSIO_FAULT_WIN_CALL(WSARecv, int, WSAAPI, + (SOCKET, LPWSABUF, DWORD, LPDWORD, LPDWORD, LPWSAOVERLAPPED, + LPWSAOVERLAPPED_COMPLETION_ROUTINE)); + std::size_t c = 0; + if(should_shorten(sys::WSARecv, c)) + { + // A zero-length receive completes with zero bytes even with + // data waiting, which is what the stream layer reads as EOF. + WSABUF t[64]; + DWORD m = truncate_wsabuf(bufs, n, c, t); + if(m == 0) + { + t[0].len = 0; + t[0].buf = (bufs && n > 0) ? bufs[0].buf : nullptr; + m = 1; + } + return real(s, t, m, got, flags, ov, cr); + } + return real(s, bufs, n, got, flags, ov, cr); +} + +int WSAAPI hooked_WSASend(SOCKET s, LPWSABUF bufs, DWORD n, LPDWORD sent, + DWORD flags, LPWSAOVERLAPPED ov, LPWSAOVERLAPPED_COMPLETION_ROUTINE cr) +{ + if(should_fail(sys::WSASend)) + return SOCKET_ERROR; + auto const real = COROSIO_FAULT_WIN_CALL(WSASend, int, WSAAPI, + (SOCKET, LPWSABUF, DWORD, LPDWORD, DWORD, LPWSAOVERLAPPED, + LPWSAOVERLAPPED_COMPLETION_ROUTINE)); + std::size_t c = 0; + if(should_shorten(sys::WSASend, c)) + { + WSABUF t[64]; + DWORD m = truncate_wsabuf(bufs, n, c, t); + if(m == 0) + { + t[0].len = 0; + t[0].buf = (bufs && n > 0) ? bufs[0].buf : nullptr; + m = 1; + } + return real(s, t, m, sent, flags, ov, cr); + } + return real(s, bufs, n, sent, flags, ov, cr); +} + +BOOL WINAPI hooked_ReadFile(HANDLE h, LPVOID buf, DWORD len, LPDWORD got, + LPOVERLAPPED ov) +{ + if(should_fail(sys::ReadFile)) + return FALSE; + auto const real = COROSIO_FAULT_WIN_CALL(ReadFile, BOOL, WINAPI, + (HANDLE, LPVOID, DWORD, LPDWORD, LPOVERLAPPED)); + std::size_t c = 0; + if(should_shorten(sys::ReadFile, c)) + return real(h, buf, static_cast(c) < len + ? static_cast(c) : len, got, ov); + return real(h, buf, len, got, ov); +} + +BOOL WINAPI hooked_WriteFile(HANDLE h, LPCVOID buf, DWORD len, LPDWORD put, + LPOVERLAPPED ov) +{ + if(should_fail(sys::WriteFile)) + return FALSE; + auto const real = COROSIO_FAULT_WIN_CALL(WriteFile, BOOL, WINAPI, + (HANDLE, LPCVOID, DWORD, LPDWORD, LPOVERLAPPED)); + std::size_t c = 0; + if(should_shorten(sys::WriteFile, c)) + return real(h, buf, static_cast(c) < len + ? static_cast(c) : len, put, ov); + return real(h, buf, len, put, ov); +} + +void WSAAPI hooked_FreeAddrInfoExW(PADDRINFOEXW ai) +{ + // Nothing to report through: the arm only records that the release + // path ran, and swallowing the call would leak. + std::ignore = should_fail(sys::FreeAddrInfoExW); + COROSIO_FAULT_WIN_CALL(FreeAddrInfoExW, void, WSAAPI, (PADDRINFOEXW))(ai); +} + +using sig_handler_t = void(__cdecl*)(int); + +sig_handler_t __cdecl hooked_signal(int sig, sig_handler_t handler) +{ + if(should_fail(sys::signal)) + return SIG_ERR; + return COROSIO_FAULT_WIN_CALL(signal, sig_handler_t, __cdecl, + (int, sig_handler_t))(sig, handler); +} + +// The pointers the OS hands out rather than exports. Written once each +// by the WSAIoctl / GetProcAddress hooks; a second io_context stores +// the same value, so the race is benign. +LPFN_ACCEPTEX real_accept_ex = nullptr; +LPFN_CONNECTEX real_connect_ex = nullptr; +proc_t real_nt_set_information_file = nullptr; +proc_t real_nt_flush_buffers_file_ex = nullptr; + +BOOL PASCAL hooked_AcceptEx(SOCKET listener, SOCKET accepted, PVOID buf, + DWORD recv_len, DWORD local_len, DWORD remote_len, LPDWORD got, + LPOVERLAPPED ov) +{ + if(should_fail(sys::AcceptEx)) + return FALSE; + return real_accept_ex(listener, accepted, buf, recv_len, local_len, + remote_len, got, ov); +} + +BOOL PASCAL hooked_ConnectEx(SOCKET s, sockaddr const* name, int namelen, + PVOID buf, DWORD buf_len, LPDWORD sent, LPOVERLAPPED ov) +{ + if(should_fail(sys::ConnectEx)) + return FALSE; + return real_connect_ex(s, name, namelen, buf, buf_len, sent, ov); +} + +// The library only tests these against zero, so one non-zero NTSTATUS +// is as good as any: the arm's `err` reaches the caller through the +// last-error slot instead. +LONG const status_unsuccessful = static_cast(0xC0000001); + +LONG NTAPI hooked_NtSetInformationFile(HANDLE h, ULONG_PTR* iosb, void* info, + ULONG len, ULONG cls) +{ + if(should_fail(sys::NtSetInformationFile)) + return status_unsuccessful; + return reinterpret_cast(real_nt_set_information_file)(h, iosb, info, len, cls); +} + +LONG NTAPI hooked_NtFlushBuffersFileEx(HANDLE h, ULONG flags, void* params, + ULONG len, void* iosb) +{ + if(should_fail(sys::NtFlushBuffersFileEx)) + return status_unsuccessful; + return reinterpret_cast(real_nt_flush_buffers_file_ex)(h, flags, params, len, iosb); +} + +bool same_guid(void const* lhs, GUID const& rhs) noexcept +{ + return std::memcmp(lhs, &rhs, sizeof(GUID)) == 0; +} + +int WSAAPI hooked_WSAIoctl(SOCKET s, DWORD code, LPVOID in, DWORD inlen, + LPVOID out, DWORD outlen, LPDWORD ret, LPWSAOVERLAPPED ov, + LPWSAOVERLAPPED_COMPLETION_ROUTINE cr) +{ + if(should_fail(sys::WSAIoctl)) + return SOCKET_ERROR; + int const r = COROSIO_FAULT_WIN_CALL(WSAIoctl, int, WSAAPI, + (SOCKET, DWORD, LPVOID, DWORD, LPVOID, DWORD, LPDWORD, + LPWSAOVERLAPPED, LPWSAOVERLAPPED_COMPLETION_ROUTINE))( + s, code, in, inlen, out, outlen, ret, ov, cr); + if(r != 0 || code != SIO_GET_EXTENSION_FUNCTION_POINTER || + !in || inlen < sizeof(GUID) || !out || outlen < sizeof(void*)) + return r; + GUID const accept_ex = WSAID_ACCEPTEX; + GUID const connect_ex = WSAID_CONNECTEX; + if(same_guid(in, accept_ex)) + { + std::memcpy(&real_accept_ex, out, sizeof(real_accept_ex)); + auto const p = &hooked_AcceptEx; + std::memcpy(out, &p, sizeof(p)); + } + else if(same_guid(in, connect_ex)) + { + std::memcpy(&real_connect_ex, out, sizeof(real_connect_ex)); + auto const p = &hooked_ConnectEx; + std::memcpy(out, &p, sizeof(p)); + } + return r; +} + +FARPROC WINAPI hooked_GetProcAddress(HMODULE mod, LPCSTR name) +{ + if(should_fail(sys::GetProcAddress)) + return nullptr; + FARPROC const p = COROSIO_FAULT_WIN_CALL(GetProcAddress, FARPROC, WINAPI, + (HMODULE, LPCSTR))(mod, name); + // An ordinal import carries no string to compare. + if(!p || IS_INTRESOURCE(name)) + return p; + if(std::strcmp(name, "NtSetInformationFile") == 0) + { + real_nt_set_information_file = reinterpret_cast(p); + return reinterpret_cast( + reinterpret_cast(&hooked_NtSetInformationFile)); + } + if(std::strcmp(name, "NtFlushBuffersFileEx") == 0) + { + real_nt_flush_buffers_file_ex = reinterpret_cast(p); + return reinterpret_cast( + reinterpret_cast(&hooked_NtFlushBuffersFileEx)); + } + return p; +} + +// win_scheduler reads the dequeue's outcome as +// `SetLastError(0); r = GetQueuedCompletionStatus(...); +// err = r ? 0 : GetLastError()`, and never looks at OVERLAPPED's own +// status, so failing the call after it succeeded is what puts an error +// on a completed operation. Leaving the overlapped pointer null +// instead reaches the scheduler's own throw path. +BOOL WINAPI hooked_GetQueuedCompletionStatus(HANDLE port, LPDWORD bytes, + PULONG_PTR key, LPOVERLAPPED* ov, DWORD ms) +{ + if(should_fail(sys::GetQueuedCompletionStatus)) + { + if(ov) + *ov = nullptr; + return FALSE; + } + BOOL const r = COROSIO_FAULT_WIN_CALL(GetQueuedCompletionStatus, BOOL, + WINAPI, (HANDLE, LPDWORD, PULONG_PTR, LPOVERLAPPED*, DWORD))( + port, bytes, key, ov, ms); + unsigned long err = 0; + if(r && ov && *ov && completion_should_fail(err)) + { + ::SetLastError(static_cast(err)); + return FALSE; + } + return r; +} + +struct hook_entry +{ + char const* name; + sys which; + proc_t hook; + // Import thunks now pointing at the hook, summed over the modules + // that carry corosio code. + unsigned bound; +}; + +#define COROSIO_FAULT_WIN_ROW(name, ret, failval, cc, params, args) \ + { #name, sys::name, reinterpret_cast(&hooked_##name), 0 }, +#define COROSIO_FAULT_WIN_ROW1(name) \ + { #name, sys::name, reinterpret_cast(&hooked_##name), 0 }, + +hook_entry hooks[] = { + COROSIO_FAULT_WIN_SIMPLE(COROSIO_FAULT_WIN_ROW) + COROSIO_FAULT_WIN_MANUAL(COROSIO_FAULT_WIN_ROW1) +}; + +static_assert(sizeof(hooks) / sizeof(hooks[0]) == hook_count, + "the hook table and the hook ids disagree"); + +// Match by function name alone rather than by (dll, name). A given +// entry point moves between kernel32, KERNELBASE and the +// api-ms-win-core-* forwarders from one toolchain to the next, and the +// CRT's `signal` between ucrtbase, msvcrt and the api-ms-win-crt-* +// forwarders; none of the names here is exported by two unrelated DLLs. +hook_entry* find_hook(char const* name) noexcept +{ + for(auto& h : hooks) + { + if(std::strcmp(h.name, name) == 0) + return &h; + } + return nullptr; +} + +char const* module_name(HMODULE mod) noexcept +{ + static char buf[MAX_PATH + 1]; + if(!::GetModuleFileNameA(mod, buf, MAX_PATH)) + std::snprintf(buf, sizeof(buf), "", + reinterpret_cast(mod)); + return buf; +} + +// True for the Winsock DLL, whose import library is the only one here +// that binds by ordinal. +bool name_is_ws2_32(char const* dll) noexcept +{ + static char const needle[] = "ws2_32"; + for(std::size_t i = 0; i < sizeof(needle) - 1; ++i) + { + char c = dll[i]; + if(c >= 'A' && c <= 'Z') + c = static_cast(c - 'A' + 'a'); + if(c != needle[i]) + return false; + } + return true; +} + +// ws2_32.lib binds the Winsock 1.1 entry points by ordinal rather than +// by name, so their thunks carry no string for the name walk to match. +// The ordinals have been fixed since NT 4 — that is what an ordinal +// import is for — and the ones this table omits are entry points the +// harness does not hook. +char const* winsock_ordinal_name(char const* dll, unsigned ordinal) noexcept +{ + if(!name_is_ws2_32(dll)) + return nullptr; + switch(ordinal) + { + case 1: return "accept"; + case 2: return "bind"; + case 3: return "closesocket"; + case 4: return "connect"; + case 5: return "getpeername"; + case 6: return "getsockname"; + case 7: return "getsockopt"; + case 10: return "ioctlsocket"; + case 13: return "listen"; + case 16: return "recv"; + case 19: return "send"; + case 21: return "setsockopt"; + case 22: return "shutdown"; + case 23: return "socket"; + case 115: return "WSAStartup"; + case 116: return "WSACleanup"; + default: return nullptr; + } +} + +// Walk one module's import descriptors, handing every import the +// harness can identify to `f` along with the thunk that holds its +// resolved address. +template +void for_each_import(HMODULE mod, F&& f) noexcept +{ + auto* const base = reinterpret_cast(mod); + auto const* dos = reinterpret_cast(base); + if(dos->e_magic != IMAGE_DOS_SIGNATURE) + return; + auto const* nt = + reinterpret_cast(base + dos->e_lfanew); + if(nt->Signature != IMAGE_NT_SIGNATURE) + return; + auto const& dir = + nt->OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_IMPORT]; + if(dir.VirtualAddress == 0) + return; + for(auto const* imp = reinterpret_cast( + base + dir.VirtualAddress); + imp->Name != 0; ++imp) + { + // Without the lookup table the thunks hold addresses, not + // names, and nothing here can be identified. + if(imp->OriginalFirstThunk == 0) + continue; + char const* const dll = + reinterpret_cast(base + imp->Name); + auto const* names = reinterpret_cast( + base + imp->OriginalFirstThunk); + auto* thunk = reinterpret_cast( + base + imp->FirstThunk); + for(; names->u1.AddressOfData != 0; ++names, ++thunk) + { + char const* name = nullptr; + if(IMAGE_SNAP_BY_ORDINAL(names->u1.Ordinal)) + { + name = winsock_ordinal_name(dll, static_cast( + IMAGE_ORDINAL(names->u1.Ordinal))); + } + else + { + auto const* by_name = + reinterpret_cast( + base + names->u1.AddressOfData); + name = reinterpret_cast(by_name->Name); + } + if(name) + f(name, *thunk); + } + } +} + +void patch_module(HMODULE mod) noexcept +{ + for_each_import(mod, [](char const* name, IMAGE_THUNK_DATA& thunk) + { + hook_entry* h = find_hook(name); + if(!h) + return; + auto const idx = static_cast(h - hooks); + if(!reals[idx]) + reals[idx] = reinterpret_cast(thunk.u1.Function); + DWORD old = 0; + if(!::VirtualProtect(&thunk.u1.Function, sizeof(void*), + PAGE_READWRITE, &old)) + { + char msg[192]; + std::snprintf(msg, sizeof(msg), + "fault harness: the import thunk for %s refused to become " + "writable", name); + die(msg); + } + thunk.u1.Function = reinterpret_cast(h->hook); + std::ignore = ::VirtualProtect(&thunk.u1.Function, sizeof(void*), + old, &old); + }); +} + +// Re-read the memory as it stands rather than trusting what the patch +// pass believed it wrote: a thunk that silently refused the store, or +// a second thunk for the same name that the walk skipped, would leave +// the arm dead with nothing to say so. +void verify_module(HMODULE mod, bool& ok) noexcept +{ + for_each_import(mod, [&](char const* name, IMAGE_THUNK_DATA& thunk) + { + hook_entry* h = find_hook(name); + if(!h) + return; + if(thunk.u1.Function == reinterpret_cast(h->hook)) + { + ++h->bound; + return; + } + std::fprintf(stderr, "fault harness: %s in %s is bound to %p, hook " + "is %p\n", name, module_name(mod), + reinterpret_cast(thunk.u1.Function), + reinterpret_cast(h->hook)); + ok = false; + }); +} + +// Case-insensitive substring match, hand-rolled because the CRT spells +// its wide comparison differently on every toolchain. +bool name_holds_corosio(wchar_t const* name) noexcept +{ + static wchar_t const needle[] = L"corosio"; + for(; *name; ++name) + { + std::size_t i = 0; + for(; needle[i]; ++i) + { + wchar_t c = name[i]; + if(c >= L'A' && c <= L'Z') + c = static_cast(c - L'A' + L'a'); + if(c != needle[i]) + break; + } + if(!needle[i]) + return true; + } + return false; +} + +// Every module that can hold corosio code. The IOCP backend is header +// inline, so a shared build calls the OS from two import tables: the +// DLL's, reached by plain io_context/tcp_socket through the backend's +// virtual dispatch, and the executable's, reached by the native_* +// wrappers instantiated in the test. +std::size_t collect_modules(HMODULE* out, std::size_t cap) noexcept +{ + std::size_t n = 0; + auto add = [&](HMODULE m) + { + if(!m) + return; + for(std::size_t i = 0; i < n; ++i) + { + if(out[i] == m) + return; + } + if(n < cap) + out[n++] = m; + }; + add(::GetModuleHandleW(nullptr)); + + // host_name is an ordinary exported corosio function, so the module + // owning its address is the library wherever it ended up. This + // needs no name and cannot be fooled by an unrelated module. + HMODULE lib = nullptr; + auto const* addr = + reinterpret_cast(&boost::corosio::host_name); + if(::GetModuleHandleExW( + GET_MODULE_HANDLE_EX_FLAG_FROM_ADDRESS | + GET_MODULE_HANDLE_EX_FLAG_UNCHANGED_REFCOUNT, + static_cast(addr), &lib)) + add(lib); + + // A corosio satellite (a TLS backend, say) would carry inline + // backend code of its own; a snapshot is the only way to see it. + HANDLE const snap = ::CreateToolhelp32Snapshot(TH32CS_SNAPMODULE, 0); + if(snap != INVALID_HANDLE_VALUE) + { + MODULEENTRY32W me{}; + me.dwSize = static_cast(sizeof(me)); + if(::Module32FirstW(snap, &me)) + { + do + { + if(name_holds_corosio(me.szModule)) + add(me.hModule); + } + while(::Module32NextW(snap, &me)); + } + ::CloseHandle(snap); + } + return n; +} + +bool shared_build() noexcept +{ + HMODULE lib = nullptr; + auto const* addr = + reinterpret_cast(&boost::corosio::host_name); + if(!::GetModuleHandleExW( + GET_MODULE_HANDLE_EX_FLAG_FROM_ADDRESS | + GET_MODULE_HANDLE_EX_FLAG_UNCHANGED_REFCOUNT, + static_cast(addr), &lib)) + return false; + return lib != ::GetModuleHandleW(nullptr); +} + +int const installed = [] +{ + HMODULE mods[8]; + std::size_t const n = collect_modules(mods, sizeof(mods) / sizeof(mods[0])); + for(std::size_t i = 0; i < n; ++i) + patch_module(mods[i]); + + bool ok = true; + for(std::size_t i = 0; i < n; ++i) + verify_module(mods[i], ok); + + for(auto const& h : hooks) + { + if(h.bound != 0) + continue; + // Reported rather than fatal: what a given toolchain imports + // varies, and a name nothing in this program references has no + // thunk to patch. The self-tests skip a hook that is not live, + // so an unreported drift cannot masquerade as coverage. + std::fprintf(stderr, + "fault harness: %s is imported by no corosio module\n", h.name); + } + if(!ok) + die("fault harness: the import tables were not patched"); + return 0; +}(); + +} // namespace + +bool corosio_is_shared() noexcept +{ + return shared_build(); +} + +bool hook_is_live(sys which) noexcept +{ + switch(which) + { + // Substituted through the pointer WSAIoctl hands out. + case sys::AcceptEx: + case sys::ConnectEx: + return hooks[h_WSAIoctl].bound != 0; + // Substituted through the pointer GetProcAddress hands out. + case sys::NtSetInformationFile: + case sys::NtFlushBuffersFileEx: + return hooks[h_GetProcAddress].bound != 0; + default: + break; + } + for(auto const& h : hooks) + { + if(h.which == which) + return h.bound != 0; + } + return false; +} + +} // boost::corosio::test::fault + +#endif diff --git a/test/unit/fault/iocp_dissociate_faults.cpp b/test/unit/fault/iocp_dissociate_faults.cpp new file mode 100644 index 000000000..768b81a46 --- /dev/null +++ b/test/unit/fault/iocp_dissociate_faults.cpp @@ -0,0 +1,87 @@ +// +// Copyright (c) 2026 Steve Gerbino +// +// Distributed under the Boost Software License, Version 1.0. (See accompanying +// file LICENSE_1_0.txt or copy at http://www.boost.org/LICENSE_1_0.txt) +// +// Official repository: https://github.com/cppalliance/corosio +// + +#include "fault.hpp" +#include "fault_test_utils.hpp" +#include "context.hpp" +#include "test_suite.hpp" +#include "test_utils.hpp" + +#include +#include + +#include + +#if BOOST_COROSIO_HAS_IOCP + +namespace boost::corosio::test::fault { + +/* The lookup behind dissociate_from_iocp, which happens once. + + The NtSetInformationFile pointer lives in a function-local static, + so the module handle it is resolved through is asked for on the + first release() the process makes and never again. That is why this + is a suite of its own: iocp_faults releases a socket too, and + whichever ran first would leave the other testing something it did + not mean to. + + The suite name sorts before every other fault suite, so a + single-process run of the whole executable still meets this test + with the pointer unresolved. Under CTest and b2 each suite is its + own process and the ordering does not arise. +*/ +struct iocp_dissociate_faults +{ + void testReleaseWithoutNtEntryPoint() + { + if(!hook_is_live(sys::GetModuleHandleW)) + { + skip_dead_hook("GetModuleHandleW"); + return; + } + io_context ioc(iocp); + tcp_socket s(ioc); + BOOST_TEST(!s.open(tcp::v4())); + native_handle_type h{}; + { + // ntdll refuses to answer, so there is no entry point to + // cache and severing the association is never attempted. + // Armed immediately before the call because nothing else + // on this thread asks for a module handle in between. + fault_scope f(sys::GetModuleHandleW, ERROR_MOD_NOT_FOUND); + h = s.release(); + BOOST_TEST(f.fired()); + } + // Severing the association is best effort: the caller gets a + // working socket whether or not it happened. + BOOST_TEST(!s.is_open()); + BOOST_TEST(native_socket_valid(h)); + close_native_socket(h); + + // The lookup ran once for the process, so a second release + // asks nothing and still hands back its socket. + tcp_socket t(ioc); + BOOST_TEST(!t.open(tcp::v4())); + auto const h2 = t.release(); + BOOST_TEST(!t.is_open()); + BOOST_TEST(native_socket_valid(h2)); + close_native_socket(h2); + } + + void run() + { + testReleaseWithoutNtEntryPoint(); + } +}; + +TEST_SUITE(iocp_dissociate_faults, "boost.corosio.fault.dissociate"); + +} // boost::corosio::test::fault + +#endif diff --git a/test/unit/fault/iocp_faults.cpp b/test/unit/fault/iocp_faults.cpp new file mode 100644 index 000000000..d87a10a7f --- /dev/null +++ b/test/unit/fault/iocp_faults.cpp @@ -0,0 +1,2017 @@ +// +// Copyright (c) 2026 Steve Gerbino +// +// Distributed under the Boost Software License, Version 1.0. (See accompanying +// file LICENSE_1_0.txt or copy at http://www.boost.org/LICENSE_1_0.txt) +// +// Official repository: https://github.com/cppalliance/corosio +// + +#include "fault.hpp" +#include "fault_test_utils.hpp" +#include "context.hpp" +#include "test_suite.hpp" +#include "test_utils.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +#if BOOST_COROSIO_HAS_IOCP + +// Some Windows SDKs still ship winsock2.h without the AF_UNIX name the +// library's own sources spell out the same way. +#ifndef AF_UNIX +#define AF_UNIX 1 +#endif + +namespace boost::corosio::test::fault { + +namespace { + +endpoint loopback() +{ + return endpoint(ipv4_address::loopback(), 0); +} + +// Reach win_scheduler::post(continuation&) without a heap allocation, +// which is the overload whose PostQueuedCompletionStatus failure has a +// fallback of its own. +struct post_awaitable +{ + capy::continuation* cont; + + bool await_ready() const noexcept { return false; } + + void await_suspend( + std::coroutine_handle<> h, capy::io_env const* env) noexcept + { + cont->h = h; + env->executor.post(*cont); + } + + void await_resume() const noexcept {} +}; + +// One entry point make_wakeup_pair calls, the code to fail it with, +// and which of its calls to that entry point to hit. +struct wakeup_arm +{ + sys which; + int err; + unsigned nth = 1; +}; + +// An operation the wait reactor can no longer complete parks forever, +// and a run loop that never returns reads as a job timeout on CI +// rather than as a failure. This turns that into an assertion. +capy::task<> stop_guard(io_context& ioc, bool& expired) +{ + std::ignore = co_await corosio::delay(std::chrono::seconds(2)); + expired = true; + ioc.stop(); +} + +// Wait until the provider reports the error condition on `s`, and say +// whether it ever did. The wait reactor keys off the same bits, so a +// round that never carried them is a premise the caller did not have +// rather than a failure of the code under test. `events` is what the +// reactor would have asked for, since whether the error bits come back +// at all is the provider's answer to that question and not to another. +bool wait_for_poll_error(tcp_socket& s, SHORT events) +{ + constexpr SHORT err_bits = POLLERR | POLLHUP | POLLNVAL; + WSAPOLLFD pfd{}; + pfd.fd = static_cast(s.native_handle()); + pfd.events = events; + for(int i = 0; i < 200; ++i) + { + pfd.revents = 0; + if(::WSAPoll(&pfd, 1, 10) > 0 && (pfd.revents & err_bits) != 0) + return true; + } + return false; +} + +// Say what the provider makes of `s` for each mask an error wait +// could register with. Only reached where the mask the reactor uses +// carried no error condition, so a round that has to choose another +// one is not blind. +void report_poll_masks(tcp_socket& s) +{ + SHORT const masks[] = {0, POLLRDNORM, POLLWRNORM, POLLRDBAND}; + for(SHORT m : masks) + { + WSAPOLLFD pfd{static_cast(s.native_handle()), m, 0}; + ::WSASetLastError(0); + int const n = ::WSAPoll(&pfd, 1, 0); + std::fprintf(stderr, + "fault harness: events %d -> %d, revents %d, error %d\n", + static_cast(m), n, static_cast(pfd.revents), + ::WSAGetLastError()); + } +} + +// Run into the reset, so the socket has a chance to record it where +// SO_ERROR will report it. A connection the provider has flagged +// through the poll does not necessarily have an error waiting there +// for anyone who has not touched it since. +void touch_after_reset(tcp_socket& s) +{ + auto const fd = static_cast(s.native_handle()); + char byte = '!'; + for(int i = 0; i < 2; ++i) + std::ignore = ::send(fd, &byte, 1, 0); +} + +} // namespace + +/* Faults on the IOCP backend itself: the scheduler, its completion + dequeue, the socket services and the auxiliary wait reactor. The + file, resolver, host_name and signal paths live in win_faults.cpp. +*/ +struct iocp_faults +{ + void testSchedulerConstructFails() + { + // Winsock is started once per process and released when the + // last service goes, so this only fires while no io_context is + // alive. The resolver service that starts it is built inside + // the scheduler's constructor, after the completion port: the + // port is the scheduler's to release on the way out. + // + // A lost port is exactly one handle per attempt, so the run is + // twice as long as the growth it allows: sixteen attempts have + // to stay under the eight handles of ambient drift the default + // shape tolerates. + expect_no_handle_leak([]{ + fault_scope f(sys::WSAStartup, WSAEAFNOSUPPORT); + expect_system_error([]{ io_context ioc(iocp); }, + std::errc::address_family_not_supported); + BOOST_TEST(f.fired()); + }, 16, 8); + { + // The scheduler's own port: CreateIoCompletionPort with + // INVALID_HANDLE_VALUE. + fault_scope f(sys::CreateIoCompletionPort, + ERROR_INVALID_PARAMETER); + expect_system_error([]{ io_context ioc(iocp); }, + win_err(ERROR_INVALID_PARAMETER)); + BOOST_TEST(f.fired()); + } + } + + void testTimerCreationFails() + { + // A null waitable timer is never reported: start() returns + // without a thread and update_timeout() does nothing, so + // timers simply never fire. + fault_scope f(sys::CreateWaitableTimerW, ERROR_NOT_ENOUGH_MEMORY); + io_context ioc(iocp); + BOOST_TEST(f.fired()); + + bool fired_timer = false; + auto body = [&]() -> capy::task<> + { + std::ignore = co_await corosio::delay( + std::chrono::milliseconds(1)); + fired_timer = true; + }; + capy::run_async(ioc.get_executor())(body()); + // Nothing else can end the loop, so the run is bounded by the + // stop rather than by the timer. + ioc.stop(); + ioc.run(); + // A smoke check rather than a proof: the stop latches before + // run() begins, so the loop would have returned early even with + // a working timer. What the arm proves is that construction + // swallowed the failure; this only shows nothing fired anyway. + BOOST_TEST(!fired_timer); + } + + void testTimerThreadWaitFails() + { + // The wait is the first thing the timer thread does, but the + // thread starts asynchronously: destroying the context right + // away can set the shutdown flag before the loop is entered + // and the wait never happens. + // So the context is held until the arm reports, bounded so a + // wait that never comes fails rather than hangs. + // + // WaitForSingleObject is not ours alone: the CRT and the + // standard library reach it through the same import thunk -- + // std::thread::join at the end of this scope certainly does, + // and a first-use initialization anywhere might. Any of those + // would spend the process-wide arm on the caller and leave + // this test passing without the timer thread ever having been + // faulted. A thread-local arm shadows the process-wide one for + // the thread that holds it, and an arm whose nth is out of + // reach never fires, so `shield` turns every wait on this + // thread into a plain forward and leaves the process-wide arm + // for the timer thread, the only other one this context + // starts: the blocking-I/O pool holds no worker until + // something posts blocking work, and this test posts none. + fault_scope shield(sys::WaitForSingleObject, ERROR_INVALID_HANDLE, + (std::numeric_limits::max)()); + fault_scope f(sys::WaitForSingleObject, ERROR_INVALID_HANDLE, 1, + any_thread); + { + io_context ioc(iocp); + for(int i = 0; i < 2000 && !f.fired(); ++i) + std::this_thread::sleep_for(std::chrono::milliseconds(1)); + } + BOOST_TEST(f.fired()); + BOOST_TEST(!shield.fired()); + } + + void testStopPostFails() + { + io_context ioc(iocp); + // The shutdown packet is the only thing that wakes a blocked + // run(), so a failed post is fatal rather than reported. + fault_scope f(sys::PostQueuedCompletionStatus, + ERROR_NO_SYSTEM_RESOURCES); + expect_system_error([&]{ ioc.stop(); }, + win_err(ERROR_NO_SYSTEM_RESOURCES)); + BOOST_TEST(f.fired()); + } + + void testPostFallbackRuns() + { + io_context ioc(iocp); + capy::continuation cont{}; + bool ran = false; + bool fired = false; + auto body = [&]() -> capy::task<> + { + { + // A failed post falls back to the allocating handle + // path, so the work still runs. + fault_scope f(sys::PostQueuedCompletionStatus, + ERROR_NO_SYSTEM_RESOURCES); + co_await post_awaitable{&cont}; + fired = f.fired(); + } + ran = true; + }; + capy::run_async(ioc.get_executor())(body()); + ioc.run(); + BOOST_TEST(fired); + BOOST_TEST(ran); + } + + void testRunLoopDequeueFails() + { + io_context ioc(iocp); + // A dequeue that reports failure with no OVERLAPPED is not a + // timeout, so the run loop throws. + fault_scope f(sys::GetQueuedCompletionStatus, ERROR_INVALID_HANDLE); + auto body = [&]() -> capy::task<> + { + std::ignore = co_await corosio::delay( + std::chrono::milliseconds(1)); + }; + capy::run_async(ioc.get_executor())(body()); + expect_system_error([&]{ ioc.run(); }, + win_err(ERROR_INVALID_HANDLE)); + BOOST_TEST(f.fired()); + } + + void testTcpOpenFails() + { + io_context ioc(iocp); + { + tcp_socket s(ioc); + fault_scope f(sys::WSASocketW, WSAEAFNOSUPPORT); + auto ec = s.open(tcp::v4()); + BOOST_TEST(f.fired()); + BOOST_TEST(ec == std::errc::address_family_not_supported); + BOOST_TEST(!s.is_open()); + } + // The socket exists when the association fails, so the + // failure path owns closing it. + expect_no_handle_leak([&]{ + tcp_socket s(ioc); + fault_scope f(sys::CreateIoCompletionPort, + ERROR_INVALID_PARAMETER); + auto ec = s.open(tcp::v4()); + BOOST_TEST(f.fired()); + BOOST_TEST(ec == win_err(ERROR_INVALID_PARAMETER)); + BOOST_TEST(!s.is_open()); + }); + } + + void testTcpAssignFails() + { + io_context ioc(iocp); + auto h = make_native_socket(AF_INET, SOCK_STREAM); + make_native_adoptable(h); + expect_no_handle_leak([&]{ + { + // SO_PROTOCOL_INFOW is how adoption learns the + // family and type. + tcp_socket s(ioc); + fault_scope f(sys::getsockopt, WSAENOTSOCK); + auto ec = s.assign(h); + BOOST_TEST(f.fired()); + BOOST_TEST(ec == std::errc::not_a_socket); + BOOST_TEST(!s.is_open()); + } + { + tcp_socket s(ioc); + fault_scope f(sys::CreateIoCompletionPort, + ERROR_INVALID_PARAMETER); + auto ec = s.assign(h); + BOOST_TEST(f.fired()); + BOOST_TEST(ec == win_err(ERROR_INVALID_PARAMETER)); + BOOST_TEST(!s.is_open()); + } + }); + // A rejected adoption leaves the socket with the caller. + BOOST_TEST(native_socket_valid(h)); + close_native_socket(h); + } + + void testTcpBindFails() + { + io_context ioc(iocp); + tcp_socket s(ioc); + BOOST_TEST(!s.open(tcp::v4())); + fault_scope f(sys::bind, WSAEADDRINUSE); + auto ec = s.bind(loopback()); + BOOST_TEST(f.fired()); + BOOST_TEST(ec == std::errc::address_in_use); + BOOST_TEST(s.is_open()); + } + + void testTcpOptionsFail() + { + io_context ioc(iocp); + tcp_socket s(ioc); + BOOST_TEST(!s.open(tcp::v4())); + { + fault_scope f(sys::setsockopt, WSAENOTSOCK); + expect_system_error( + [&]{ s.set_option(socket_option::reuse_address(true)); }, + std::errc::not_a_socket); + BOOST_TEST(f.fired()); + BOOST_TEST(s.is_open()); + } + { + fault_scope f(sys::getsockopt, WSAENOTSOCK); + expect_system_error( + [&]{ + std::ignore = s.get_option(); + }, + std::errc::not_a_socket); + BOOST_TEST(f.fired()); + BOOST_TEST(s.is_open()); + } + { + fault_scope f(sys::shutdown, WSAENOTSOCK); + BOOST_TEST(s.shutdown(tcp_socket::shutdown_both) == + std::errc::not_a_socket); + BOOST_TEST(f.fired()); + } + } + + void testTcpReleaseIgnoresDissociate() + { + if(!hook_is_live(sys::NtSetInformationFile)) + { + skip_dead_hook("NtSetInformationFile"); + return; + } + io_context ioc(iocp); + tcp_socket s(ioc); + BOOST_TEST(!s.open(tcp::v4())); + // Severing the port association is best effort: the caller + // gets a working socket either way. + fault_scope f(sys::NtSetInformationFile, ERROR_INVALID_PARAMETER); + auto h = s.release(); + BOOST_TEST(f.fired()); + BOOST_TEST(!s.is_open()); + BOOST_TEST(native_socket_valid(h)); + close_native_socket(h); + } + + void testTcpExtensionPointerMissing() + { + // load_extension_functions runs once, from the tcp service's + // constructor, so the arm has to precede the io_context. + fault_scope f(sys::WSAIoctl, WSAEOPNOTSUPP); + io_context ioc(iocp); + BOOST_TEST(f.fired()); + + tcp_acceptor acc(ioc, loopback()); + auto port = acc.local_endpoint().port(); + tcp_socket s(ioc); + BOOST_TEST(!s.open(tcp::v4())); + std::error_code cec; + auto body = [&]() -> capy::task<> + { + auto [ec] = co_await s.connect( + endpoint(ipv4_address::loopback(), port)); + cec = ec; + }; + capy::run_async(ioc.get_executor())(body()); + ioc.run(); + BOOST_TEST(cec == std::errc::operation_not_supported); + } + + void testTcpConnectFails() + { + io_context ioc(iocp); + tcp_acceptor acc(ioc, loopback()); + auto const ep = endpoint( + ipv4_address::loopback(), acc.local_endpoint().port()); + std::error_code bec, sec, cec; + bool expired = false; + auto body = [&]() -> capy::task<> + { + { + // ConnectEx needs a bound socket, so an unbound one + // is bound to the wildcard first. + tcp_socket s(ioc); + BOOST_TEST(!s.open(tcp::v4())); + fault_scope f(sys::bind, WSAEADDRNOTAVAIL); + auto [ec] = co_await s.connect(ep); + bec = ec; + BOOST_TEST(f.fired()); + } + if(hook_is_live(sys::ConnectEx)) + { + tcp_socket s(ioc); + BOOST_TEST(!s.open(tcp::v4())); + fault_scope f(sys::ConnectEx, WSAECONNREFUSED); + auto [ec] = co_await s.connect(ep); + sec = ec; + BOOST_TEST(f.fired()); + } + else + { + skip_dead_hook("ConnectEx"); + } + { + // The kernel result of a queued connect only exists + // on the completion. + tcp_socket s(ioc); + BOOST_TEST(!s.open(tcp::v4())); + completion_fault_scope q(ERROR_CONNECTION_REFUSED); + auto [ec] = co_await s.connect(ep); + cec = ec; + BOOST_TEST(q.fired()); + } + ioc.stop(); + }; + capy::run_async(ioc.get_executor())(body()); + capy::run_async(ioc.get_executor())(stop_guard(ioc, expired)); + ioc.run(); + BOOST_TEST(!expired); + BOOST_TEST(bec == std::errc::address_not_available); + if(hook_is_live(sys::ConnectEx)) + BOOST_TEST(sec == std::errc::connection_refused); + BOOST_TEST(cec == std::errc::connection_refused); + } + + void testTcpReadWriteFails() + { + io_context ioc(iocp); + auto pair = make_socket_pair(ioc); + auto& a = pair.first; + auto& b = pair.second; + char buf[8] = {}; + char out[4] = "abc"; + std::error_code rec, wec, rcec, wcec, eec, wtec; + std::size_t en = 99; + bool expired = false; + auto body = [&]() -> capy::task<> + { + { + fault_scope f(sys::WSARecv, WSAENOTSOCK); + auto [ec, n] = co_await a.read_some( + capy::mutable_buffer(buf, sizeof(buf))); + std::ignore = n; + rec = ec; + BOOST_TEST(f.fired()); + } + { + fault_scope f(sys::WSASend, WSAENOTSOCK); + auto [ec, n] = co_await a.write_some( + capy::const_buffer(out, 3)); + std::ignore = n; + wec = ec; + BOOST_TEST(f.fired()); + } + { + // A wait for readability is a zero-byte WSARecv. + fault_scope f(sys::WSARecv, WSAENOTSOCK); + auto [ec] = co_await a.wait(wait_type::read); + wtec = ec; + BOOST_TEST(f.fired()); + } + { + auto [ec, n] = co_await b.write_some( + capy::const_buffer(out, 3)); + std::ignore = n; + BOOST_TEST(!ec); + } + { + // A remote reset reaches a pending read as + // ERROR_NETNAME_DELETED, which off the accept path + // means connection_reset. + completion_fault_scope q(ERROR_NETNAME_DELETED); + auto [ec, n] = co_await a.read_some( + capy::mutable_buffer(buf, sizeof(buf))); + std::ignore = n; + rcec = ec; + BOOST_TEST(q.fired()); + } + { + completion_fault_scope q(ERROR_NETNAME_DELETED); + auto [ec, n] = co_await a.write_some( + capy::const_buffer(out, 3)); + std::ignore = n; + wcec = ec; + BOOST_TEST(q.fired()); + } + { + auto [ec, n] = co_await b.write_some( + capy::const_buffer(out, 3)); + std::ignore = n; + BOOST_TEST(!ec); + } + { + // A receive shortened to nothing completes with zero + // bytes even with data waiting, which the stream + // contract reads as end of file. + auto f = fault_scope::returning(sys::WSARecv, 0); + auto [ec, n] = co_await a.read_some( + capy::mutable_buffer(buf, sizeof(buf))); + eec = ec; + en = n; + BOOST_TEST(f.fired()); + } + a.cancel(); + b.cancel(); + ioc.stop(); + }; + capy::run_async(ioc.get_executor())(body()); + capy::run_async(ioc.get_executor())(stop_guard(ioc, expired)); + ioc.run(); + BOOST_TEST(!expired); + BOOST_TEST(rec == std::errc::not_a_socket); + BOOST_TEST(wec == std::errc::not_a_socket); + BOOST_TEST(wtec == std::errc::not_a_socket); + BOOST_TEST(rcec == std::errc::connection_reset); + BOOST_TEST(wcec == std::errc::connection_reset); + BOOST_TEST(eec == capy::error::eof); + BOOST_TEST_EQ(en, 0u); + } + + void testAcceptorOpenFails() + { + io_context ioc(iocp); + { + tcp_acceptor acc(ioc); + fault_scope f(sys::WSASocketW, WSAEAFNOSUPPORT); + auto ec = acc.open(); + BOOST_TEST(f.fired()); + BOOST_TEST(ec == std::errc::address_family_not_supported); + BOOST_TEST(!acc.is_open()); + } + expect_no_handle_leak([&]{ + tcp_acceptor acc(ioc); + fault_scope f(sys::CreateIoCompletionPort, + ERROR_INVALID_PARAMETER); + auto ec = acc.open(); + BOOST_TEST(f.fired()); + BOOST_TEST(ec == win_err(ERROR_INVALID_PARAMETER)); + BOOST_TEST(!acc.is_open()); + }); + { + tcp_acceptor acc(ioc); + BOOST_TEST(!acc.open()); + fault_scope f(sys::bind, WSAEADDRINUSE); + BOOST_TEST(acc.bind(loopback()) == std::errc::address_in_use); + BOOST_TEST(f.fired()); + } + { + tcp_acceptor acc(ioc); + BOOST_TEST(!acc.open()); + BOOST_TEST(!acc.bind(loopback())); + fault_scope f(sys::listen, WSAEOPNOTSUPP); + BOOST_TEST(acc.listen() == std::errc::operation_not_supported); + BOOST_TEST(f.fired()); + } + { + // The convenience constructor reports the same codes by + // throwing, once per leg it walks. + fault_scope f(sys::WSASocketW, WSAEAFNOSUPPORT); + expect_system_error([&]{ tcp_acceptor acc(ioc, loopback()); }, + std::errc::address_family_not_supported); + BOOST_TEST(f.fired()); + } + { + fault_scope f(sys::listen, WSAEOPNOTSUPP); + expect_system_error([&]{ tcp_acceptor acc(ioc, loopback()); }, + std::errc::operation_not_supported); + BOOST_TEST(f.fired()); + } + } + + void testAcceptFails() + { + io_context ioc(iocp); + tcp_acceptor acc(ioc, loopback()); + auto const ep = endpoint( + ipv4_address::loopback(), acc.local_endpoint().port()); + std::error_code sockec, portec, syncec, compec, waitec; + bool expired = false; + auto body = [&]() -> capy::task<> + { + tcp_socket server(ioc); + { + // Writability carries no meaning for a listening + // socket and reaches no syscall. + auto [ec] = co_await acc.wait(wait_type::write); + waitec = ec; + } + { + tcp_socket client(ioc); + auto [cec] = co_await client.connect(ep); + BOOST_TEST(!cec); + fault_scope f(sys::WSASocketW, WSAEAFNOSUPPORT); + auto [ec] = co_await acc.accept(server); + sockec = ec; + BOOST_TEST(f.fired()); + BOOST_TEST(!server.is_open()); + client.cancel(); + client.close(); + } + { + tcp_socket client(ioc); + auto [cec] = co_await client.connect(ep); + BOOST_TEST(!cec); + // No handle-count assertion here: the client socket + // this accept needs is created inside the same window, + // so the count carries more than the accept's own + // bookkeeping. + fault_scope f(sys::CreateIoCompletionPort, + ERROR_INVALID_PARAMETER); + auto [ec] = co_await acc.accept(server); + portec = ec; + BOOST_TEST(f.fired()); + BOOST_TEST(!server.is_open()); + client.cancel(); + client.close(); + } + if(hook_is_live(sys::AcceptEx)) + { + tcp_socket client(ioc); + auto [cec] = co_await client.connect(ep); + BOOST_TEST(!cec); + fault_scope f(sys::AcceptEx, WSAENOTSOCK); + auto [ec] = co_await acc.accept(server); + syncec = ec; + BOOST_TEST(f.fired()); + BOOST_TEST(!server.is_open()); + client.cancel(); + client.close(); + } + else + { + skip_dead_hook("AcceptEx"); + } + { + tcp_socket client(ioc); + auto [cec] = co_await client.connect(ep); + BOOST_TEST(!cec); + // On the accept path ERROR_NETNAME_DELETED means the + // half-open connection died, not a reset stream. + completion_fault_scope q(ERROR_NETNAME_DELETED); + auto [ec] = co_await acc.accept(server); + compec = ec; + BOOST_TEST(q.fired()); + BOOST_TEST(!server.is_open()); + client.cancel(); + client.close(); + } + ioc.stop(); + }; + capy::run_async(ioc.get_executor())(body()); + capy::run_async(ioc.get_executor())(stop_guard(ioc, expired)); + ioc.run(); + BOOST_TEST(!expired); + BOOST_TEST(waitec == std::errc::operation_not_supported); + BOOST_TEST(sockec == std::errc::address_family_not_supported); + BOOST_TEST(portec == win_err(ERROR_INVALID_PARAMETER)); + if(hook_is_live(sys::AcceptEx)) + BOOST_TEST(syncec == std::errc::not_a_socket); + BOOST_TEST(compec == std::errc::connection_aborted); + } + + void testUdpSetupFails() + { + io_context ioc(iocp); + { + udp_socket u(ioc); + fault_scope f(sys::WSASocketW, WSAEAFNOSUPPORT); + auto ec = u.open(udp::v4()); + BOOST_TEST(f.fired()); + BOOST_TEST(ec == std::errc::address_family_not_supported); + BOOST_TEST(!u.is_open()); + } + expect_no_handle_leak([&]{ + udp_socket u(ioc); + fault_scope f(sys::CreateIoCompletionPort, + ERROR_INVALID_PARAMETER); + auto ec = u.open(udp::v4()); + BOOST_TEST(f.fired()); + BOOST_TEST(ec == win_err(ERROR_INVALID_PARAMETER)); + BOOST_TEST(!u.is_open()); + }); + { + udp_socket u(ioc); + BOOST_TEST(!u.open(udp::v4())); + fault_scope f(sys::bind, WSAEADDRINUSE); + BOOST_TEST(u.bind(loopback()) == std::errc::address_in_use); + BOOST_TEST(f.fired()); + BOOST_TEST(u.is_open()); + } + { + auto h = make_native_socket(AF_INET, SOCK_DGRAM); + make_native_adoptable(h); + expect_no_handle_leak([&]{ + { + udp_socket u(ioc); + fault_scope f(sys::getsockopt, WSAENOTSOCK); + BOOST_TEST(u.assign(h) == std::errc::not_a_socket); + BOOST_TEST(f.fired()); + BOOST_TEST(!u.is_open()); + } + { + udp_socket u(ioc); + fault_scope f(sys::CreateIoCompletionPort, + ERROR_INVALID_PARAMETER); + BOOST_TEST( + u.assign(h) == win_err(ERROR_INVALID_PARAMETER)); + BOOST_TEST(f.fired()); + BOOST_TEST(!u.is_open()); + } + }); + BOOST_TEST(native_socket_valid(h)); + close_native_socket(h); + } + { + udp_socket u(ioc); + BOOST_TEST(!u.open(udp::v4())); + { + fault_scope f(sys::setsockopt, WSAENOTSOCK); + expect_system_error( + [&]{ u.set_option(socket_option::reuse_address(true)); }, + std::errc::not_a_socket); + BOOST_TEST(f.fired()); + } + { + fault_scope f(sys::getsockopt, WSAENOTSOCK); + expect_system_error( + [&]{ + std::ignore = + u.get_option(); + }, + std::errc::not_a_socket); + BOOST_TEST(f.fired()); + } + { + fault_scope f(sys::shutdown, WSAENOTSOCK); + BOOST_TEST(u.shutdown(udp_socket::shutdown_both) == + std::errc::not_a_socket); + BOOST_TEST(f.fired()); + } + } + } + + void testUdpIoFails() + { + io_context ioc(iocp); + udp_socket a(ioc), b(ioc); + BOOST_TEST(!a.open(udp::v4())); + BOOST_TEST(!a.bind(loopback())); + BOOST_TEST(!b.open(udp::v4())); + BOOST_TEST(!b.bind(loopback())); + auto const a_ep = endpoint( + ipv4_address::loopback(), a.local_endpoint().port()); + auto const b_ep = endpoint( + ipv4_address::loopback(), b.local_endpoint().port()); + char buf[8] = {}; + char out[4] = "abc"; + std::error_code stec, rfec, stcec, rfcec, conec, sec, rec, scec, rcec; + bool expired = false; + auto body = [&]() -> capy::task<> + { + { + fault_scope f(sys::WSASendTo, WSAENOTSOCK); + auto [ec, n] = co_await a.send_to( + capy::const_buffer(out, 3), b_ep); + std::ignore = n; + stec = ec; + BOOST_TEST(f.fired()); + } + { + endpoint src; + fault_scope f(sys::WSARecvFrom, WSAENOTSOCK); + auto [ec, n] = co_await b.recv_from( + capy::mutable_buffer(buf, sizeof(buf)), src); + std::ignore = n; + rfec = ec; + BOOST_TEST(f.fired()); + } + { + // The kernel result of a queued WSASendTo only exists + // on the completion, so the unconnected send has its + // own completion fault. + completion_fault_scope q(ERROR_NETNAME_DELETED); + auto [ec, n] = co_await a.send_to( + capy::const_buffer(out, 3), b_ep); + std::ignore = n; + stcec = ec; + BOOST_TEST(q.fired()); + } + { + // That datagram really went out — only its completion + // was rewritten — so the receive has a real one to + // fail in turn. + endpoint src; + completion_fault_scope q(ERROR_NETNAME_DELETED); + auto [ec, n] = co_await b.recv_from( + capy::mutable_buffer(buf, sizeof(buf)), src); + std::ignore = n; + rfcec = ec; + BOOST_TEST(q.fired()); + } + { + // A datagram connect is synchronous: WSAConnect + // either names the peer or reports why not. + fault_scope f(sys::WSAConnect, WSAEAFNOSUPPORT); + auto [ec] = co_await a.connect(b_ep); + conec = ec; + BOOST_TEST(f.fired()); + } + { + auto [ec] = co_await a.connect(b_ep); + BOOST_TEST(!ec); + } + { + auto [ec] = co_await b.connect(a_ep); + BOOST_TEST(!ec); + } + { + fault_scope f(sys::WSASend, WSAENOTSOCK); + auto [ec, n] = co_await a.send(capy::const_buffer(out, 3)); + std::ignore = n; + sec = ec; + BOOST_TEST(f.fired()); + } + { + fault_scope f(sys::WSARecv, WSAENOTSOCK); + auto [ec, n] = co_await b.recv( + capy::mutable_buffer(buf, sizeof(buf))); + std::ignore = n; + rec = ec; + BOOST_TEST(f.fired()); + } + { + completion_fault_scope q(ERROR_NETNAME_DELETED); + auto [ec, n] = co_await a.send(capy::const_buffer(out, 3)); + std::ignore = n; + scec = ec; + BOOST_TEST(q.fired()); + } + { + // The datagram sent above is already queued, so the + // receive has a real completion to fail. + completion_fault_scope q(ERROR_NETNAME_DELETED); + auto [ec, n] = co_await b.recv( + capy::mutable_buffer(buf, sizeof(buf))); + std::ignore = n; + rcec = ec; + BOOST_TEST(q.fired()); + } + ioc.stop(); + }; + capy::run_async(ioc.get_executor())(body()); + capy::run_async(ioc.get_executor())(stop_guard(ioc, expired)); + ioc.run(); + // A receive parks until its datagram lands; the guard turns a + // dropped one into a failure instead of a stalled run loop. + BOOST_TEST(!expired); + BOOST_TEST(stec == std::errc::not_a_socket); + BOOST_TEST(rfec == std::errc::not_a_socket); + BOOST_TEST(stcec == std::errc::connection_reset); + BOOST_TEST(rfcec == std::errc::connection_reset); + BOOST_TEST(conec == std::errc::address_family_not_supported); + BOOST_TEST(sec == std::errc::not_a_socket); + BOOST_TEST(rec == std::errc::not_a_socket); + BOOST_TEST(scec == std::errc::connection_reset); + BOOST_TEST(rcec == std::errc::connection_reset); + } + + void testWaitReactorSetupFails() + { + // The wakeup channel is built as the scheduler is, right after + // the resolver service starts Winsock, and nothing before it + // in the construction reaches these entry points -- so the + // counts below are the pair's own. A reactor that cannot be + // woken can never report readiness, so the context refuses to + // construct rather than handing back one whose every wait + // would park forever. The codes are ones neither make_err nor + // iocp_make_err rewrites, so what is injected is what the + // constructor throws. + static constexpr wakeup_arm arms[] = { + {sys::socket, WSAEMFILE}, + {sys::bind, WSAEACCES}, + {sys::listen, WSAEINVAL}, + {sys::getsockname, WSAEFAULT}, + // The second socket() in make_wakeup_pair is the peer that + // connects to the listener. + {sys::socket, WSAENOBUFS, 2u}, + {sys::connect, WSAENETDOWN}, + {sys::accept, WSAEINPROGRESS}, + {sys::ioctlsocket, WSAENOTCONN}, + }; + // The completion port, the timer wakeup and whichever wakeup + // sockets were already open are all the failed construction's + // to release. A leak of one handle per attempt has to separate + // from the ambient drift, so each arm runs sixteen times + // against a growth budget of eight. + for(auto const& arm : arms) + { + expect_no_handle_leak([&]{ + fault_scope f(arm.which, arm.err, arm.nth); + expect_system_error([]{ io_context ioc(iocp); }, + win_err(arm.err)); + BOOST_TEST(f.fired()); + }, 16, 8); + } + } + + // A context that constructs owns a wakeup channel, so the reactor + // has something to poll and the wait it is handed completes. The + // polling thread is the half that waits for a reason to exist: one + // that was already running would be sitting in WSAPoll, so a + // context that never waits leaves the arm below unspent. + void testWaitReactorStartsOnFirstWait() + { + if(!hook_is_live(sys::WSAPoll)) + { + skip_dead_hook("WSAPoll"); + } + else + { + // any_thread: a thread that had started would be polling + // on its own, not on this one. The arm outlives the + // context so a poll begun during teardown counts too. + fault_scope f(sys::WSAPoll, WSAENOBUFS, 1u, any_thread); + { + io_context quiet(iocp); + udp_socket s(quiet); + BOOST_TEST(!s.open(udp::v4())); + BOOST_TEST(!s.bind(loopback())); + s.close(); + } + BOOST_TEST(!f.fired()); + } + + io_context ioc(iocp); + auto pair = make_socket_pair(ioc); + auto& s1 = pair.first; + auto& s2 = pair.second; + std::error_code wec; + bool done = false; + bool expired = false; + auto body = [&]() -> capy::task<> + { + auto [ec] = co_await s1.wait(wait_type::write); + wec = ec; + done = true; + ioc.stop(); + }; + capy::run_async(ioc.get_executor())(body()); + capy::run_async(ioc.get_executor())(stop_guard(ioc, expired)); + ioc.run(); + BOOST_TEST(!expired); + BOOST_TEST(done); + BOOST_TEST(!wec); + s1.close(); + s2.close(); + } + + /* A wake that never left has to be retried, not swallowed. + + `wake_pending_` is set before the send and stands for a byte in + the wakeup channel, so a send that failed leaves it claiming a + byte nobody sent. Every later register, cancel and stop then + coalesces into that phantom, and the poll they were meant to end + is indefinite. + + This context never registers a wait, so no polling thread is + ever started and nothing drains the flag -- a latch here would + be permanent, which is what makes the second send the + observable. Both sends come from the socket: open() goes + through close_socket, which asks the reactor to drop any wait op + the descriptor might have had, and cancel() asks again. Both + asks reach wake_self whether or not anything is parked + (win_tcp_socket_internal::cancel and ::close_socket). + */ + void testWakeSendFails() + { + if(!hook_is_live(sys::send)) + { + skip_dead_hook("send"); + return; + } + { + io_context ioc(iocp); + // Thread-local, not any_thread: two process-wide scopes + // cannot be alive at once, and every call below is on this + // thread. Armed before open(), whose own ask is the first + // send and the one that has to leave the flag clear. + fault_scope first(sys::send, WSAENOBUFS, 1); + fault_scope second(sys::send, WSAENOBUFS, 2); + tcp_socket s(ioc); + BOOST_TEST(!s.open(tcp::v4())); + s.cancel(); + BOOST_TEST(first.fired()); + BOOST_TEST(second.fired()); + s.close(); + } + // And a context whose wakes all failed still answers a wait + // rather than leaving it outstanding. Nothing raises the error + // condition on a healthy pair, so the cancel below is the only + // thing that can end this wait: a reactor whose wakes were + // swallowed would leave it parked. + io_context ioc(iocp); + auto pair = make_socket_pair(ioc); + auto& s1 = pair.first; + auto& s2 = pair.second; + std::error_code parked_ec; + bool expired = false; + auto parked = [&]() -> capy::task<> + { + auto [ec] = co_await s1.wait(wait_type::error); + parked_ec = ec; + ioc.stop(); + }; + auto breaker = [&]() -> capy::task<> + { + s1.cancel(); + co_return; + }; + capy::run_async(ioc.get_executor())(parked()); + capy::run_async(ioc.get_executor())(breaker()); + capy::run_async(ioc.get_executor())(stop_guard(ioc, expired)); + ioc.run(); + BOOST_TEST(!expired); + BOOST_TEST(parked_ec == capy::error::canceled); + s1.close(); + s2.close(); + } + + void testWaitReactorPollFails() + { + io_context ioc(iocp); + auto pair = make_socket_pair(ioc); + auto& s1 = pair.first; + auto& s2 = pair.second; + std::optional arm; + std::error_code parked_ec; + bool expired = false; + auto parked = [&]() -> capy::task<> + { + // An error wait on a quiet socket never becomes ready, so + // the only thing that can complete it is the reactor + // leaving its loop. + auto [ec] = co_await s1.wait(wait_type::error); + parked_ec = ec; + ioc.stop(); + }; + auto breaker = [&]() -> capy::task<> + { + // Armed after the wait above is queued, so whichever poll + // fails first already has that op in hand: the drain on + // the way out covers registered_ and pending_register_ + // alike. + arm.emplace(sys::WSAPoll, WSAENOBUFS, 1u, any_thread); + // A cancel for an op the reactor never registered is a + // no-op that still pokes the self-pipe. + s2.cancel(); + co_return; + }; + capy::run_async(ioc.get_executor())(parked()); + capy::run_async(ioc.get_executor())(breaker()); + capy::run_async(ioc.get_executor())(stop_guard(ioc, expired)); + ioc.run(); + BOOST_TEST(!expired); + BOOST_TEST(arm.has_value() && arm->fired()); + // No entry of the poll set accounts for WSAENOBUFS, so the + // reactor leaves for good -- and the op it was holding is told + // what happened rather than that someone cancelled it. + BOOST_TEST(parked_ec == win_err(WSAENOBUFS)); + arm.reset(); + + // The polling thread left for good and nothing restarts it, so + // a wait registered afterwards has nobody to report its + // readiness. Refusing it is the only answer that is not a park + // forever, and the refusal names what killed the thread rather + // than claiming a cancellation. io_context::stop() does not + // reach the reactor, so this is the reactor's own state + // answering and not a stopped scheduler. + ioc.restart(); + std::error_code late_ec; + bool late_expired = false; + auto late = [&]() -> capy::task<> + { + auto [ec] = co_await s1.wait(wait_type::error); + late_ec = ec; + ioc.stop(); + }; + capy::run_async(ioc.get_executor())(late()); + capy::run_async(ioc.get_executor())(stop_guard(ioc, late_expired)); + ioc.run(); + BOOST_TEST(!late_expired); + BOOST_TEST(late_ec == win_err(WSAENOBUFS)); + s1.close(); + s2.close(); + } + + void testLocalSetupFails() + { + io_context ioc(iocp); + { + local_stream_socket s(ioc); + fault_scope f(sys::WSASocketW, WSAEAFNOSUPPORT); + auto ec = s.open(); + BOOST_TEST(f.fired()); + BOOST_TEST(ec == std::errc::address_family_not_supported); + BOOST_TEST(!s.is_open()); + } + expect_no_handle_leak([&]{ + local_stream_socket s(ioc); + fault_scope f(sys::CreateIoCompletionPort, + ERROR_INVALID_PARAMETER); + auto ec = s.open(); + BOOST_TEST(f.fired()); + BOOST_TEST(ec == win_err(ERROR_INVALID_PARAMETER)); + BOOST_TEST(!s.is_open()); + }); + { + local_stream_acceptor acc(ioc); + fault_scope f(sys::WSASocketW, WSAEAFNOSUPPORT); + auto ec = acc.open(); + BOOST_TEST(f.fired()); + BOOST_TEST(ec == std::errc::address_family_not_supported); + BOOST_TEST(!acc.is_open()); + } + expect_no_handle_leak([&]{ + local_stream_acceptor acc(ioc); + fault_scope f(sys::CreateIoCompletionPort, + ERROR_INVALID_PARAMETER); + auto ec = acc.open(); + BOOST_TEST(f.fired()); + BOOST_TEST(ec == win_err(ERROR_INVALID_PARAMETER)); + BOOST_TEST(!acc.is_open()); + }); + { + auto h = make_native_socket(AF_UNIX, SOCK_STREAM); + make_native_adoptable(h); + expect_no_handle_leak([&]{ + { + // Adoption learns the family and type from + // SO_PROTOCOL_INFOW here too. + local_stream_socket s(ioc); + fault_scope f(sys::getsockopt, WSAENOTSOCK); + BOOST_TEST(s.assign(h) == std::errc::not_a_socket); + BOOST_TEST(f.fired()); + BOOST_TEST(!s.is_open()); + } + { + local_stream_socket s(ioc); + fault_scope f(sys::CreateIoCompletionPort, + ERROR_INVALID_PARAMETER); + BOOST_TEST( + s.assign(h) == win_err(ERROR_INVALID_PARAMETER)); + BOOST_TEST(f.fired()); + BOOST_TEST(!s.is_open()); + } + }); + // A rejected adoption leaves the socket with the caller. + BOOST_TEST(native_socket_valid(h)); + close_native_socket(h); + } + temp_socket_dir dir; + auto const ep = corosio::local_endpoint(dir.path()); + { + local_stream_acceptor acc(ioc); + BOOST_TEST(!acc.open()); + // The unlink that precedes a bind is best effort: a + // missing file is the common case + // (local_stream_acceptor::bind). + fault_scope f(sys::DeleteFileA, ERROR_ACCESS_DENIED); + BOOST_TEST(!acc.bind(ep, bind_option::unlink_existing)); + BOOST_TEST(f.fired()); + BOOST_TEST(acc.is_open()); + } + { + local_stream_acceptor acc(ioc); + BOOST_TEST(!acc.open()); + fault_scope f(sys::bind, WSAEADDRINUSE); + BOOST_TEST(acc.bind(ep) == std::errc::address_in_use); + BOOST_TEST(f.fired()); + BOOST_TEST(acc.is_open()); + } + { + local_stream_acceptor acc(ioc); + BOOST_TEST(!acc.open()); + BOOST_TEST(!acc.bind(ep, bind_option::unlink_existing)); + fault_scope f(sys::listen, WSAEOPNOTSUPP); + BOOST_TEST(acc.listen() == std::errc::operation_not_supported); + BOOST_TEST(f.fired()); + // Not latched: the acceptor is still bound and open, so a + // second listen takes. + BOOST_TEST(acc.is_open()); + BOOST_TEST(!acc.listen()); + } + // The convenience constructor walks open, bind and listen and + // throws at whichever fails. Its bind does not unlink, so the + // open leg -- which never reaches it -- and the listen leg -- + // which leaves the path bound -- each get a path of their own. + { + temp_socket_dir odir; + fault_scope f(sys::WSASocketW, WSAEAFNOSUPPORT); + expect_system_error( + [&]{ + local_stream_acceptor a( + ioc, corosio::local_endpoint(odir.path())); + }, + std::errc::address_family_not_supported); + BOOST_TEST(f.fired()); + } + { + temp_socket_dir ldir; + fault_scope f(sys::listen, WSAEOPNOTSUPP); + expect_system_error( + [&]{ + local_stream_acceptor a( + ioc, corosio::local_endpoint(ldir.path())); + }, + std::errc::operation_not_supported); + BOOST_TEST(f.fired()); + } + } + + void testLocalConnectAcceptFails() + { + io_context ioc(iocp); + temp_socket_dir dir; + auto const ep = corosio::local_endpoint(dir.path()); + local_stream_acceptor acc(ioc); + BOOST_TEST(!acc.open()); + BOOST_TEST(!acc.bind(ep, bind_option::unlink_existing)); + BOOST_TEST(!acc.listen()); + std::error_code bec, sockec, portec, syncec, compec; + bool expired = false; + auto body = [&]() -> capy::task<> + { + { + // AF_UNIX ConnectEx also needs a bound socket, which + // it satisfies with a family-only sockaddr_un. + local_stream_socket s(ioc); + BOOST_TEST(!s.open()); + fault_scope f(sys::bind, WSAEADDRNOTAVAIL); + auto [ec] = co_await s.connect(ep); + bec = ec; + BOOST_TEST(f.fired()); + } + local_stream_socket peer(ioc); + { + local_stream_socket c(ioc); + BOOST_TEST(!c.open()); + auto [cec] = co_await c.connect(ep); + BOOST_TEST(!cec); + fault_scope f(sys::WSASocketW, WSAEAFNOSUPPORT); + auto [ec] = co_await acc.accept(peer); + sockec = ec; + BOOST_TEST(f.fired()); + BOOST_TEST(!peer.is_open()); + c.cancel(); + c.close(); + } + { + local_stream_socket c(ioc); + BOOST_TEST(!c.open()); + auto [cec] = co_await c.connect(ep); + BOOST_TEST(!cec); + fault_scope f(sys::CreateIoCompletionPort, + ERROR_INVALID_PARAMETER); + auto [ec] = co_await acc.accept(peer); + portec = ec; + BOOST_TEST(f.fired()); + BOOST_TEST(!peer.is_open()); + c.cancel(); + c.close(); + } + if(hook_is_live(sys::AcceptEx)) + { + // The AF_UNIX accept reaches the same substituted + // pointer as the tcp one. + local_stream_socket c(ioc); + BOOST_TEST(!c.open()); + auto [cec] = co_await c.connect(ep); + BOOST_TEST(!cec); + fault_scope f(sys::AcceptEx, WSAENOTSOCK); + auto [ec] = co_await acc.accept(peer); + syncec = ec; + BOOST_TEST(f.fired()); + BOOST_TEST(!peer.is_open()); + c.cancel(); + c.close(); + } + else + { + skip_dead_hook("AcceptEx"); + } + { + local_stream_socket c(ioc); + BOOST_TEST(!c.open()); + auto [cec] = co_await c.connect(ep); + BOOST_TEST(!cec); + completion_fault_scope q(ERROR_NETNAME_DELETED); + auto [ec] = co_await acc.accept(peer); + compec = ec; + BOOST_TEST(q.fired()); + BOOST_TEST(!peer.is_open()); + c.cancel(); + c.close(); + } + ioc.stop(); + }; + capy::run_async(ioc.get_executor())(body()); + capy::run_async(ioc.get_executor())(stop_guard(ioc, expired)); + ioc.run(); + BOOST_TEST(!expired); + BOOST_TEST(bec == std::errc::address_not_available); + BOOST_TEST(sockec == std::errc::address_family_not_supported); + BOOST_TEST(portec == win_err(ERROR_INVALID_PARAMETER)); + if(hook_is_live(sys::AcceptEx)) + BOOST_TEST(syncec == std::errc::not_a_socket); + BOOST_TEST(compec == std::errc::connection_aborted); + } + + /* The deferred queue, and a re-post that puts an op back on it. + + A post that fails leaves the work on completed_ops_ with + dispatch_required_ raised, and do_one consults that flag only at + the top of its loop. The failure inside the drain therefore + raises it again just after the check, leaving the loop blocked + in GetQueuedCompletionStatus with work waiting: a timer is what + brings it round, and the wake is posted by the timer thread, + whose calls no arm here counts. + */ + void testPostDeferredFallbackRuns() + { + io_context ioc(iocp); + capy::continuation cont{}; + bool ran = false; + bool fired = false; + bool expired = false; + auto body = [&]() -> capy::task<> + { + { + // 1 is the continuation post, 2 the allocating handle + // post it falls back to -- the one that reaches the + // deferred queue -- and 3 the drain's own re-post. + fault_scope f1(sys::PostQueuedCompletionStatus, + ERROR_NO_SYSTEM_RESOURCES, 1); + fault_scope f2(sys::PostQueuedCompletionStatus, + ERROR_NO_SYSTEM_RESOURCES, 2); + fault_scope f3(sys::PostQueuedCompletionStatus, + ERROR_NO_SYSTEM_RESOURCES, 3); + co_await post_awaitable{&cont}; + fired = f1.fired() && f2.fired() && f3.fired(); + } + ran = true; + ioc.stop(); + }; + auto pump = [&]() -> capy::task<> + { + while(!ran) + { + std::ignore = co_await corosio::delay( + std::chrono::milliseconds(5)); + } + }; + capy::run_async(ioc.get_executor())(body()); + capy::run_async(ioc.get_executor())(pump()); + capy::run_async(ioc.get_executor())(stop_guard(ioc, expired)); + ioc.run(); + BOOST_TEST(!expired); + BOOST_TEST(fired); + BOOST_TEST(ran); + } + + /* An inline completion whose own post fails. + + A synchronous failure is published through on_completion, which + posts a key_result_stored packet to carry it. Landing on the + deferred queue instead is the only fallback that path has, and + the run loop's next turn is what dispatches it -- the flag is + raised while the loop is still inside the packet that resumed + this coroutine, so it is seen on the way back round. + */ + void testInlineCompletionPostFails() + { + io_context ioc(iocp); + auto pair = make_socket_pair(ioc); + auto& a = pair.first; + auto& b = pair.second; + char buf[8] = {}; + std::error_code rec; + bool fired = false; + bool expired = false; + auto body = [&]() -> capy::task<> + { + { + fault_scope r(sys::WSARecv, WSAENOTSOCK); + fault_scope p(sys::PostQueuedCompletionStatus, + ERROR_NO_SYSTEM_RESOURCES); + auto [ec, n] = co_await a.read_some( + capy::mutable_buffer(buf, sizeof(buf))); + std::ignore = n; + rec = ec; + fired = r.fired() && p.fired(); + } + a.cancel(); + b.cancel(); + ioc.stop(); + }; + capy::run_async(ioc.get_executor())(body()); + capy::run_async(ioc.get_executor())(stop_guard(ioc, expired)); + ioc.run(); + BOOST_TEST(!expired); + BOOST_TEST(fired); + BOOST_TEST(rec == std::errc::not_a_socket); + } + + /* The error probe the wait reactor runs on a raised poll event. + + Only the write and error waits reach the reactor at all: a wait + for readability is a zero-byte WSARecv + (win_tcp_socket_internal::wait). The peer is reset before + the wait is registered, so the reactor's first poll already has + the condition in hand and nothing here depends on a round + landing between two coroutines. + + A write wait, so the second half reports no error at all rather + than WSAECONNABORTED: the substitution the probe falls through + to is for error waits only, and this is the probe's own answer + that is under test. + */ + void testWaitReactorErrorProbe() + { + { + io_context ioc(iocp); + auto pair = make_socket_pair(ioc); + auto& s1 = pair.first; + auto& s2 = pair.second; + std::error_code wec; + bool premise = false; + bool expired = false; + auto body = [&]() -> capy::task<> + { + // make_socket_pair leaves both ends on a zero linger, + // so this is a reset rather than an orderly shutdown + // and it leaves SO_ERROR set on the survivor. + s2.close(); + premise = wait_for_poll_error(s1, POLLWRNORM); + touch_after_reset(s1); + auto [ec] = co_await s1.wait(wait_type::write); + wec = ec; + ioc.stop(); + }; + capy::run_async(ioc.get_executor())(body()); + capy::run_async(ioc.get_executor())(stop_guard(ioc, expired)); + ioc.run(); + BOOST_TEST(!expired); + BOOST_TEST(premise); + // Whether the reset the poll reported is also waiting in + // SO_ERROR is the provider's to decide; what the wait owes + // its caller is the probe's answer either way. Named in + // the log so a run that gives neither is legible. + if(wec != std::errc::connection_reset) + { + std::fprintf(stderr, + "fault harness: the reset the poll reported left " + "SO_ERROR reading %d (%s)\n", + wec.value(), wec.message().c_str()); + BOOST_TEST(!wec); + } + BOOST_TEST(s1.is_open()); + s1.close(); + } + + io_context ioc(iocp); + auto pair = make_socket_pair(ioc); + auto& s1 = pair.first; + auto& s2 = pair.second; + std::optional probe; + std::error_code wec; + bool probed = false; + bool premise = false; + bool expired = false; + auto body = [&]() -> capy::task<> + { + s2.close(); + premise = wait_for_poll_error(s1, POLLWRNORM); + // The probe runs on the reactor's polling thread, so the + // arm has to be process-wide; nothing else calls + // getsockopt while it is up. + probe.emplace(sys::getsockopt, WSAENOTSOCK, 1u, any_thread); + auto [ec] = co_await s1.wait(wait_type::write); + wec = ec; + probed = probe->fired(); + probe.reset(); + ioc.stop(); + }; + capy::run_async(ioc.get_executor())(body()); + capy::run_async(ioc.get_executor())(stop_guard(ioc, expired)); + ioc.run(); + // A wait that never resolved leaves the one process-wide arm + // claimed, and the next test to want one would abort. + probe.reset(); + BOOST_TEST(!expired); + BOOST_TEST(premise); + BOOST_TEST(probed); + // The probe answered nothing, and the substitution it falls + // through to is for error waits only, so this round has + // nothing to report. + BOOST_TEST(!wec); + BOOST_TEST(s1.is_open()); + s1.close(); + } + + /* An error wait is answered, and costs the context nothing. + + events_for_wait used to ask WSAPoll for POLLPRI, which this + provider does not implement and refuses the whole call for: the + reactor left its loop, drained every op it held and refused + every later register, so one error wait disabled waiting on the + context for good. The bit it asks for now has to be one the + provider takes, and the error condition an error wait is after + arrives in revents either way. + */ + void testErrorWaitOnThisProvider() + { + { + // The provider on its own, with none of the library in it. + io_context ioc(iocp); + auto pair = make_socket_pair(ioc); + auto const fd = + static_cast(pair.first.native_handle()); + WSAPOLLFD pfd{fd, POLLRDBAND, 0}; + ::WSASetLastError(0); + int const n = ::WSAPoll(&pfd, 1, 0); + int const err = ::WSAGetLastError(); + if(n == SOCKET_ERROR) + { + // The other bit an error wait could register with, + // named here so a round that has to choose again is + // not blind. + WSAPOLLFD quiet{fd, 0, 0}; + ::WSASetLastError(0); + int const qn = ::WSAPoll(&quiet, 1, 0); + std::fprintf(stderr, + "fault harness: WSAPoll refuses POLLRDBAND with " + "%d; asked for nothing it answers %d, revents %d, " + "error %d\n", + err, qn, static_cast(quiet.revents), + ::WSAGetLastError()); + } + BOOST_TEST(n != SOCKET_ERROR); + // Accepted or ignored, the bit must not cost a live socket + // its validity. + BOOST_TEST((pfd.revents & POLLNVAL) == 0); + pair.first.close(); + pair.second.close(); + } + + // The contract: a reset answers the error wait, and a wait + // registered afterwards is answered too. Both pairs are built + // before the run, since make_socket_pair runs the context. + io_context ioc(iocp); + auto pair = make_socket_pair(ioc); + auto healthy = make_socket_pair(ioc); + auto& s1 = pair.first; + auto& s2 = pair.second; + std::error_code eec, wec; + bool premise = false; + bool expired = false; + auto body = [&]() -> capy::task<> + { + // Zero linger on both ends, so this is a reset and not an + // orderly shutdown. + s2.close(); + premise = wait_for_poll_error(s1, POLLRDBAND); + if(!premise) + { + report_poll_masks(s1); + ioc.stop(); + co_return; + } + auto [ec] = co_await s1.wait(wait_type::error); + eec = ec; + auto [wc] = co_await healthy.first.wait(wait_type::write); + wec = wc; + ioc.stop(); + }; + capy::run_async(ioc.get_executor())(body()); + capy::run_async(ioc.get_executor())(stop_guard(ioc, expired)); + ioc.run(); + BOOST_TEST(!expired); + BOOST_TEST(premise); + // The reset itself, or the substitution the reactor makes when + // SO_ERROR has nothing to add -- but never a cancellation, + // which nothing here cancelled. + BOOST_TEST(eec); + BOOST_TEST(eec != capy::error::canceled); + BOOST_TEST(!wec); + s1.close(); + healthy.first.close(); + healthy.second.close(); + } + + /* A descriptor the provider refuses costs its own wait and no + other. + + WSAPoll validates the whole array before it polls any of it, so + one socket it no longer recognises refuses the call for every + registration in the set. close_socket asks the reactor to drop + the entry before it closes the handle, but the ask is read on + the reactor's next pass and the handle is gone before then, so + a poll carrying the dead descriptor is reachable in ordinary + use (win_tcp_socket_internal::close_socket says so). + + Closed behind the library's back rather than through a + fault_scope: the reactor answers the refusal by asking about + each entry on its own, so the arm would have to fail the set + poll and that entry's own poll, and the harness arms one call + ordinal at a time. + */ + void testWaitReactorEntryRefused() + { + io_context ioc(iocp); + // Both pairs exist before anything is closed, so no socket id + // freed below can come back as one of these. + auto pair = make_socket_pair(ioc); + auto healthy = make_socket_pair(ioc); + auto& s1 = pair.first; + auto& s2 = pair.second; + std::error_code eec, wec, lec; + bool done_err = false; + bool done_late = false; + bool expired = false; + auto parked = [&]() -> capy::task<> + { + // Nothing raises the error condition on a healthy pair, so + // this wait stays parked while the handle under it goes. + auto [ec] = co_await s1.wait(wait_type::error); + eec = ec; + done_err = true; + if(done_late) + ioc.stop(); + }; + auto breaker = [&]() -> capy::task<> + { + ::closesocket(static_cast(s1.native_handle())); + // A register is what makes the reactor rebuild its poll + // set around the entry that is now gone. + auto [wc] = co_await healthy.first.wait(wait_type::write); + wec = wc; + // And one more afterwards: a reactor that survived the + // refusal still takes registrations. + auto [lc] = co_await healthy.second.wait(wait_type::write); + lec = lc; + done_late = true; + if(done_err) + ioc.stop(); + }; + capy::run_async(ioc.get_executor())(parked()); + capy::run_async(ioc.get_executor())(breaker()); + capy::run_async(ioc.get_executor())(stop_guard(ioc, expired)); + ioc.run(); + BOOST_TEST(!expired); + BOOST_TEST(done_err); + BOOST_TEST(done_late); + // Whether the provider refused the call or flagged the entry + // POLLNVAL, the wait on the dead descriptor is answered with + // an error of its own and not with the abort a drained reactor + // hands out. + BOOST_TEST(eec); + BOOST_TEST(eec != capy::error::canceled); + BOOST_TEST(!wec); + BOOST_TEST(!lec); + // s1's handle is already gone; this closes a socket id no + // later call of this test's own asked for. + s1.close(); + s2.close(); + healthy.first.close(); + healthy.second.close(); + } + + /* A context whose extension-pointer bootstrap never ran. + + win_tcp_service::load_extension_functions creates one socket to + ask Winsock for ConnectEx and AcceptEx; failing that socket + leaves both pointers null for the whole + context. That is the one arm that reaches all three call sites + which refuse an operation because the pointer is missing -- + testTcpExtensionPointerMissing fails the query instead and + reaches only the tcp connect. + */ + void testExtensionPointersMissing() + { + // The bootstrap's socket is the first WSASocketW of the + // construction; the wakeup pair uses ::socket. The arm is + // spent by the time the rest of the test runs. + fault_scope arm(sys::WSASocketW, WSAEMFILE); + io_context ioc(iocp); + BOOST_TEST(arm.fired()); + + temp_socket_dir dir; + auto const ep = corosio::local_endpoint(dir.path()); + tcp_acceptor acc(ioc, loopback()); + local_stream_acceptor lacc(ioc); + BOOST_TEST(!lacc.open()); + BOOST_TEST(!lacc.bind(ep, bind_option::unlink_existing)); + BOOST_TEST(!lacc.listen()); + std::error_code tec, lec, cec; + bool expired = false; + auto body = [&]() -> capy::task<> + { + { + // The accepted socket is created before the pointer is + // consulted, so the refusal owns closing it. + tcp_socket server(ioc); + auto [ec] = co_await acc.accept(server); + tec = ec; + BOOST_TEST(!server.is_open()); + } + { + local_stream_socket peer(ioc); + auto [ec] = co_await lacc.accept(peer); + lec = ec; + BOOST_TEST(!peer.is_open()); + } + { + local_stream_socket c(ioc); + BOOST_TEST(!c.open()); + auto [ec] = co_await c.connect(ep); + cec = ec; + BOOST_TEST(c.is_open()); + } + ioc.stop(); + }; + capy::run_async(ioc.get_executor())(body()); + capy::run_async(ioc.get_executor())(stop_guard(ioc, expired)); + ioc.run(); + BOOST_TEST(!expired); + BOOST_TEST(tec == std::errc::operation_not_supported); + BOOST_TEST(lec == std::errc::operation_not_supported); + BOOST_TEST(cec == std::errc::operation_not_supported); + } + + // Adoption on the acceptors, which learn the family and type from + // SO_PROTOCOL_INFOW the way the sockets do and associate before + // letting go of what they held. + void testAcceptorAssignFails() + { + io_context ioc(iocp); + { + auto h = make_native_socket(AF_INET, SOCK_STREAM); + make_native_adoptable(h); + expect_no_handle_leak([&]{ + { + tcp_acceptor acc(ioc); + fault_scope f(sys::getsockopt, WSAENOTSOCK); + BOOST_TEST(acc.assign(h) == std::errc::not_a_socket); + BOOST_TEST(f.fired()); + BOOST_TEST(!acc.is_open()); + } + { + tcp_acceptor acc(ioc); + fault_scope f(sys::CreateIoCompletionPort, + ERROR_INVALID_PARAMETER); + BOOST_TEST(acc.assign(h) == + win_err(ERROR_INVALID_PARAMETER)); + BOOST_TEST(f.fired()); + BOOST_TEST(!acc.is_open()); + } + }); + BOOST_TEST(native_socket_valid(h)); + close_native_socket(h); + } + { + auto h = make_native_socket(AF_UNIX, SOCK_STREAM); + make_native_adoptable(h); + expect_no_handle_leak([&]{ + { + local_stream_acceptor acc(ioc); + fault_scope f(sys::getsockopt, WSAENOTSOCK); + BOOST_TEST(acc.assign(h) == std::errc::not_a_socket); + BOOST_TEST(f.fired()); + BOOST_TEST(!acc.is_open()); + } + { + local_stream_acceptor acc(ioc); + fault_scope f(sys::CreateIoCompletionPort, + ERROR_INVALID_PARAMETER); + BOOST_TEST(acc.assign(h) == + win_err(ERROR_INVALID_PARAMETER)); + BOOST_TEST(f.fired()); + BOOST_TEST(!acc.is_open()); + } + }); + BOOST_TEST(native_socket_valid(h)); + close_native_socket(h); + } + } + + // The acceptor's own option surface, which the socket suite covers + // for sockets only. + void testAcceptorOptionsFail() + { + io_context ioc(iocp); + tcp_acceptor acc(ioc); + BOOST_TEST(!acc.open()); + { + fault_scope f(sys::setsockopt, WSAENOTSOCK); + expect_system_error( + [&]{ acc.set_option(socket_option::reuse_address(true)); }, + std::errc::not_a_socket); + BOOST_TEST(f.fired()); + } + { + fault_scope f(sys::getsockopt, WSAENOTSOCK); + expect_system_error( + [&]{ + std::ignore = + acc.get_option(); + }, + std::errc::not_a_socket); + BOOST_TEST(f.fired()); + } + BOOST_TEST(acc.is_open()); + } + + /* The AF_UNIX legs that refuse before the kernel takes the work. + + Each of these is the initiating call reporting a Winsock error + inline, which the service publishes through on_completion rather + than through a completion packet, plus the synchronous shutdown + the socket answers for itself. + */ + void testLocalIoFails() + { + io_context ioc(iocp); + temp_socket_dir dir; + auto const ep = corosio::local_endpoint(dir.path()); + local_stream_acceptor acc(ioc); + BOOST_TEST(!acc.open()); + BOOST_TEST(!acc.bind(ep, bind_option::unlink_existing)); + BOOST_TEST(!acc.listen()); + char buf[8] = {}; + char out[4] = "abc"; + std::error_code rec, wec, wtec, sdec; + bool expired = false; + auto body = [&]() -> capy::task<> + { + local_stream_socket c(ioc), peer(ioc); + BOOST_TEST(!c.open()); + { + auto [ec] = co_await c.connect(ep); + BOOST_TEST(!ec); + } + { + auto [ec] = co_await acc.accept(peer); + BOOST_TEST(!ec); + } + { + fault_scope f(sys::WSARecv, WSAECONNRESET); + auto [ec, n] = co_await c.read_some( + capy::mutable_buffer(buf, sizeof(buf))); + std::ignore = n; + rec = ec; + BOOST_TEST(f.fired()); + } + { + fault_scope f(sys::WSASend, WSAENOBUFS); + auto [ec, n] = co_await c.write_some( + capy::const_buffer(out, 3)); + std::ignore = n; + wec = ec; + BOOST_TEST(f.fired()); + } + { + // A wait for readability is a zero-byte WSARecv here + // too. + fault_scope f(sys::WSARecv, WSAENOTSOCK); + auto [ec] = co_await c.wait(wait_type::read); + wtec = ec; + BOOST_TEST(f.fired()); + } + { + fault_scope f(sys::shutdown, WSAENOTCONN); + sdec = c.shutdown(local_stream_socket::shutdown_both); + BOOST_TEST(f.fired()); + } + c.cancel(); + peer.cancel(); + c.close(); + peer.close(); + ioc.stop(); + }; + capy::run_async(ioc.get_executor())(body()); + capy::run_async(ioc.get_executor())(stop_guard(ioc, expired)); + ioc.run(); + BOOST_TEST(!expired); + BOOST_TEST(rec == std::errc::connection_reset); + BOOST_TEST(wec == win_err(WSAENOBUFS)); + BOOST_TEST(wtec == std::errc::not_a_socket); + BOOST_TEST(sdec == win_err(WSAENOTCONN)); + } + + void run() + { + testSchedulerConstructFails(); + testTimerCreationFails(); + testTimerThreadWaitFails(); + testStopPostFails(); + testPostFallbackRuns(); + testPostDeferredFallbackRuns(); + testInlineCompletionPostFails(); + testRunLoopDequeueFails(); + testTcpOpenFails(); + testTcpAssignFails(); + testTcpBindFails(); + testTcpOptionsFail(); + testTcpReleaseIgnoresDissociate(); + testTcpExtensionPointerMissing(); + testTcpConnectFails(); + testTcpReadWriteFails(); + testAcceptorOpenFails(); + testAcceptorOptionsFail(); + testAcceptorAssignFails(); + testAcceptFails(); + testExtensionPointersMissing(); + testUdpSetupFails(); + testUdpIoFails(); + testWaitReactorSetupFails(); + testWaitReactorStartsOnFirstWait(); + testWakeSendFails(); + testWaitReactorPollFails(); + testWaitReactorErrorProbe(); + testErrorWaitOnThisProvider(); + testWaitReactorEntryRefused(); + testLocalSetupFails(); + testLocalConnectAcceptFails(); + testLocalIoFails(); + } +}; + +TEST_SUITE(iocp_faults, "boost.corosio.fault.iocp"); + +} // boost::corosio::test::fault + +#endif diff --git a/test/unit/fault/kqueue_faults.cpp b/test/unit/fault/kqueue_faults.cpp new file mode 100644 index 000000000..40cf2efed --- /dev/null +++ b/test/unit/fault/kqueue_faults.cpp @@ -0,0 +1,537 @@ +// +// Copyright (c) 2026 Steve Gerbino +// +// Distributed under the Boost Software License, Version 1.0. (See accompanying +// file LICENSE_1_0.txt or copy at http://www.boost.org/LICENSE_1_0.txt) +// +// Official repository: https://github.com/cppalliance/corosio +// + +#include "fault.hpp" +#include "fault_test_utils.hpp" +#include "context.hpp" +#include "test_suite.hpp" +#include "test_utils.hpp" + +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include + +#include +#include + +#if BOOST_COROSIO_HAS_KQUEUE + +namespace boost::corosio::test::fault { + +namespace { + +endpoint loopback() +{ + return endpoint(ipv4_address::loopback(), 0); +} + +} // namespace + +/* kqueue-specific fault coverage. + + Every registration, interruption and wait on this backend is one + symbol, kevent(), so an arm's `nth` has to be read against the + syscall order of the operation under test rather than against a + symbol that only the run loop uses. The scheduler issues exactly one + kevent() while constructing (the EVFILT_USER registration), so a + scope armed after the io_context exists starts counting at the next + one. +*/ +struct kqueue_faults +{ + void testConstructorFails() + { + auto expect_throw = [](sys s, unsigned nth, int err, std::errc code) + { + int before = open_fds(); + fault_scope f(s, err, nth); + expect_system_error([&]{ io_context ioc(kqueue); }, code); + BOOST_TEST(f.fired()); + // Every constructor failure past kqueue() closes the queue + // before throwing (the kqueue_scheduler constructor). + BOOST_TEST_EQ(open_fds(), before); + }; + expect_throw(sys::kqueue, 1, EMFILE, + std::errc::too_many_files_open); + // The only fcntl the scheduler makes is FD_CLOEXEC on the queue. + expect_throw(sys::fcntl, 1, EINVAL, + std::errc::invalid_argument); + // 1 is the EVFILT_USER registration used to interrupt the wait. + expect_throw(sys::kevent, 1, ENOMEM, + std::errc::not_enough_memory); + } + + void testOpenFails() + { + io_context ioc(kqueue); + { + tcp_socket s(ioc); + fault_scope f(sys::socket, EMFILE); + auto ec = s.open(tcp::v4()); + BOOST_TEST(f.fired()); + BOOST_TEST(ec == std::errc::too_many_files_open); + BOOST_TEST(!s.is_open()); + } + // F_GETFL, F_SETFL(O_NONBLOCK) and F_SETFD(FD_CLOEXEC), in that + // order (kqueue_traits::set_fd_options). + for(unsigned nth : {1u, 2u, 3u}) + { + int before = open_fds(); + tcp_socket s(ioc); + fault_scope f(sys::fcntl, EINVAL, nth); + auto ec = s.open(tcp::v4()); + BOOST_TEST(f.fired()); + BOOST_TEST(ec == std::errc::invalid_argument); + BOOST_TEST(!s.is_open()); + BOOST_TEST_EQ(open_fds(), before); + } + { + // SO_NOSIGPIPE is the one setsockopt an AF_INET open + // makes, and it is fatal: this backend's write policy + // spells its writes writev() (write_policy::write) and + // write() (write_policy::write_one), neither of which carries + // per-call SIGPIPE suppression, so the socket-level flag + // is the only guard. + int before = open_fds(); + tcp_socket s(ioc); + fault_scope f(sys::setsockopt, ENOPROTOOPT); + auto ec = s.open(tcp::v4()); + BOOST_TEST(f.fired()); + BOOST_TEST(ec == std::errc::no_protocol_option); + BOOST_TEST(!s.is_open()); + BOOST_TEST_EQ(open_fds(), before); + } + { + // The socket already exists when registration fails, so the + // failure path owns closing it. + int before = open_fds(); + tcp_socket s(ioc); + fault_scope f(sys::kevent, ENOMEM); + auto ec = s.open(tcp::v4()); + BOOST_TEST(f.fired()); + BOOST_TEST(ec == std::errc::not_enough_memory); + BOOST_TEST(!s.is_open()); + BOOST_TEST_EQ(open_fds(), before); + } + // An acceptor reaches the same options through a configure + // function of its own (kqueue_traits::configure_ip_acceptor), + // which has its own error leg to leave through. + { + int before = open_fds(); + tcp_acceptor acc(ioc); + fault_scope f(sys::fcntl, EINVAL); + auto ec = acc.open(); + BOOST_TEST(f.fired()); + BOOST_TEST(ec == std::errc::invalid_argument); + BOOST_TEST(!acc.is_open()); + BOOST_TEST_EQ(open_fds(), before); + } + { + int before = open_fds(); + tcp_acceptor acc(ioc); + fault_scope f(sys::setsockopt, ENOPROTOOPT); + auto ec = acc.open(); + BOOST_TEST(f.fired()); + // v4 asks for no address-family option, so SO_NOSIGPIPE is + // the only setsockopt the open makes. + BOOST_TEST_EQ(f.count(), 1u); + BOOST_TEST(ec == std::errc::no_protocol_option); + BOOST_TEST(!acc.is_open()); + BOOST_TEST_EQ(open_fds(), before); + } + } + + void testAssignRegisterFails() + { + io_context ioc(kqueue); + int before = open_fds(); + auto h = make_native_socket(AF_INET, SOCK_STREAM); + make_native_adoptable(h); + { + // assign rolls its own registration failure back rather + // than closing anything: fd_ and registered_events return + // to their closed values and the caller keeps the + // descriptor it passed in + // (reactor_basic_socket::init_and_register). + tcp_socket s(ioc); + int held = open_fds(); + fault_scope f(sys::kevent, ENOMEM); + auto ec = s.assign(h); + BOOST_TEST(f.fired()); + BOOST_TEST(ec == std::errc::not_enough_memory); + BOOST_TEST(!s.is_open()); + BOOST_TEST_EQ(open_fds(), held); + BOOST_TEST(native_socket_valid(h)); + } + close_native_socket(h); + BOOST_TEST_EQ(open_fds(), before); + } + + void testAcceptorRegisterFails() + { + io_context ioc(kqueue); + tcp_acceptor acc(ioc); + BOOST_TEST(!acc.open()); + BOOST_TEST(!acc.bind(loopback())); + // An acceptor reaches register_descriptor for the first time at + // listen (reactor_acceptor::do_listen); open only creates and + // configures the fd. + fault_scope f(sys::kevent, ENOMEM); + BOOST_TEST(acc.listen() == std::errc::not_enough_memory); + BOOST_TEST(f.fired()); + // Not latched: registered_events stayed 0, so a second listen + // re-registers (kqueue_scheduler::register_descriptor). + BOOST_TEST(!acc.listen()); + } + + void testAcceptFails() + { + io_context ioc(kqueue); + tcp_acceptor acc(ioc, loopback()); + std::error_code aec, aec2; + int leaked = 0; + auto body = [&]() -> capy::task<> + { + tcp_socket client(ioc), server(ioc); + { + auto [ec] = co_await client.connect(acc.local_endpoint()); + BOOST_TEST(!ec); + } + { + // EINTR is retried inside accept_policy, then the real + // accept succeeds (accept_policy::do_accept). + fault_scope f(sys::accept, EINTR); + auto [ec] = co_await acc.accept(server); + BOOST_TEST(f.fired()); + BOOST_TEST(!ec); + } + server.close(); + client.close(); + tcp_socket client2(ioc); + { + auto [ec] = co_await client2.connect(acc.local_endpoint()); + BOOST_TEST(!ec); + } + { + // The faulted accept never ran, so the queued connection + // survives for the retry below. + fault_scope f(sys::accept, ECONNABORTED); + auto [ec] = co_await acc.accept(server); + aec = ec; + BOOST_TEST(f.fired()); + BOOST_TEST(!server.is_open()); + } + { + auto [ec] = co_await acc.accept(server); + BOOST_TEST(!ec); + } + server.close(); + client2.close(); + { + // The accepted fd fails to register: the impl is + // destroyed, which closes it, and the error is reported + // (reactor_acceptor_impl::accept). + tcp_socket client3(ioc); + auto [cec] = co_await client3.connect(acc.local_endpoint()); + BOOST_TEST(!cec); + int before = open_fds(); + fault_scope f(sys::kevent, ENOMEM); + auto [ec] = co_await acc.accept(server); + aec2 = ec; + leaked = open_fds() - before; + BOOST_TEST(f.fired()); + BOOST_TEST(!server.is_open()); + client3.close(); + } + }; + capy::run_async(ioc.get_executor())(body()); + ioc.run(); + BOOST_TEST(aec == std::errc::connection_aborted); + BOOST_TEST(aec2 == std::errc::not_enough_memory); + BOOST_TEST_EQ(leaked, 0); + } + + void testAcceptConfigureFails() + { + io_context ioc(kqueue); + tcp_acceptor acc(ioc, loopback()); + std::error_code fcntl_ecs[3], sockopt_ec; + int fcntl_leaked[3] = {}, sockopt_leaked = 0; + auto body = [&]() -> capy::task<> + { + // Each failure closes the descriptor accept() already + // handed back, consuming the queued connection, so every + // iteration needs a client of its own. + for(unsigned nth = 1; nth <= 3; ++nth) + { + tcp_socket c(ioc), s(ioc); + { + auto [ec] = co_await c.connect(acc.local_endpoint()); + BOOST_TEST(!ec); + } + int before = open_fds(); + // Armed after the connect, so 1..3 are the F_GETFL, + // F_SETFL and F_SETFD accept_policy makes on the + // accepted fd (accept_policy::do_accept). + fault_scope f(sys::fcntl, EINVAL, nth); + auto [ec] = co_await acc.accept(s); + fcntl_ecs[nth - 1] = ec; + fcntl_leaked[nth - 1] = open_fds() - before; + BOOST_TEST(f.fired()); + BOOST_TEST(!s.is_open()); + c.close(); + } + { + tcp_socket c(ioc), s(ioc); + { + auto [ec] = co_await c.connect(acc.local_endpoint()); + BOOST_TEST(!ec); + } + int before = open_fds(); + // SO_NOSIGPIPE on the accepted fd, fatal for the + // same reason (accept_policy::do_accept). + fault_scope f(sys::setsockopt, ENOPROTOOPT); + auto [ec] = co_await acc.accept(s); + sockopt_ec = ec; + sockopt_leaked = open_fds() - before; + BOOST_TEST(f.fired()); + BOOST_TEST(!s.is_open()); + c.close(); + } + }; + capy::run_async(ioc.get_executor())(body()); + ioc.run(); + for(unsigned i = 0; i < 3; ++i) + { + BOOST_TEST(fcntl_ecs[i] == std::errc::invalid_argument); + BOOST_TEST_EQ(fcntl_leaked[i], 0); + } + BOOST_TEST(sockopt_ec == std::errc::no_protocol_option); + BOOST_TEST_EQ(sockopt_leaked, 0); + } + + void testRunLoopFaults() + { + // Nothing calls kevent between the constructor and the run + // loop's first wait: posting the task does not interrupt a + // reactor that is not running yet + // (reactor_scheduler::wake_one_thread_and_unlock), so nth 1 is + // that wait. + { + io_context ioc(kqueue); + fault_scope f(sys::kevent, EINTR); + bool done = false; + auto body = [&]() -> capy::task<> + { + std::ignore = co_await corosio::delay( + std::chrono::milliseconds(1)); + done = true; + }; + capy::run_async(ioc.get_executor())(body()); + ioc.run(); + BOOST_TEST(f.fired()); + BOOST_TEST(done); + } + { + // Any other errno leaves run() through an exception + // (kqueue_scheduler::run_task). + io_context ioc(kqueue); + fault_scope f(sys::kevent, EBADF); + auto body = [&]() -> capy::task<> + { + std::ignore = co_await corosio::delay( + std::chrono::milliseconds(1)); + }; + capy::run_async(ioc.get_executor())(body()); + expect_system_error([&]{ ioc.run(); }, + std::errc::bad_file_descriptor); + BOOST_TEST(f.fired()); + } + } + + /* A NOTE_TRIGGER that never left has to be retried, not swallowed. + + `user_event_armed_` is set before the kevent, so it stands for a + trigger queued on the kqueue. A kevent that failed queued none: + leaving the flag set would coalesce every later interrupt into a + wake that does not exist, and a wait only an interrupt could end + would never end. The second arm is the observable — it can only + fire if the second interrupt actually reached kevent. + */ + void testInterruptTriggerFails() + { + io_context ioc(kqueue); + { + // stop() is the one path that reaches NOTE_TRIGGER without + // a reactor thread (reactor_scheduler::stop), and it + // interrupts only on the transition, so each stop below is + // one kevent. + fault_scope first(sys::kevent, EIO, 1); + fault_scope second(sys::kevent, EIO, 2); + ioc.stop(); + BOOST_TEST(first.fired()); + BOOST_TEST(!second.fired()); + ioc.restart(); + ioc.stop(); + BOOST_TEST(second.fired()); + } + // And the loop the failed wakes were aimed at still runs: timed + // work fires and a stop from inside ends run(). + ioc.restart(); + bool done = false; + auto body = [&]() -> capy::task<> + { + std::ignore = co_await corosio::delay( + std::chrono::milliseconds(1)); + done = true; + ioc.stop(); + }; + capy::run_async(ioc.get_executor())(body()); + ioc.run(); + BOOST_TEST(done); + } + + void testSignalReaderRegisterFails() + { + in_child([]{ + io_context ioc(kqueue); + signal_set ss(ioc); + std::error_code ec; + bool fired = false; + { + // The self-pipe's pipe() and six fcntl() calls come + // first; the kevent is the reader registration + // (posix_signal_service::add_signal). + fault_scope f(sys::kevent, ENOMEM); + ec = ss.add(SIGUSR2); + fired = f.fired(); + } + // Not latched: the next add retries the registration. + return fired && ec == std::errc::not_enough_memory && + !ss.add(SIGUSR2) && !ss.clear(); + }); + } + + void testAcceptorAssignRegisterFails() + { + io_context ioc(kqueue); + auto h = make_native_socket(AF_INET, SOCK_STREAM); + BOOST_TEST(static_cast(h) >= 0); + make_native_adoptable(h); + sockaddr_in sa{}; + sa.sin_family = AF_INET; + sa.sin_addr.s_addr = htonl(INADDR_LOOPBACK); + BOOST_TEST_EQ(::bind(static_cast(h), + reinterpret_cast(&sa), sizeof(sa)), 0); + BOOST_TEST_EQ(::listen(static_cast(h), 1), 0); + + int const before = open_fds(); + tcp_acceptor acc(ioc); + { + // Nothing else on the adopt path reaches kevent, so the + // first call is the registration itself. + fault_scope f(sys::kevent, ENOMEM); + auto ec = acc.assign(h); + BOOST_TEST(f.fired()); + BOOST_TEST_EQ(f.count(), 1u); + BOOST_TEST(ec == std::errc::not_enough_memory); + } + // The rollback puts the acceptor back where it was and leaves + // the caller owning the descriptor it passed in + // (reactor_acceptor::init_and_register). + BOOST_TEST(!acc.is_open()); + BOOST_TEST(native_socket_valid(h)); + BOOST_TEST_EQ(open_fds(), before); + BOOST_TEST(!acc.assign(h)); + acc.close(); + } + + /* Registering the descriptor the reactor's own accept produced. + + An accept that runs inline registers from the initiator; one the + reactor dispatches registers from the completion instead, which + is a different arm with a rollback of its own. A first accept + with no peer waiting reports EAGAIN and parks the operation, so + the connection has to arrive afterwards for the retry to be the + reactor's. + */ + void testPostedAcceptRegisterFails() + { + io_context ioc(kqueue); + tcp_acceptor acc(ioc, loopback()); + tcp_socket client(ioc), server(ioc); + // Opened before any arm so its own registration is not counted. + BOOST_TEST(!client.open(tcp::v4())); + std::error_code aec; + int leaked = 0; + unsigned calls = 0; + auto accept_body = [&]() -> capy::task<> + { + int const before = open_fds(); + // Both filters of both descriptors are already registered, + // so the next descriptor the kqueue is asked to add is the + // one the reactor's retry accepts. Counting registrations + // rather than kevent calls keeps the arm off the waits the + // run loop makes while the connection is on its way. + fault_scope f(sys::kevent_register, ENOMEM); + auto [ec] = co_await acc.accept(server); + aec = ec; + calls = f.count(); + // The peer implementation owns the accepted descriptor by + // then, so destroying it is what closes it. + leaked = open_fds() - before; + BOOST_TEST(f.fired()); + }; + auto connect_body = [&]() -> capy::task<> + { + auto [ec] = co_await client.connect(acc.local_endpoint()); + BOOST_TEST(!ec); + }; + capy::run_async(ioc.get_executor())(accept_body()); + capy::run_async(ioc.get_executor())(connect_body()); + ioc.run(); + BOOST_TEST_EQ(calls, 1u); + BOOST_TEST(aec == std::errc::not_enough_memory); + BOOST_TEST(!server.is_open()); + BOOST_TEST_EQ(leaked, 0); + } + + void run() + { + if(skip_under_valgrind()) + return; + testConstructorFails(); + testOpenFails(); + testAssignRegisterFails(); + testAcceptorRegisterFails(); + testAcceptorAssignRegisterFails(); + testAcceptFails(); + testPostedAcceptRegisterFails(); + testAcceptConfigureFails(); + testRunLoopFaults(); + testInterruptTriggerFails(); + testSignalReaderRegisterFails(); + } +}; + +TEST_SUITE(kqueue_faults, "boost.corosio.fault.kqueue"); + +} // boost::corosio::test::fault + +#endif diff --git a/test/unit/fault/posix_faults.cpp b/test/unit/fault/posix_faults.cpp new file mode 100644 index 000000000..7d77ce7ad --- /dev/null +++ b/test/unit/fault/posix_faults.cpp @@ -0,0 +1,674 @@ +// +// Copyright (c) 2026 Steve Gerbino +// +// Distributed under the Boost Software License, Version 1.0. (See accompanying +// file LICENSE_1_0.txt or copy at http://www.boost.org/LICENSE_1_0.txt) +// +// Official repository: https://github.com/cppalliance/corosio +// + +#include "fault.hpp" +#include "fault_test_utils.hpp" +#include "context.hpp" +#include "test_suite.hpp" +#include "test_utils.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include + +namespace boost::corosio::test::fault { + +template +struct posix_common_faults +{ + // The io_uring backend has its own file implementation: reads and + // writes go through the ring instead of preadv/pwritev, seek uses + // lseek instead of fstat, and open never stats an appending file. +#if BOOST_COROSIO_HAS_IO_URING + static constexpr bool ring_files = std::is_same_v< + std::remove_cvref_t, io_uring_t>; +#else + static constexpr bool ring_files = false; +#endif + + // macOS has no fdatasync, so sync_data() lowers to fsync there + // (posix_stream_file::sync_data and + // posix_random_access_file::sync_data). Keying off the library's + // own macro rather than __APPLE__ keeps the two in step. +#if BOOST_COROSIO_HAS_POSIX_SYNCHRONIZED_IO + static constexpr sys sync_data_call = sys::fdatasync; +#else + static constexpr sys sync_data_call = sys::fsync; +#endif + + // Functional probe: proves the library's own socket() call binds + // to the hook on this backend, whatever the link mode. + void testSocketOpenFails() + { + io_context ioc(Backend); + tcp_socket s(ioc); + fault_scope f(sys::socket, EMFILE); + auto ec = s.open(tcp::v4()); + BOOST_TEST(f.fired()); + BOOST_TEST(ec == std::errc::too_many_files_open); + BOOST_TEST(!s.is_open()); + } + + void testBindFails() + { + io_context ioc(Backend); + tcp_socket s(ioc); + BOOST_TEST(!s.open(tcp::v4())); + fault_scope f(sys::bind, EADDRINUSE); + auto ec = s.bind(endpoint(ipv4_address::loopback(), 0)); + BOOST_TEST(f.fired()); + BOOST_TEST(ec == std::errc::address_in_use); + BOOST_TEST(s.is_open()); + } + + void testSetOptionFails() + { + io_context ioc(Backend); + tcp_socket s(ioc); + BOOST_TEST(!s.open(tcp::v4())); + fault_scope f(sys::setsockopt, ENOPROTOOPT); + expect_system_error( + [&]{ s.set_option(socket_option::reuse_address(true)); }, + std::errc::no_protocol_option); + BOOST_TEST(f.fired()); + BOOST_TEST(s.is_open()); + } + + void testGetOptionFails() + { + io_context ioc(Backend); + tcp_socket s(ioc); + BOOST_TEST(!s.open(tcp::v4())); + fault_scope f(sys::getsockopt, ENOPROTOOPT); + expect_system_error( + [&]{ std::ignore = s.get_option(); }, + std::errc::no_protocol_option); + BOOST_TEST(f.fired()); + BOOST_TEST(s.is_open()); + } + + void testAssignValidateFails() + { + io_context ioc(Backend); + int before = open_fds(); + auto h = make_native_socket(AF_INET, SOCK_STREAM); + make_native_adoptable(h); + { + tcp_socket s(ioc); + fault_scope f(sys::getsockname, EBADF); + auto ec = s.assign(h); + BOOST_TEST(f.fired()); + BOOST_TEST(ec == std::errc::bad_file_descriptor); + BOOST_TEST(!s.is_open()); + } + { + tcp_socket s(ioc); + fault_scope f(sys::getsockopt, EBADF); + auto ec = s.assign(h); + BOOST_TEST(f.fired()); + BOOST_TEST(ec == std::errc::bad_file_descriptor); + BOOST_TEST(!s.is_open()); + } + BOOST_TEST(native_socket_valid(h)); + close_native_socket(h); + BOOST_TEST_EQ(open_fds(), before); + } + + void testConnectPairFails() + { + io_context ioc(Backend); + int before = open_fds(); + { + local_stream_socket a(ioc), b(ioc); + fault_scope f(sys::socketpair, EMFILE); + auto ec = connect_pair(a, b); + BOOST_TEST(f.fired()); + BOOST_TEST(ec == std::errc::too_many_files_open); + BOOST_TEST(!a.is_open() && !b.is_open()); + } + // 1 is the F_GETFL probe, 2 the F_SETFL that follows it. + for(unsigned nth : {1u, 2u}) + { + local_stream_socket a(ioc), b(ioc); + fault_scope f(sys::fcntl, EINVAL, nth); + auto ec = connect_pair(a, b); + BOOST_TEST(f.fired()); + BOOST_TEST(ec == std::errc::invalid_argument); + BOOST_TEST(!a.is_open() && !b.is_open()); + } + // assign() interrogates the descriptor once with + // getsockopt(SO_TYPE), so the ordinal selects which half of + // the pair is refused. Either way both descriptors go back. + for(unsigned nth : {1u, 2u}) + { + local_stream_socket a(ioc), b(ioc); + fault_scope f(sys::getsockopt, EBADF, nth); + auto ec = connect_pair(a, b); + BOOST_TEST(f.fired()); + BOOST_TEST_EQ(f.count(), nth); + BOOST_TEST(ec == std::errc::bad_file_descriptor); + BOOST_TEST(!a.is_open() && !b.is_open()); + } + { + local_datagram_socket a(ioc), b(ioc); + fault_scope f(sys::socketpair, EMFILE); + auto ec = connect_pair(a, b); + BOOST_TEST(f.fired()); + BOOST_TEST(ec == std::errc::too_many_files_open); + BOOST_TEST(!a.is_open() && !b.is_open()); + } + { + local_datagram_socket a(ioc), b(ioc); + BOOST_TEST(!connect_pair(a, b)); + } + BOOST_TEST_EQ(open_fds(), before); + } + + void testDatagramAvailableThrows() + { + io_context ioc(Backend); + local_datagram_socket a(ioc), b(ioc); + BOOST_TEST(!connect_pair(a, b)); + fault_scope f(sys::ioctl, EBADF); + expect_system_error( + [&]{ std::ignore = a.available(); }, + std::errc::bad_file_descriptor); + BOOST_TEST(f.fired()); + BOOST_TEST(a.is_open()); + } + + void testDatagramBindFails() + { + io_context ioc(Backend); + auto path = temp_path("ldb"); + ::unlink(path.c_str()); + local_datagram_socket s(ioc); + BOOST_TEST(!s.open()); + // An already-open socket takes the early return rather than + // replacing the descriptor it holds. + BOOST_TEST(!s.open()); + { + fault_scope f(sys::bind, EACCES); + BOOST_TEST(s.bind(corosio::local_endpoint(path)) == + std::errc::permission_denied); + BOOST_TEST(f.fired()); + BOOST_TEST(s.is_open()); + } + BOOST_TEST(!s.bind(corosio::local_endpoint(path))); + s.close(); + ::unlink(path.c_str()); + } + + /* The convenience constructors report through an exception, since + there is no object yet to hand an error code back on. Each leg + has its own `what`, which is the only way a caller can tell + which step of open/bind/listen refused. + */ + void testAcceptorConstructorThrows() + { + io_context ioc(Backend); + { + int before = open_fds(); + fault_scope f(sys::listen, EADDRINUSE); + expect_system_error( + [&]{ + tcp_acceptor acc( + ioc, endpoint(ipv4_address::loopback(), 0)); + }, + std::errc::address_in_use); + BOOST_TEST(f.fired()); + BOOST_TEST_EQ(open_fds(), before); + } + auto path = temp_path("lsa"); + { + ::unlink(path.c_str()); + int before = open_fds(); + fault_scope f(sys::socket, EMFILE); + expect_system_error( + [&]{ + local_stream_acceptor acc( + ioc, corosio::local_endpoint(path)); + }, + std::errc::too_many_files_open); + BOOST_TEST(f.fired()); + BOOST_TEST_EQ(open_fds(), before); + } + { + // bind() leaves the socket node behind, and a second bind + // to a path that exists fails for its own reason. + ::unlink(path.c_str()); + int before = open_fds(); + fault_scope f(sys::listen, EADDRINUSE); + expect_system_error( + [&]{ + local_stream_acceptor acc( + ioc, corosio::local_endpoint(path)); + }, + std::errc::address_in_use); + BOOST_TEST(f.fired()); + BOOST_TEST_EQ(open_fds(), before); + } + ::unlink(path.c_str()); + } + + void testAvailableThrows() + { + io_context ioc(Backend); + local_stream_socket a(ioc), b(ioc); + BOOST_TEST(!connect_pair(a, b)); + fault_scope f(sys::ioctl, ENOTTY); + expect_system_error( + [&]{ std::ignore = a.available(); }, + std::errc::inappropriate_io_control_operation); + BOOST_TEST(f.fired()); + BOOST_TEST(a.is_open()); + } + + void testHostNameFails() + { + fault_scope f(sys::gethostname, EPERM); + auto [ec, name] = host_name(); + BOOST_TEST(f.fired()); + BOOST_TEST(ec == std::errc::operation_not_permitted); + BOOST_TEST(name.empty()); + } + + void testStreamFileOpenFails() + { + io_context ioc(Backend); + auto path = temp_path("sf"); + stream_file sf(ioc); + { + fault_scope f(sys::open, EACCES); + auto ec = sf.open(path, file_base::read_write | file_base::create); + BOOST_TEST(f.fired()); + BOOST_TEST(ec == std::errc::permission_denied); + BOOST_TEST(!sf.is_open()); + } + // Only the POSIX backend seeds its own offset with fstat when + // opening for append; the ring backend leaves that to O_APPEND. + if constexpr(!ring_files) + { + // fstat is unshadowed on pre-2.33 glibc (an inline redirect to + // __fxstat), so the append-offset seed cannot be faulted there. + if(!hook_is_live(sys::fstat)) + { + skip_dead_hook("fstat"); + } + else + { + int before = open_fds(); + fault_scope f(sys::fstat, EIO); + auto ec = sf.open(path, file_base::write_only | + file_base::create | file_base::append); + BOOST_TEST(f.fired()); + BOOST_TEST(ec == std::errc::io_error); + BOOST_TEST(!sf.is_open()); + BOOST_TEST_EQ(open_fds(), before); + } + } + ::unlink(path.c_str()); + } + + void testStreamFileSyncOps() + { + io_context ioc(Backend); + auto path = temp_path("sf2"); + stream_file sf(ioc); + BOOST_TEST(!sf.open(path, file_base::read_write | file_base::create)); + if(!hook_is_live(sys::fstat)) + { + skip_dead_hook("fstat"); + } + else + { + fault_scope f(sys::fstat, EIO); + expect_system_error( + [&]{ std::ignore = sf.size(); }, std::errc::io_error); + BOOST_TEST(f.fired()); + } + { + fault_scope f(sys::ftruncate, EFBIG); + BOOST_TEST(sf.resize(16) == std::errc::file_too_large); + BOOST_TEST(f.fired()); + } + { + fault_scope f(sync_data_call, EIO); + BOOST_TEST(sf.sync_data() == std::errc::io_error); + BOOST_TEST(f.fired()); + } + { + fault_scope f(sys::fsync, EIO); + BOOST_TEST(sf.sync_all() == std::errc::io_error); + BOOST_TEST(f.fired()); + } + { + constexpr sys seek_end_call = ring_files ? sys::lseek : sys::fstat; + if(!hook_is_live(seek_end_call)) + { + skip_dead_hook(ring_files ? "lseek" : "fstat"); + } + else + { + fault_scope f(seek_end_call, EIO); + auto [ec, pos] = sf.seek(0, file_base::seek_end); + BOOST_TEST(f.fired()); + BOOST_TEST(ec == std::errc::io_error); + BOOST_TEST_EQ(pos, 0u); + } + } + sf.close(); + ::unlink(path.c_str()); + } + + void testStreamFileIoFails() + { + // Ring file I/O never reaches preadv/pwritev; its completion + // faults belong with the other io_uring coverage. + if constexpr(ring_files) + return; + io_context ioc(Backend); + auto path = temp_path("sf3"); + stream_file sf(ioc); + BOOST_TEST(!sf.open(path, file_base::read_write | file_base::create)); + char buf[8] = "1234567"; + std::error_code rec, wec, eec; + std::size_t rn = 99, en = 99; + auto t = [&]() -> capy::task<> + { + // The syscall runs on a pool thread, so the arm has to be + // visible outside this one. + { + fault_scope f(sys::pwritev, ENOSPC, 1, any_thread); + auto [ec, n] = co_await sf.write_some( + capy::const_buffer(buf, 7)); + std::ignore = n; + wec = ec; + BOOST_TEST(f.fired()); + } + { + auto [ec, n] = co_await sf.write_some( + capy::const_buffer(buf, 7)); + std::ignore = n; + BOOST_TEST(!ec); + } + { + auto [ec, pos] = sf.seek(0, file_base::seek_set); + std::ignore = pos; + BOOST_TEST(!ec); + } + { + fault_scope f(sys::preadv, EIO, 1, any_thread); + auto [ec, n] = co_await sf.read_some( + capy::mutable_buffer(buf, 7)); + rec = ec; + rn = n; + BOOST_TEST(f.fired()); + } + { + auto f = fault_scope::returning_any_thread(sys::preadv, 0); + auto [ec, n] = co_await sf.read_some( + capy::mutable_buffer(buf, 7)); + eec = ec; + en = n; + BOOST_TEST(f.fired()); + } + }; + capy::run_async(ioc.get_executor())(t()); + ioc.run(); + BOOST_TEST(wec == std::errc::no_space_on_device); + BOOST_TEST(rec == std::errc::io_error); + BOOST_TEST_EQ(rn, 0u); + BOOST_TEST(eec == capy::error::eof); + BOOST_TEST_EQ(en, 0u); + sf.close(); + ::unlink(path.c_str()); + } + + void testRandomAccessFileFails() + { + io_context ioc(Backend); + auto path = temp_path("raf"); + random_access_file rf(ioc); + { + fault_scope f(sys::open, EACCES); + BOOST_TEST(rf.open(path, file_base::read_write | + file_base::create) == std::errc::permission_denied); + BOOST_TEST(f.fired()); + } + BOOST_TEST(!rf.open(path, file_base::read_write | file_base::create)); + if(!hook_is_live(sys::fstat)) + { + skip_dead_hook("fstat"); + } + else + { + fault_scope f(sys::fstat, EIO); + expect_system_error( + [&]{ std::ignore = rf.size(); }, std::errc::io_error); + BOOST_TEST(f.fired()); + } + { + fault_scope f(sys::ftruncate, EFBIG); + BOOST_TEST(rf.resize(16) == std::errc::file_too_large); + BOOST_TEST(f.fired()); + } + { + fault_scope f(sync_data_call, EIO); + BOOST_TEST(rf.sync_data() == std::errc::io_error); + BOOST_TEST(f.fired()); + } + { + fault_scope f(sys::fsync, EIO); + BOOST_TEST(rf.sync_all() == std::errc::io_error); + BOOST_TEST(f.fired()); + } + // Ring file I/O never reaches preadv/pwritev; its completion + // faults belong with the other io_uring coverage. + if constexpr(ring_files) + { + rf.close(); + ::unlink(path.c_str()); + return; + } + char buf[8] = "1234567"; + std::error_code rec, wec, eec; + auto t = [&]() -> capy::task<> + { + { + fault_scope f(sys::pwritev, ENOSPC, 1, any_thread); + auto [ec, n] = co_await rf.write_some_at( + 0, capy::const_buffer(buf, 7)); + std::ignore = n; + wec = ec; + BOOST_TEST(f.fired()); + } + { + auto [ec, n] = co_await rf.write_some_at( + 0, capy::const_buffer(buf, 7)); + std::ignore = n; + BOOST_TEST(!ec); + } + { + fault_scope f(sys::preadv, EIO, 1, any_thread); + auto [ec, n] = co_await rf.read_some_at( + 0, capy::mutable_buffer(buf, 7)); + std::ignore = n; + rec = ec; + BOOST_TEST(f.fired()); + } + { + auto f = fault_scope::returning_any_thread(sys::preadv, 0); + auto [ec, n] = co_await rf.read_some_at( + 0, capy::mutable_buffer(buf, 7)); + std::ignore = n; + eec = ec; + BOOST_TEST(f.fired()); + } + }; + capy::run_async(ioc.get_executor())(t()); + ioc.run(); + BOOST_TEST(wec == std::errc::no_space_on_device); + BOOST_TEST(rec == std::errc::io_error); + BOOST_TEST(eec == capy::error::eof); + rf.close(); + ::unlink(path.c_str()); + } + + void testResolverFails() + { + io_context ioc(Backend); + resolver r(ioc); + std::error_code fec, rec; + auto t = [&]() -> capy::task<> + { + { + fault_scope f(sys::getaddrinfo, EAI_FAIL, 1, any_thread); + auto [ec, results] = co_await r.resolve("localhost", "80"); + std::ignore = results; + fec = ec; + BOOST_TEST(f.fired()); + } + { + fault_scope f(sys::getnameinfo, EAI_FAIL, 1, any_thread); + auto [ec, result] = co_await r.resolve( + endpoint(ipv4_address::loopback(), 80)); + std::ignore = result; + rec = ec; + BOOST_TEST(f.fired()); + } + }; + capy::run_async(ioc.get_executor())(t()); + ioc.run(); + BOOST_TEST(fec == std::errc::io_error); + BOOST_TEST(rec == std::errc::io_error); + } + + // The signal service's shutdown walks the implementations it still + // owns, giving back each registration hanging off them. A set held + // in an abandoned coroutine frame is the only way to reach that + // walk; the frame dies with the child rather than being reported + // against the suite. + void testSignalTeardownWalk() + { + in_child([]{ + bool resumed = false; + { + io_context ioc(Backend); + auto keeper = [&]() -> capy::task<> { + signal_set sig(ioc, SIGINT); + std::ignore = co_await sig.wait(); + resumed = true; + }; + capy::run_async(ioc.get_executor())(keeper()); + // Exactly one handler, and it has to be the coroutine + // start: a zero here would leave nothing parked and the + // walk with nothing to reclaim. + if(ioc.run_one() != 1) + return false; + } + struct sigaction cur = {}; + if(::sigaction(SIGINT, nullptr, &cur) < 0) + return false; + return !resumed && cur.sa_handler == SIG_DFL; + }); + } + + void run() + { + if(skip_under_valgrind()) + return; + testSocketOpenFails(); + testBindFails(); + testSetOptionFails(); + testGetOptionFails(); + testAssignValidateFails(); + testConnectPairFails(); + testDatagramAvailableThrows(); + testDatagramBindFails(); + testAcceptorConstructorThrows(); + testAvailableThrows(); + testHostNameFails(); + testStreamFileOpenFails(); + testStreamFileSyncOps(); + testStreamFileIoFails(); + testRandomAccessFileFails(); + testResolverFails(); + testSignalTeardownWalk(); + } +}; + +COROSIO_NON_IOCP_BACKEND_TESTS(posix_common_faults, "boost.corosio.fault.posix") + +template +struct reactor_acceptor_option_faults +{ + void testAcceptorOptions() + { + io_context ioc(Backend); + tcp_acceptor acc(ioc); + BOOST_TEST(!acc.open()); + { + fault_scope f(sys::setsockopt, ENOPROTOOPT); + expect_system_error( + [&]{ acc.set_option(socket_option::reuse_address(true)); }, + std::errc::no_protocol_option); + BOOST_TEST(f.fired()); + BOOST_TEST(acc.is_open()); + } + { + fault_scope f(sys::getsockopt, ENOPROTOOPT); + expect_system_error( + [&]{ + std::ignore = + acc.get_option(); + }, + std::errc::no_protocol_option); + BOOST_TEST(f.fired()); + BOOST_TEST(acc.is_open()); + } + } + + void run() + { + if(skip_under_valgrind()) + return; + testAcceptorOptions(); + } +}; + +COROSIO_REACTOR_BACKEND_TESTS( + reactor_acceptor_option_faults, "boost.corosio.fault.posix.acceptor_opts") + +} // boost::corosio::test::fault diff --git a/test/unit/fault/reactor_faults.cpp b/test/unit/fault/reactor_faults.cpp new file mode 100644 index 000000000..dd0b408e8 --- /dev/null +++ b/test/unit/fault/reactor_faults.cpp @@ -0,0 +1,17 @@ +// +// Copyright (c) 2026 Steve Gerbino +// +// Distributed under the Boost Software License, Version 1.0. (See accompanying +// file LICENSE_1_0.txt or copy at http://www.boost.org/LICENSE_1_0.txt) +// +// Official repository: https://github.com/cppalliance/corosio +// + +#include "reactor_faults.hpp" + +namespace boost::corosio::test::fault { + +COROSIO_REACTOR_BACKEND_TESTS( + reactor_common_faults, "boost.corosio.fault.reactor"); + +} // boost::corosio::test::fault diff --git a/test/unit/fault/reactor_faults.hpp b/test/unit/fault/reactor_faults.hpp new file mode 100644 index 000000000..8b1dba868 --- /dev/null +++ b/test/unit/fault/reactor_faults.hpp @@ -0,0 +1,1241 @@ +// +// Copyright (c) 2026 Steve Gerbino +// +// Distributed under the Boost Software License, Version 1.0. (See accompanying +// file LICENSE_1_0.txt or copy at http://www.boost.org/LICENSE_1_0.txt) +// +// Official repository: https://github.com/cppalliance/corosio +// + +#ifndef BOOST_COROSIO_TEST_FAULT_REACTOR_FAULTS_HPP +#define BOOST_COROSIO_TEST_FAULT_REACTOR_FAULTS_HPP + +#include "fault.hpp" +#include "fault_test_utils.hpp" +#include "context.hpp" +#include "test_suite.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include + +namespace boost::corosio::test::fault { + +/* Fault tests that hold for every reactor backend. + + The syscalls these arm live in the shared reactor sources + (reactor_op.hpp, reactor_descriptor_state.hpp, + reactor_stream_socket.hpp, reactor_datagram_socket.hpp) or in + traits that spell them identically on epoll and select, so one body + covers both. Backend-specific registration and run-loop faults stay + in the per-backend files. +*/ +template +struct reactor_common_faults +{ +#if BOOST_COROSIO_HAS_SELECT + static constexpr bool is_select = std::is_same_v< + std::remove_cvref_t, select_t>; +#else + static constexpr bool is_select = false; +#endif + +#if BOOST_COROSIO_HAS_KQUEUE + static constexpr bool is_kqueue = std::is_same_v< + std::remove_cvref_t, kqueue_t>; +#else + static constexpr bool is_kqueue = false; +#endif + + // Every backend routes a write through its own write_policy, so the + // symbol to arm differs. kqueue spells write_policy::write_one + // write() and write_policy::write writev(); epoll and select spell + // the same two send(MSG_NOSIGNAL) and sendmsg(), falling back to + // write() where the platform has no MSG_NOSIGNAL. Darwin defines + // it, so select on macOS arms exactly as it does on Linux. +#if defined(MSG_NOSIGNAL) + static constexpr sys spec_write = is_kqueue ? sys::write : sys::send; +#else + static constexpr sys spec_write = sys::write; +#endif + static constexpr sys vec_write = + is_kqueue ? sys::writev : sys::sendmsg; + + static endpoint loopback() + { + return endpoint(ipv4_address::loopback(), 0); + } + + /* Connect a pair whose sender cannot outrun its peer. + + The send buffer has to be shrunk before the handshake: Darwin + auto-tunes an established socket, so a size set afterwards no + longer binds (wait.cpp, make_backpressured_pair). The accepted socket + inherits the listener's, so both ends are shrunk here. The + receive buffers keep their defaults: shrinking those closes the + TCP window, and the parked write then waits on the persist timer + rather than on the reader, which costs seconds on a backend that + has no latched write readiness to short-circuit it. + */ + static std::pair + make_backpressured_pair(io_context& ioc) + { + auto ex = ioc.get_executor(); + tcp_acceptor acc(ioc); + BOOST_TEST(!acc.open()); + acc.set_option(socket_option::reuse_address(true)); + acc.set_option(socket_option::send_buffer_size(1024)); + // set_option throws rather than reporting, so reaching here + // only proves the call was accepted. Every kernel clamps and + // rescales the request, so read the effective size back: what + // the caller depends on is that it is far below the payload, + // not that it is 1 KiB. + BOOST_TEST( + acc.get_option().value() < + 64 * 1024); + BOOST_TEST(!acc.bind(loopback())); + BOOST_TEST(!acc.listen()); + auto port = acc.local_endpoint().port(); + + tcp_socket s1(ioc), s2(ioc); + BOOST_TEST(!s2.open(tcp::v4())); + s2.set_option(socket_option::send_buffer_size(1024)); + BOOST_TEST( + s2.get_option().value() < + 64 * 1024); + + std::error_code aec, cec; + auto accept_task = [&]() -> capy::task<> + { + auto [ec] = co_await acc.accept(s1); + aec = ec; + }; + auto connect_task = [&]() -> capy::task<> + { + auto [ec] = co_await s2.connect( + endpoint(ipv4_address::loopback(), port)); + cec = ec; + }; + capy::run_async(ex)(accept_task()); + capy::run_async(ex)(connect_task()); + ioc.run(); + ioc.restart(); + BOOST_TEST(!aec); + BOOST_TEST(!cec); + return {std::move(s1), std::move(s2)}; + } + + void testAcceptorFails() + { + io_context ioc(Backend); + tcp_acceptor acc(ioc); + { + fault_scope f(sys::socket, EMFILE); + BOOST_TEST(acc.open() == std::errc::too_many_files_open); + BOOST_TEST(f.fired()); + } + BOOST_TEST(!acc.open()); + { + fault_scope f(sys::bind, EACCES); + BOOST_TEST(acc.bind(loopback()) == std::errc::permission_denied); + BOOST_TEST(f.fired()); + } + BOOST_TEST(!acc.bind(loopback())); + { + fault_scope f(sys::listen, EADDRINUSE); + BOOST_TEST(acc.listen() == std::errc::address_in_use); + BOOST_TEST(f.fired()); + } + // Not latched: the failed listen left the acceptor open and + // unregistered, so a second listen still succeeds. + BOOST_TEST(!acc.listen()); + { + int before = open_fds(); + fault_scope f(sys::socket, EMFILE); + expect_system_error( + [&]{ tcp_acceptor a2(ioc, loopback()); }, + std::errc::too_many_files_open); + BOOST_TEST(f.fired()); + BOOST_TEST_EQ(open_fds(), before); + } + } + + void testConnectFails() + { + io_context ioc(Backend); + tcp_acceptor acc(ioc, loopback()); + // A faulted EINPROGRESS parks the connect op on a descriptor + // the real connect() never touched. BSD never reports such a + // socket as writable -- sowriteable() requires SS_ISCONNECTED + // for a stream socket -- so neither kqueue nor select would + // ever dispatch the parked op there, while Linux raises + // POLLOUT on it immediately. Park the two deferred cases on + // sockets that are already connected, which are writable + // everywhere; the faulted connect still decides the result, + // because the real syscall never runs. On a connected socket + // register_op may find write_ready already latched and run + // perform_io() inline, so what these blocks prove is that the + // probe and the SO_ERROR read in reactor_connect_op::perform_io + // are reached -- not that a reactor dispatch delivered them. + auto [d1, peer1] = test::make_socket_pair(ioc); + auto [d2, peer2] = test::make_socket_pair(ioc); + std::error_code sync_ec, poll_ec, soerr_ec; + auto body = [&]() -> capy::task<> + { + { + tcp_socket s(ioc); + fault_scope f(sys::connect, ENETUNREACH); + auto [ec] = co_await s.connect(acc.local_endpoint()); + sync_ec = ec; + BOOST_TEST(f.fired()); + } + { + // Loopback connect completes synchronously, so the + // deferred path is only reachable by making connect + // report EINPROGRESS. + fault_scope f1(sys::connect, EINPROGRESS); + fault_scope f2(sys::poll, EIO); + auto [ec] = co_await d1.connect(acc.local_endpoint()); + poll_ec = ec; + BOOST_TEST(f1.fired()); + BOOST_TEST(f2.fired()); + } + { + fault_scope f1(sys::connect, EINPROGRESS); + fault_scope f2(sys::getsockopt, EBADF); + auto [ec] = co_await d2.connect(acc.local_endpoint()); + soerr_ec = ec; + BOOST_TEST(f1.fired()); + BOOST_TEST(f2.fired()); + } + }; + capy::run_async(ioc.get_executor())(body()); + ioc.run(); + // Named only so the structured binding is complete; what + // matters is the scope, which keeps each peer open across the + // run. A closed peer would reset the connection and make the + // parked socket writable for the wrong reason. + std::ignore = peer1; + std::ignore = peer2; + BOOST_TEST(sync_ec == std::errc::network_unreachable); + BOOST_TEST(poll_ec == std::errc::io_error); + BOOST_TEST(soerr_ec == std::errc::bad_file_descriptor); + } + + void testStreamIoFails() + { + io_context ioc(Backend); + auto [a, b] = test::make_socket_pair(ioc); + char buf[8] = "1234567"; + std::error_code rec, wec, rec_multi, wec_multi, drec; + auto body = [&]() -> capy::task<> + { + { + fault_scope f(spec_write, EPIPE); + auto [ec, n] = co_await a.write_some( + capy::const_buffer(buf, 7)); + std::ignore = n; + wec = ec; + BOOST_TEST(f.fired()); + } + { + fault_scope f(spec_write, EINTR); + auto [ec, n] = co_await a.write_some( + capy::const_buffer(buf, 7)); + BOOST_TEST(f.fired()); + BOOST_TEST(!ec); + BOOST_TEST_EQ(n, 7u); + } + { + fault_scope f(sys::recv, ECONNRESET); + auto [ec, n] = co_await b.read_some( + capy::mutable_buffer(buf, 7)); + std::ignore = n; + rec = ec; + BOOST_TEST(f.fired()); + } + { + fault_scope f(sys::recv, EINTR); + auto [ec, n] = co_await b.read_some( + capy::mutable_buffer(buf, 7)); + BOOST_TEST(f.fired()); + BOOST_TEST(!ec); + BOOST_TEST_EQ(n, 7u); + } + char x[4] = {}, y[4] = {}; + std::array mb{ + capy::mutable_buffer(x, 4), capy::mutable_buffer(y, 4)}; + std::array cb{ + capy::const_buffer(x, 4), capy::const_buffer(y, 4)}; + { + fault_scope f(vec_write, EPIPE); + auto [ec, n] = co_await a.write_some(cb); + std::ignore = n; + wec_multi = ec; + BOOST_TEST(f.fired()); + } + { + fault_scope f(vec_write, EINTR); + auto [ec, n] = co_await a.write_some(cb); + BOOST_TEST(f.fired()); + BOOST_TEST(!ec); + BOOST_TEST_EQ(n, 8u); + } + { + auto [ec, n] = co_await a.write_some(cb); + BOOST_TEST(!ec); + BOOST_TEST_EQ(n, 8u); + } + { + fault_scope f(sys::readv, ECONNRESET); + auto [ec, n] = co_await b.read_some(mb); + std::ignore = n; + rec_multi = ec; + BOOST_TEST(f.fired()); + } + { + fault_scope f(sys::readv, EINTR); + auto [ec, n] = co_await b.read_some(mb); + BOOST_TEST(f.fired()); + BOOST_TEST(!ec); + BOOST_TEST_EQ(n, 8u); + } + { + auto [ec, n] = co_await b.read_some(mb); + BOOST_TEST(!ec); + BOOST_TEST_EQ(n, 8u); + } + // Deferred read: the speculative recv reports EAGAIN, so the + // reactor re-runs the op, which always uses readv even for a + // single buffer. + { + fault_scope f1(sys::recv, EAGAIN); + fault_scope f2(sys::readv, EIO); + auto writer = [&]() -> capy::task<> + { + auto [ec, n] = co_await a.write_some( + capy::const_buffer(buf, 4)); + std::ignore = n; + BOOST_TEST(!ec); + }; + capy::run_async(ioc.get_executor())(writer()); + auto [ec, n] = co_await b.read_some( + capy::mutable_buffer(buf, 7)); + std::ignore = n; + drec = ec; + BOOST_TEST(f1.fired()); + BOOST_TEST(f2.fired()); + } + }; + capy::run_async(ioc.get_executor())(body()); + ioc.run(); + BOOST_TEST(wec == std::errc::broken_pipe); + BOOST_TEST(rec == std::errc::connection_reset); + BOOST_TEST(wec_multi == std::errc::broken_pipe); + BOOST_TEST(rec_multi == std::errc::connection_reset); + BOOST_TEST(drec == std::errc::io_error); + } + + void testDeferredWriteFails() + { + io_context ioc(Backend); + // A faulted EAGAIN cannot park a write durably: the socket + // stays writable, so an edge-triggered backend never fires + // again and the op would never be retried. Push real + // backpressure instead, so draining the peer produces a + // genuine writable event. + auto [a, b] = make_backpressured_pair(ioc); + + // The writer only parks once the peer's receive window is + // closed as well, so the payload has to outrun both buffers: + // the send buffer is pinned small above, but the receive + // buffer keeps its default and Darwin autotunes it into the + // hundreds of kilobytes. 256 KiB sat right at that boundary + // and parked only sometimes. + std::vector payload(4 * 1024 * 1024, 'X'); + std::error_code wec; + // The speculative single-buffer write takes the write_one fast + // path, so the vector form is reachable only from the parked + // op's reactor retry (reactor_write_op::perform_io). + fault_scope f(vec_write, EIO); + auto writer = [&]() -> capy::task<> + { + std::size_t off = 0; + while(off < payload.size()) + { + auto [ec, n] = co_await a.write_some(capy::const_buffer( + payload.data() + off, payload.size() - off)); + if(ec) + { + wec = ec; + break; + } + off += n; + } + a.close(); + }; + auto reader = [&]() -> capy::task<> + { + std::vector sink(4096); + for(;;) + { + auto [ec, n] = co_await b.read_some( + capy::mutable_buffer(sink.data(), sink.size())); + if(ec || n == 0) + break; + } + }; + capy::run_async(ioc.get_executor())(writer()); + capy::run_async(ioc.get_executor())(reader()); + ioc.run(); + BOOST_TEST(f.fired()); + BOOST_TEST(wec == std::errc::io_error); + } + + void testShutdownAndWaitFails() + { + io_context ioc(Backend); + // b is unused beyond keeping the peer of a alive. + auto [a, b] = test::make_socket_pair(ioc); + std::ignore = b; + { + fault_scope f(sys::shutdown, ENOTCONN); + BOOST_TEST(a.shutdown(shutdown_send) == std::errc::not_connected); + BOOST_TEST(f.fired()); + } + std::error_code wec; + auto body = [&]() -> capy::task<> + { + fault_scope f(sys::poll, EIO); + auto [ec] = co_await a.wait(wait_type::write); + wec = ec; + BOOST_TEST(f.fired()); + }; + capy::run_async(ioc.get_executor())(body()); + ioc.run(); + BOOST_TEST(wec == std::errc::io_error); + } + + /* Fill the send path until it refuses a small write. + + A write-direction operation parks only on a descriptor the + kernel would refuse, and how much a socket takes before it + refuses is the kernel's business. Neither a `send` that stops + short nor a descriptor that has stopped reporting itself + writable is enough: a stream socket still accepts a few bytes + below its low-water mark, and the operation these tests park is + seven of them. The bulk loop saturates both ends — the peer + never reads, so what stops it is the closed window — and the + small sends that follow are the actual precondition, so the + caller can skip rather than fail an unrelated assertion where + filling does not close it. + + @return True once a small write would be refused. + */ + static bool fill_send_buffer(tcp_socket& s) + { + std::vector chunk(64 * 1024, 'x'); + int const fd = static_cast(s.native_handle()); + // A refused send is not the end of the loop: the peer's receive + // buffer keeps draining ours, so the window reopens until that + // is full too. + for(int i = 0; i < 512; ++i) + std::ignore = ::send(fd, chunk.data(), chunk.size(), MSG_DONTWAIT); + for(int i = 0; i < 4096; ++i) + { + if(::send(fd, chunk.data(), 8, MSG_DONTWAIT) < 0) + return true; + } + return false; + } + + /* Empty a peer's receive buffer so the sender's window reopens. + + The counterpart to fill_send_buffer: the sender is blocked on a + closed window rather than on its own buffer, so what reopens it + is the far end reading. + */ + static void drain_receive_buffer(tcp_socket& s) + { + std::vector buf(64 * 1024); + int const fd = static_cast(s.native_handle()); + while(::recv(fd, buf.data(), buf.size(), MSG_DONTWAIT) > 0) + { + } + } + + // Report a kernel this test cannot put into the state it needs. + static void skip_unfillable(char const* what) + { + std::fprintf(stderr, + "fault harness: the send window would not stay closed on " + "this kernel; skipping %s\n", what); + } + + /* Raise an error condition on the far end of `peer`. + + epoll and kqueue report a reset as an error event, so SO_LINGER + 0 plus a close is enough. select reports an exceptional + condition only for out-of-band data, which is why the two + backends need different triggers for the same dispatch arm. + */ + static void raise_error_condition(tcp_socket& peer) + { + // A parked operation that resolved early leaves nothing here to + // raise the condition on. Report that rather than throwing out + // of a coroutine, where it would abort the process and hide + // whichever assertion actually failed. + BOOST_TEST(peer.is_open()); + if(!peer.is_open()) + return; + if constexpr(is_select) + { + char oob = '!'; + BOOST_TEST_EQ( + ::send(peer.native_handle(), &oob, 1, MSG_OOB), 1); + } + else + { + peer.set_option(socket_option::linger(true, 0)); + peer.close(); + } + } + + /* An error arriving on a descriptor with an operation parked on it. + + The dispatch has a separate arm per parked op kind, each of + which completes the op with the error instead of running its + I/O. The SO_ERROR probe is faulted so what the op reports is the + armed code rather than whatever the kernel recorded — the same + trick testErrorEventSoError uses for the plain read arm. + */ + void testErrorEventOnParkedWaitRead() + { + io_context ioc(Backend); + auto [c, peer] = test::make_socket_pair(ioc); + std::stop_source guard; + std::error_code wec; + auto waiter = [&]() -> capy::task<> + { + fault_scope probe(sys::getsockopt, EBADF); + auto [ec] = co_await c.wait(wait_type::read); + wec = ec; + BOOST_TEST(probe.fired()); + // An out-of-band byte keeps select's except set raised, so + // the socket that raised it goes before the next pass. + peer.close(); + guard.request_stop(); + }; + auto trigger = [&]() -> capy::task<> + { + std::ignore = co_await corosio::delay( + std::chrono::milliseconds(1)); + raise_error_condition(peer); + }; + bool expired = false; + capy::run_async(ioc.get_executor())(waiter()); + capy::run_async(ioc.get_executor())(trigger()); + capy::run_async(ioc.get_executor(), guard.get_token())( + stop_guard(ioc, expired)); + ioc.run(); + BOOST_TEST(!expired); + BOOST_TEST(wec == std::errc::bad_file_descriptor); + BOOST_TEST(c.is_open()); + } + + void testErrorEventOnParkedWaitError() + { + io_context ioc(Backend); + auto [c, peer] = test::make_socket_pair(ioc); + std::stop_source guard; + std::error_code wec; + auto waiter = [&]() -> capy::task<> + { + fault_scope probe(sys::getsockopt, EBADF); + auto [ec] = co_await c.wait(wait_type::error); + wec = ec; + BOOST_TEST(probe.fired()); + peer.close(); + guard.request_stop(); + }; + auto trigger = [&]() -> capy::task<> + { + std::ignore = co_await corosio::delay( + std::chrono::milliseconds(1)); + raise_error_condition(peer); + }; + bool expired = false; + capy::run_async(ioc.get_executor())(waiter()); + capy::run_async(ioc.get_executor())(trigger()); + capy::run_async(ioc.get_executor(), guard.get_token())( + stop_guard(ioc, expired)); + ioc.run(); + BOOST_TEST(!expired); + BOOST_TEST(wec == std::errc::bad_file_descriptor); + BOOST_TEST(c.is_open()); + } + + /* The two write-direction arms, on Linux only. + + Both need an operation that is still parked when the error + arrives, so the trigger runs in the run-loop turn straight after + the one that parked it, with nothing posted in between; a socket + whose send window can reopen on its own does not stay parked any + longer than that. Where it resolves even in that gap the test + reports a skip rather than asserting on a dispatch that never + ran. + + The BSD family never reaches the arm at all. A reset delivered + to a parked write surfaces there as writability rather than as + an error condition, on kqueue and on select alike, so the op + re-runs its I/O and reports the real error instead of the + faulted probe's. The trigger that does raise an error condition + on the write side is testErrorEventOnWritableWrite's, which runs + everywhere and covers the same two arms; what these two add is + the reset, and only where a reset reaches them. + */ + void testErrorEventOnParkedWrite() + { + io_context ioc(Backend); + auto [c, peer] = make_backpressured_pair(ioc); + if(!fill_send_buffer(c)) + { + skip_unfillable("testErrorEventOnParkedWrite"); + return; + } + char buf[8] = "1234567"; + std::stop_source guard; + std::error_code wec; + bool done = false, parked = false, probe_fired = false; + auto writer = [&]() -> capy::task<> + { + fault_scope probe(sys::getsockopt, EBADF); + auto [ec, n] = co_await c.write_some( + capy::const_buffer(buf, 7)); + std::ignore = n; + wec = ec; + probe_fired = probe.fired(); + done = true; + peer.close(); + guard.request_stop(); + }; + auto trigger = [&]() -> capy::task<> + { + parked = !done; + if(parked) + raise_error_condition(peer); + co_return; + }; + bool expired = false; + capy::run_async(ioc.get_executor())(writer()); + capy::run_async(ioc.get_executor())(trigger()); + capy::run_async(ioc.get_executor(), guard.get_token())( + stop_guard(ioc, expired)); + ioc.run(); + BOOST_TEST(!expired); + if(!parked) + { + skip_unfillable("testErrorEventOnParkedWrite"); + return; + } + BOOST_TEST(probe_fired); + BOOST_TEST(wec == std::errc::bad_file_descriptor); + BOOST_TEST(c.is_open()); + } + + void testErrorEventOnParkedWaitWrite() + { + io_context ioc(Backend); + auto [c, peer] = make_backpressured_pair(ioc); + if(!fill_send_buffer(c)) + { + skip_unfillable("testErrorEventOnParkedWaitWrite"); + return; + } + std::stop_source guard; + std::error_code wec; + bool done = false, parked = false, probe_fired = false; + auto waiter = [&]() -> capy::task<> + { + fault_scope probe(sys::getsockopt, EBADF); + auto [ec] = co_await c.wait(wait_type::write); + wec = ec; + probe_fired = probe.fired(); + done = true; + peer.close(); + guard.request_stop(); + }; + auto trigger = [&]() -> capy::task<> + { + parked = !done; + if(parked) + raise_error_condition(peer); + co_return; + }; + bool expired = false; + capy::run_async(ioc.get_executor())(waiter()); + capy::run_async(ioc.get_executor())(trigger()); + capy::run_async(ioc.get_executor(), guard.get_token())( + stop_guard(ioc, expired)); + ioc.run(); + BOOST_TEST(!expired); + if(!parked) + { + skip_unfillable("testErrorEventOnParkedWaitWrite"); + return; + } + BOOST_TEST(probe_fired); + BOOST_TEST(wec == std::errc::bad_file_descriptor); + BOOST_TEST(c.is_open()); + } + + /* Poll `target` until the except set is raised, and -- where the + parked operation is waiting on the send window -- until it is + writable in the same answer. + + The dispatch arms need one round to report writability and the + except set together. Probing for both is the only way the test + can tell that premise from the two halves holding at different + moments, which is what a kernel that reopens the window a beat + late gives it. Bounded well inside the stop_guard's two seconds, + so a kernel that never shows the pair fails the test rather than + the guard. + + @param target The descriptor the parked operation is on. + @param and_writable Whether writability is part of the premise. + + @return True once every bit the caller needs holds at once. + */ + static bool await_condition(tcp_socket& target, bool and_writable) + { + int const fd = static_cast(target.native_handle()); + for(int i = 0; i < 200; ++i) + { + fd_set w, ex; + FD_ZERO(&w); + FD_ZERO(&ex); + FD_SET(fd, &w); + FD_SET(fd, &ex); + timeval tv{0, 1000}; + if(::select(fd + 1, nullptr, and_writable ? &w : nullptr, + &ex, &tv) > 0 && + FD_ISSET(fd, &ex) && + (!and_writable || FD_ISSET(fd, &w))) + { + return true; + } + } + return false; + } + + /* Raise an except condition on `target` from its peer. + + Out-of-band data is the one condition select reports in the + except set, and it stays raised until the byte is consumed -- + which is what lets a write-direction readiness bit arrive in the + same round. The order is load-bearing twice over. A condition + raised before the operation parks is dispatched on its own, and + the operation then leaves through the tail block for an error + with no readiness bit rather than through the write arm; and a + send is not a delivery, so the turn goes back to the run loop + only once the condition is observable. + + @return True once the except set is raised on `target`. + */ + static bool raise_urgent_byte(tcp_socket& peer, tcp_socket& target) + { + char oob = '!'; + BOOST_TEST_EQ(::send(peer.native_handle(), &oob, 1, MSG_OOB), 1); + return await_condition(target, false); + } + + /* Raise the condition and reopen the window a parked write is + waiting on. + + Draining comes before the probe, not after: the window has to be + open for the probe to find it and the except set in one answer, + and one answer is what the dispatch arm needs. + + @return True once writability and the except set hold together. + */ + static bool + raise_writable_error_condition(tcp_socket& peer, tcp_socket& target) + { + char oob = '!'; + BOOST_TEST_EQ(::send(peer.native_handle(), &oob, 1, MSG_OOB), 1); + drain_receive_buffer(peer); + return await_condition(target, true); + } + + // Report a kernel that will not show us the condition the two + // write-direction arms need. + static void skip_unraisable(char const* what) + { + std::fprintf(stderr, + "fault harness: an urgent byte did not raise the except set " + "on this kernel; skipping %s\n", what); + } + + /* The write-direction dispatch when the except set is raised + without a socket error. + + Both arms need the same round to report writability and an + exceptional condition on one descriptor. An urgent byte does + that: it raises select's except set on a descriptor that is + writable in its own right. + + The SO_ERROR probe is left unfaulted on purpose. Out-of-band data + leaves no socket error behind, so the probe reads back zero -- and + a healthy socket must not be faulted for it + (reactor_descriptor_state::invoke_deferred_io). The write simply + re-runs its I/O and completes normally, on a socket that is in no + way broken. + */ + void testErrorEventOnWritableWrite() + { + if constexpr(is_select) + { + io_context ioc(Backend); + auto [c, peer] = test::make_socket_pair(ioc); + char buf[8] = "1234567"; + std::stop_source guard; + std::error_code wec; + bool open_after = false, spec_fired = false, raised = false; + auto writer = [&]() -> capy::task<> + { + // Refusing the speculative write is what parks the + // operation. Backpressure would do it too, but how much + // a socket takes before it refuses is the kernel's + // business and a window that closed can reopen before + // the operation reaches it. + fault_scope spec(spec_write, EAGAIN); + auto [ec, n] = co_await c.write_some( + capy::const_buffer(buf, 7)); + std::ignore = n; + wec = ec; + spec_fired = spec.fired(); + // Nothing broke: the condition was one byte of urgent + // data, so the write completes normally. + open_after = c.is_open(); + // The byte is never consumed, so the except set stays + // raised; the descriptor that raised it goes before the + // run loop is asked for another pass. + c.close(); + guard.request_stop(); + }; + auto trigger = [&]() -> capy::task<> + { + raised = raise_urgent_byte(peer, c); + co_return; + }; + bool expired = false; + capy::run_async(ioc.get_executor())(writer()); + capy::run_async(ioc.get_executor())(trigger()); + capy::run_async(ioc.get_executor(), guard.get_token())( + stop_guard(ioc, expired)); + ioc.run(); + BOOST_TEST(!expired); + BOOST_TEST(spec_fired); + if(!raised) + { + skip_unraisable("testErrorEventOnWritableWrite"); + return; + } + BOOST_TEST(!wec); + BOOST_TEST(open_after); + } + } + + void testErrorEventOnWritableWaitWrite() + { + if constexpr(is_select) + { + io_context ioc(Backend); + auto [c, peer] = make_backpressured_pair(ioc); + if(!fill_send_buffer(c)) + { + skip_unfillable("testErrorEventOnWritableWaitWrite"); + return; + } + std::stop_source guard; + std::error_code wec; + bool done = false, parked = false, open_after = false; + bool raised = false; + auto waiter = [&]() -> capy::task<> + { + auto [ec] = co_await c.wait(wait_type::write); + wec = ec; + done = true; + open_after = c.is_open(); + c.close(); + guard.request_stop(); + }; + auto trigger = [&]() -> capy::task<> + { + parked = !done; + if(parked) + raised = raise_writable_error_condition(peer, c); + co_return; + }; + bool expired = false; + capy::run_async(ioc.get_executor())(waiter()); + capy::run_async(ioc.get_executor())(trigger()); + capy::run_async(ioc.get_executor(), guard.get_token())( + stop_guard(ioc, expired)); + ioc.run(); + BOOST_TEST(!expired); + if(!parked) + { + skip_unfillable("testErrorEventOnWritableWaitWrite"); + return; + } + if(!raised) + { + skip_unraisable("testErrorEventOnWritableWaitWrite"); + return; + } + // Urgent data on a writable socket is not a fault: the wait + // completes on the writability, never with an error. + BOOST_TEST(done); + BOOST_TEST(!wec); + BOOST_TEST(open_after); + } + } + + /* The address family a socket was created with is read back from + the kernel, not remembered, and a v4 destination on a v6 socket + is the one case where the answer changes the sockaddr handed to + connect(). A failed probe reports AF_UNSPEC, which yields the + v4 shape and an address the v6 socket cannot use. + */ + void testSocketFamilyProbeFails() + { + io_context ioc(Backend); + tcp_acceptor acc(ioc, loopback()); + tcp_socket s(ioc); + if(s.open(tcp::v6())) + { + std::fprintf(stderr, + "fault harness: no IPv6 socket on this host; skipping " + "testSocketFamilyProbeFails\n"); + return; + } + std::error_code cec; + unsigned probes = 0; + auto body = [&]() -> capy::task<> + { + fault_scope f(sys::getsockname, EBADF); + auto [ec] = co_await s.connect(acc.local_endpoint()); + cec = ec; + probes = f.count(); + BOOST_TEST(f.fired()); + }; + capy::run_async(ioc.get_executor())(body()); + ioc.run(); + BOOST_TEST_EQ(probes, 1u); + // Which code a kernel picks for an address in the wrong family + // is its own choice: Linux rejects the sockaddr as too short + // for AF_INET6, the BSDs reject the family itself. +#if defined(__linux__) + BOOST_TEST(cec == std::errc::invalid_argument); +#else + BOOST_TEST(cec == std::errc::address_family_not_supported); +#endif + BOOST_TEST(s.is_open()); + } + + void testErrorEventSoError() + { + io_context ioc(Backend); + tcp_acceptor acc(ioc, loopback()); + std::error_code rec; + auto body = [&]() -> capy::task<> + { + tcp_socket c(ioc), s(ioc); + { + auto [ec] = co_await c.connect(acc.local_endpoint()); + BOOST_TEST(!ec); + } + { + auto [ec] = co_await acc.accept(s); + BOOST_TEST(!ec); + } + if constexpr(is_select) + { + // select() reports an exceptional condition only for + // out-of-band data; a RST shows up as plain readability + // and never reaches the SO_ERROR probe. One OOB byte is + // the portable way to raise the except set. + char oob = '!'; + BOOST_TEST_EQ( + ::send(s.native_handle(), &oob, 1, MSG_OOB), 1); + } + else + { + // SO_LINGER 0 makes close send a RST, which surfaces as + // an error event on the parked reader. + s.set_option(socket_option::linger(true, 0)); + } + // Nothing else on this path reads a socket option, so the + // armed getsockopt is the reactor's SO_ERROR probe. + fault_scope f1(sys::recv, EAGAIN); + fault_scope f2(sys::getsockopt, EBADF); + // The frame only holds a pointer to the closure, so the + // name has to outlive the read below, not just the spawn. + [[maybe_unused]] auto closer = [&]() -> capy::task<> + { + s.close(); + co_return; + }; + if constexpr(!is_select) + { + capy::run_async(ioc.get_executor())(closer()); + } + char buf[4]; + auto [ec, n] = co_await c.read_some(capy::mutable_buffer(buf, 4)); + std::ignore = n; + rec = ec; + BOOST_TEST(f1.fired()); + BOOST_TEST(f2.fired()); + if constexpr(is_select) + { + // The OOB byte keeps the except set raised, so the + // socket that raised it has to go before the run loop + // is asked for another pass. + s.close(); + } + }; + capy::run_async(ioc.get_executor())(body()); + ioc.run(); + BOOST_TEST(rec == std::errc::bad_file_descriptor); + } + + void testDatagramFails() + { + io_context ioc(Backend); + udp_socket a(ioc), b(ioc); + BOOST_TEST(!a.open(udp::v4())); + BOOST_TEST(!b.open(udp::v4())); + BOOST_TEST(!a.bind(loopback())); + BOOST_TEST(!b.bind(loopback())); + { + fault_scope f(sys::connect, ENETUNREACH); + std::error_code cec; + auto conn = [&]() -> capy::task<> + { + auto [ec] = co_await a.connect(b.local_endpoint()); + cec = ec; + }; + capy::run_async(ioc.get_executor())(conn()); + ioc.run(); + ioc.restart(); + BOOST_TEST(f.fired()); + BOOST_TEST(cec == std::errc::network_unreachable); + } + { + fault_scope f(sys::shutdown, ENOTCONN); + BOOST_TEST(a.shutdown(shutdown_both) == std::errc::not_connected); + BOOST_TEST(f.fired()); + } + char buf[8] = "1234567"; + std::error_code stec, rfec, sec, rec, dstec, drfec, dsfec, drrec; + auto body = [&]() -> capy::task<> + { + { + fault_scope f(sys::sendmsg, EPERM); + auto [ec, n] = co_await a.send_to( + capy::const_buffer(buf, 7), b.local_endpoint()); + std::ignore = n; + stec = ec; + BOOST_TEST(f.fired()); + } + { + auto [ec, n] = co_await a.send_to( + capy::const_buffer(buf, 7), b.local_endpoint()); + std::ignore = n; + BOOST_TEST(!ec); + } + { + fault_scope f(sys::recvmsg, EIO); + endpoint from; + auto [ec, n] = co_await b.recv_from( + capy::mutable_buffer(buf, 7), from); + std::ignore = n; + rfec = ec; + BOOST_TEST(f.fired()); + } + { + endpoint from; + auto [ec, n] = co_await b.recv_from( + capy::mutable_buffer(buf, 7), from); + std::ignore = n; + BOOST_TEST(!ec); + } + // Deferred send_to. A faulted EAGAIN cannot make a UDP + // socket unwritable, so no later edge would ever arrive to + // retry a parked op on an edge-triggered backend. Use a + // socket registered this instant and hop the run loop once, + // which dispatches the registration's writable event and + // latches desc_state.write_ready; register_op then runs the + // parked op's perform_io() inline instead of waiting for an + // edge. select is level-triggered and re-reports the fd as + // soon as the op opts into the write set, so the hop is + // merely harmless there. + { + udp_socket d(ioc); + BOOST_TEST(!d.open(udp::v4())); + BOOST_TEST(!d.bind(loopback())); + std::ignore = co_await corosio::delay( + std::chrono::milliseconds(1)); + fault_scope f1(sys::sendmsg, EAGAIN); + fault_scope f2(sys::sendmsg, EIO, 2); + auto [ec, n] = co_await d.send_to( + capy::const_buffer(buf, 7), b.local_endpoint()); + std::ignore = n; + dsfec = ec; + BOOST_TEST(f1.fired()); + BOOST_TEST(f2.fired()); + } + { + fault_scope f1(sys::recvmsg, EAGAIN); + fault_scope f2(sys::recvmsg, EIO, 2); + auto sender = [&]() -> capy::task<> + { + auto [ec, n] = co_await a.send_to( + capy::const_buffer(buf, 4), b.local_endpoint()); + std::ignore = n; + BOOST_TEST(!ec); + }; + capy::run_async(ioc.get_executor())(sender()); + endpoint from; + auto [ec, n] = co_await b.recv_from( + capy::mutable_buffer(buf, 7), from); + std::ignore = n; + drrec = ec; + BOOST_TEST(f1.fired()); + BOOST_TEST(f2.fired()); + } + { + auto [ec] = co_await a.connect(b.local_endpoint()); + BOOST_TEST(!ec); + } + { + fault_scope f(sys::sendmsg, EPERM); + auto [ec, n] = co_await a.send(capy::const_buffer(buf, 7)); + std::ignore = n; + sec = ec; + BOOST_TEST(f.fired()); + } + { + auto [ec, n] = co_await a.send(capy::const_buffer(buf, 7)); + std::ignore = n; + BOOST_TEST(!ec); + } + { + fault_scope f(sys::recvmsg, EIO); + auto [ec, n] = co_await b.recv(capy::mutable_buffer(buf, 7)); + std::ignore = n; + rec = ec; + BOOST_TEST(f.fired()); + } + { + auto [ec, n] = co_await b.recv(capy::mutable_buffer(buf, 7)); + std::ignore = n; + BOOST_TEST(!ec); + } + { + fault_scope f1(sys::recvmsg, EAGAIN); + fault_scope f2(sys::recvmsg, EIO, 2); + auto sender = [&]() -> capy::task<> + { + auto [ec, n] = co_await a.send( + capy::const_buffer(buf, 4)); + std::ignore = n; + BOOST_TEST(!ec); + }; + capy::run_async(ioc.get_executor())(sender()); + auto [ec, n] = co_await b.recv(capy::mutable_buffer(buf, 7)); + std::ignore = n; + drfec = ec; + BOOST_TEST(f1.fired()); + BOOST_TEST(f2.fired()); + } + // Deferred connected send; same latching hop as send_to. + { + udp_socket d(ioc); + BOOST_TEST(!d.open(udp::v4())); + BOOST_TEST(!d.bind(loopback())); + { + auto [ec] = co_await d.connect(b.local_endpoint()); + BOOST_TEST(!ec); + } + std::ignore = co_await corosio::delay( + std::chrono::milliseconds(1)); + fault_scope f1(sys::sendmsg, EAGAIN); + fault_scope f2(sys::sendmsg, EIO, 2); + auto [ec, n] = co_await d.send(capy::const_buffer(buf, 7)); + std::ignore = n; + dstec = ec; + BOOST_TEST(f1.fired()); + BOOST_TEST(f2.fired()); + } + }; + capy::run_async(ioc.get_executor())(body()); + ioc.run(); + BOOST_TEST(stec == std::errc::operation_not_permitted); + BOOST_TEST(rfec == std::errc::io_error); + BOOST_TEST(sec == std::errc::operation_not_permitted); + BOOST_TEST(rec == std::errc::io_error); + BOOST_TEST(dsfec == std::errc::io_error); + BOOST_TEST(drrec == std::errc::io_error); + BOOST_TEST(drfec == std::errc::io_error); + BOOST_TEST(dstec == std::errc::io_error); + } + + void run() + { + if(skip_under_valgrind()) + return; + testAcceptorFails(); + testConnectFails(); + testStreamIoFails(); + testDeferredWriteFails(); + testShutdownAndWaitFails(); + testErrorEventSoError(); + testErrorEventOnParkedWaitRead(); + testErrorEventOnParkedWaitError(); +#if defined(__linux__) + testErrorEventOnParkedWrite(); + testErrorEventOnParkedWaitWrite(); +#endif + testErrorEventOnWritableWrite(); + testErrorEventOnWritableWaitWrite(); + testSocketFamilyProbeFails(); + testDatagramFails(); + } +}; + +} // boost::corosio::test::fault + +#endif diff --git a/test/unit/fault/select_faults.cpp b/test/unit/fault/select_faults.cpp new file mode 100644 index 000000000..57c80603b --- /dev/null +++ b/test/unit/fault/select_faults.cpp @@ -0,0 +1,472 @@ +// +// Copyright (c) 2026 Steve Gerbino +// +// Distributed under the Boost Software License, Version 1.0. (See accompanying +// file LICENSE_1_0.txt or copy at http://www.boost.org/LICENSE_1_0.txt) +// +// Official repository: https://github.com/cppalliance/corosio +// + +#include "fault.hpp" +#include "fault_test_utils.hpp" +#include "context.hpp" +#include "test_suite.hpp" +#include "test_utils.hpp" + +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include + +#include +#include +#include + +#if BOOST_COROSIO_HAS_SELECT + +namespace boost::corosio::test::fault { + +namespace { + +endpoint loopback() +{ + return endpoint(ipv4_address::loopback(), 0); +} + +} // namespace + +struct select_faults +{ + void testConstructorFails() + { + { + int before = open_fds(); + fault_scope f(sys::pipe, EMFILE); + expect_system_error([]{ io_context ioc(select); }, + std::errc::too_many_files_open); + BOOST_TEST(f.fired()); + BOOST_TEST_EQ(open_fds(), before); + } + // Three fcntl calls configure each end of the interrupt pipe, + // read end first: 1-3 fail the read end, 4-6 the write end. + for(unsigned nth : {1u, 2u, 3u, 4u, 5u, 6u}) + { + int before = open_fds(); + fault_scope f(sys::fcntl, EINVAL, nth); + expect_system_error([]{ io_context ioc(select); }, + std::errc::invalid_argument); + BOOST_TEST(f.fired()); + BOOST_TEST_EQ(open_fds(), before); + } + } + + void testOpenFcntlFails() + { + io_context ioc(select); + for(unsigned nth : {1u, 2u, 3u}) + { + int before = open_fds(); + tcp_socket s(ioc); + fault_scope f(sys::fcntl, EINVAL, nth); + auto ec = s.open(tcp::v4()); + BOOST_TEST(f.fired()); + BOOST_TEST(ec == std::errc::invalid_argument); + BOOST_TEST(!s.is_open()); + BOOST_TEST_EQ(open_fds(), before); + } + for(unsigned nth : {1u, 2u, 3u}) + { + int before = open_fds(); + tcp_acceptor acc(ioc); + fault_scope f(sys::fcntl, EINVAL, nth); + auto ec = acc.open(); + BOOST_TEST(f.fired()); + BOOST_TEST(ec == std::errc::invalid_argument); + BOOST_TEST(!acc.is_open()); + BOOST_TEST_EQ(open_fds(), before); + } + } + + void testAcceptFails() + { + io_context ioc(select); + tcp_acceptor acc(ioc, loopback()); + std::error_code aec; + int leaked = 0; + auto body = [&]() -> capy::task<> + { + tcp_socket client(ioc), server(ioc); + { + auto [ec] = co_await client.connect(acc.local_endpoint()); + BOOST_TEST(!ec); + } + { + // EINTR is retried inside accept_policy, then the real + // accept succeeds. + fault_scope f(sys::accept, EINTR); + auto [ec] = co_await acc.accept(server); + BOOST_TEST(f.fired()); + BOOST_TEST(!ec); + } + server.close(); + client.close(); + tcp_socket client2(ioc); + { + auto [ec] = co_await client2.connect(acc.local_endpoint()); + BOOST_TEST(!ec); + } + { + int before = open_fds(); + fault_scope f(sys::accept, ECONNABORTED); + auto [ec] = co_await acc.accept(server); + aec = ec; + leaked = open_fds() - before; + BOOST_TEST(f.fired()); + BOOST_TEST(!server.is_open()); + } + // The aborted accept did not consume the pending + // connection, so the retry still yields it. + { + auto [ec] = co_await acc.accept(server); + BOOST_TEST(!ec); + } + server.close(); + client2.close(); + }; + capy::run_async(ioc.get_executor())(body()); + ioc.run(); + BOOST_TEST(aec == std::errc::connection_aborted); + BOOST_TEST_EQ(leaked, 0); + } + + void testAcceptFcntlFails() + { + io_context ioc(select); + tcp_acceptor acc(ioc, loopback()); + std::error_code ecs[3]; + int leaked[3] = {}; + auto body = [&]() -> capy::task<> + { + for(unsigned nth = 1; nth <= 3; ++nth) + { + tcp_socket c(ioc), s(ioc); + { + auto [ec] = co_await c.connect(acc.local_endpoint()); + BOOST_TEST(!ec); + } + int before = open_fds(); + // Armed after the connect, so the count starts at the + // three fcntl calls accept_policy makes on the + // accepted descriptor. + fault_scope f(sys::fcntl, EINVAL, nth); + auto [ec] = co_await acc.accept(s); + ecs[nth - 1] = ec; + leaked[nth - 1] = open_fds() - before; + BOOST_TEST(f.fired()); + BOOST_TEST(!s.is_open()); + c.close(); + } + }; + capy::run_async(ioc.get_executor())(body()); + ioc.run(); + for(unsigned i = 0; i < 3; ++i) + { + BOOST_TEST(ecs[i] == std::errc::invalid_argument); + BOOST_TEST_EQ(leaked[i], 0); + } + } + + void testRunLoopFaults() + { + // EINTR and EBADF are the two codes select() retries: the poll + // is abandoned for this round and the run loop keeps going, so + // the delay armed before it still fires. + for(int err : {EINTR, EBADF}) + { + io_context ioc(select); + fault_scope f(sys::select, err); + bool done = false; + auto body = [&]() -> capy::task<> + { + std::ignore = co_await corosio::delay( + std::chrono::milliseconds(1)); + done = true; + }; + capy::run_async(ioc.get_executor())(body()); + ioc.run(); + BOOST_TEST(f.fired()); + BOOST_TEST(done); + } + // Every other code leaves the run loop through an exception. + { + io_context ioc(select); + fault_scope f(sys::select, EINVAL); + auto body = [&]() -> capy::task<> + { + std::ignore = co_await corosio::delay( + std::chrono::milliseconds(1)); + }; + capy::run_async(ioc.get_executor())(body()); + expect_system_error([&]{ ioc.run(); }, + std::errc::invalid_argument); + BOOST_TEST(f.fired()); + } + } + + /* This backend coalesces nothing: every interrupt writes its own + byte to the self-pipe, so a failed write costs exactly that one + interrupt. The arms below are what holds that shape in place — + the eventfd and kqueue backends reach it by clearing a flag, and + a flag introduced here would have to clear one too. + */ + void testInterruptWriteFails() + { + io_context ioc(select); + { + // stop() is the one interrupt that reaches the self-pipe + // without a reactor thread (reactor_scheduler::stop), and + // it interrupts only on the transition, so each stop below + // is one write. + fault_scope first(sys::write, EIO, 1); + fault_scope second(sys::write, EIO, 2); + ioc.stop(); + BOOST_TEST(first.fired()); + BOOST_TEST(!second.fired()); + ioc.restart(); + ioc.stop(); + BOOST_TEST(second.fired()); + } + ioc.restart(); + bool done = false; + auto body = [&]() -> capy::task<> + { + std::ignore = co_await corosio::delay( + std::chrono::milliseconds(1)); + done = true; + ioc.stop(); + }; + capy::run_async(ioc.get_executor())(body()); + ioc.run(); + BOOST_TEST(done); + } + + /* Descriptors this backend cannot represent. + + select() addresses descriptors by bit position in an fd_set, so + a number at or above FD_SETSIZE has nowhere to go and FD_SET on + it would write past the set. Every entry point that can be + handed such a number rejects it instead, and the rejection is + what these three tests drive: adoption (validate_assigned_fd), + creation (set_fd_options) and acceptance (accept_policy). + */ + void testAssignAboveFdSetsize() + { + io_context ioc(select); + int const before = open_fds(); + auto h = make_native_socket(AF_INET, SOCK_STREAM); + BOOST_TEST(static_cast(h) >= 0); + make_native_adoptable(h); + int const high = dup_above_fd_setsize(static_cast(h)); + if(high < 0) + { + skip_no_high_fd("testAssignAboveFdSetsize"); + close_native_socket(h); + return; + } + { + tcp_socket s(ioc); + auto ec = s.assign(static_cast(high)); + BOOST_TEST(ec == std::errc::too_many_files_open); + BOOST_TEST(!s.is_open()); + } + { + tcp_acceptor acc(ioc); + auto ec = acc.assign(static_cast(high)); + BOOST_TEST(ec == std::errc::too_many_files_open); + BOOST_TEST(!acc.is_open()); + } + // The rejection is non-mutating: the caller still owns both. + BOOST_TEST(native_socket_valid( + static_cast(high))); + BOOST_TEST(native_socket_valid(h)); + ::close(high); + close_native_socket(h); + BOOST_TEST_EQ(open_fds(), before); + } + + void testOpenAboveFdSetsize() + { + // The context first: the scheduler's own self-pipe has to be + // representable, and the wall would deny it a number. + io_context ioc(select); + fd_wall wall; + if(!wall.ok()) + { + skip_no_high_fd("testOpenAboveFdSetsize"); + return; + } + // The descriptor exists by the time its number is rejected, so + // the failure path owns closing it. + int const before = open_fds(); + tcp_socket s(ioc); + auto ec = s.open(tcp::v4()); + BOOST_TEST(ec == std::errc::too_many_files_open); + BOOST_TEST(!s.is_open()); + tcp_acceptor acc(ioc); + BOOST_TEST(acc.open() == std::errc::too_many_files_open); + BOOST_TEST(!acc.is_open()); + BOOST_TEST_EQ(open_fds(), before); + } + + void testAcceptAboveFdSetsize() + { + io_context ioc(select); + tcp_acceptor acc(ioc, loopback()); + tcp_socket client(ioc), server(ioc); + std::error_code aec; + bool skipped = false; + int leaked = 0; + auto body = [&]() -> capy::task<> + { + { + auto [ec] = co_await client.connect(acc.local_endpoint()); + BOOST_TEST(!ec); + } + fd_wall wall; + if(!wall.ok()) + { + skipped = true; + co_return; + } + int const before = open_fds(); + auto [ec] = co_await acc.accept(server); + // accept_policy closes the descriptor it cannot represent + // before reporting, so the pending connection is consumed + // and nothing is left behind. + leaked = open_fds() - before; + aec = ec; + }; + capy::run_async(ioc.get_executor())(body()); + ioc.run(); + if(skipped) + { + skip_no_high_fd("testAcceptAboveFdSetsize"); + return; + } + // A descriptor out of select's addressable range is the same + // logical failure the adoption (validate_assigned_fd) and + // creation (set_fd_options) checks report as too_many_files_open; + // the accept path must answer with the same code, not EINVAL. + BOOST_TEST(aec == std::errc::too_many_files_open); + BOOST_TEST(!server.is_open()); + BOOST_TEST_EQ(leaked, 0); + } + +#ifdef SO_NOSIGPIPE + /* The per-descriptor SIGPIPE guard, where the platform has one. + + This backend is portable rather than Linux-shaped: its write + policy falls back to write(), which carries no per-call flag, so + on a platform that defines SO_NOSIGPIPE the socket-level flag is + the only guard there is and select_traits::set_fd_options + treats a refusal as fatal. Linux has no such option and compiles + both arms away. + */ + void testOpenNoSigPipeFails() + { + io_context ioc(select); + int const before = open_fds(); + { + // SO_NOSIGPIPE is the only setsockopt an AF_INET open + // makes, so the first call is that one. + tcp_socket s(ioc); + fault_scope f(sys::setsockopt, ENOPROTOOPT); + auto ec = s.open(tcp::v4()); + BOOST_TEST(f.fired()); + BOOST_TEST_EQ(f.count(), 1u); + BOOST_TEST(ec == std::errc::no_protocol_option); + BOOST_TEST(!s.is_open()); + } + { + tcp_acceptor acc(ioc); + fault_scope f(sys::setsockopt, ENOPROTOOPT); + auto ec = acc.open(); + BOOST_TEST(f.fired()); + BOOST_TEST_EQ(f.count(), 1u); + BOOST_TEST(ec == std::errc::no_protocol_option); + BOOST_TEST(!acc.is_open()); + } + // The descriptor exists before the option is refused, so the + // failure path owns closing it. + BOOST_TEST_EQ(open_fds(), before); + } + + void testAcceptNoSigPipeFails() + { + io_context ioc(select); + tcp_acceptor acc(ioc, loopback()); + std::error_code aec; + int leaked = 0; + unsigned calls = 0; + auto body = [&]() -> capy::task<> + { + tcp_socket c(ioc), s(ioc); + { + auto [ec] = co_await c.connect(acc.local_endpoint()); + BOOST_TEST(!ec); + } + int const before = open_fds(); + // Armed after the connect, so the first setsockopt is the + // one accept_policy makes on the accepted descriptor + // (accept_policy::do_accept). + fault_scope f(sys::setsockopt, ENOPROTOOPT); + auto [ec] = co_await acc.accept(s); + aec = ec; + calls = f.count(); + leaked = open_fds() - before; + BOOST_TEST(f.fired()); + BOOST_TEST(!s.is_open()); + c.close(); + }; + capy::run_async(ioc.get_executor())(body()); + ioc.run(); + BOOST_TEST_EQ(calls, 1u); + BOOST_TEST(aec == std::errc::no_protocol_option); + // accept_policy closes the descriptor it could not guard, and + // the errno it preserves is the option's, not close()'s. + BOOST_TEST_EQ(leaked, 0); + } +#endif + + void run() + { + if(skip_under_valgrind()) + return; + testConstructorFails(); + testOpenFcntlFails(); + testAcceptFails(); + testAcceptFcntlFails(); +#ifdef SO_NOSIGPIPE + testOpenNoSigPipeFails(); + testAcceptNoSigPipeFails(); +#endif + testRunLoopFaults(); + testInterruptWriteFails(); + testAssignAboveFdSetsize(); + testOpenAboveFdSetsize(); + testAcceptAboveFdSetsize(); + } +}; + +TEST_SUITE(select_faults, "boost.corosio.fault.select"); + +} // boost::corosio::test::fault + +#endif diff --git a/test/unit/fault/self_test.cpp b/test/unit/fault/self_test.cpp new file mode 100644 index 000000000..8ed1b5800 --- /dev/null +++ b/test/unit/fault/self_test.cpp @@ -0,0 +1,1500 @@ +// +// Copyright (c) 2026 Steve Gerbino +// +// Distributed under the Boost Software License, Version 1.0. (See accompanying +// file LICENSE_1_0.txt or copy at http://www.boost.org/LICENSE_1_0.txt) +// +// Official repository: https://github.com/cppalliance/corosio +// + +#include "fault.hpp" +#include "fault_test_utils.hpp" +#include "test_suite.hpp" + +#include + +#if !defined(_WIN32) + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#if defined(__linux__) +#include +#include +#include +#endif + +#if defined(__APPLE__) || defined(__FreeBSD__) +#include +#endif + +#if BOOST_COROSIO_HAVE_LIBURING +#include +#endif + +#if defined(__APPLE__) +// Defined in fault_posix.cpp under this asm name, which is the only +// way to name it: the C++ identifier never reaches the linker. +extern "C" int corosio_fault_select_extsn( + int, fd_set*, fd_set*, fd_set*, timeval*) __asm__("_select$DARWIN_EXTSN"); +#endif + +#if defined(__linux__) +// Not in any public header the harness pulls in; only reachable via +// _FORTIFY_SOURCE, which self_test.cpp does not build with. +extern "C" { +ssize_t __read_chk(int, void*, size_t, size_t); +ssize_t __recv_chk(int, void*, size_t, size_t, int); +int __poll_chk(pollfd*, nfds_t, int, size_t); +int __open_2(char const*, int); +int __gethostname_chk(char*, size_t, size_t) noexcept; +} +#endif + +namespace boost::corosio::test::fault { + +// BOOST_TEST_CSTR_EQ expands to a bare `string_view(...)`, which needs +// this in scope; capy's test_suite.hpp does not pull one in itself. +using std::string_view; + +struct self_test +{ + void testFiresOnNth() + { + fault_scope f(sys::socket, EMFILE, 2); + int a = ::socket(AF_INET, SOCK_STREAM, 0); + BOOST_TEST(a >= 0); + BOOST_TEST(!f.fired()); + int b = ::socket(AF_INET, SOCK_STREAM, 0); + BOOST_TEST_EQ(b, -1); + BOOST_TEST_EQ(errno, EMFILE); + BOOST_TEST(f.fired()); + int c = ::socket(AF_INET, SOCK_STREAM, 0); + BOOST_TEST(c >= 0); + ::close(a); + ::close(c); + } + + void testDisarmsOnScopeExit() + { + { + fault_scope f(sys::socket, EMFILE, 5); + } + int a = ::socket(AF_INET, SOCK_STREAM, 0); + BOOST_TEST(a >= 0); + ::close(a); + } + + // should_fail clears `armed` the instant the fault fires; `fired()` + // must still read true for calls made after that, while the scope + // that fired is still alive. + void testFiredScopeStaysFired() + { + fault_scope f(sys::socket, EMFILE, 1); + int a = ::socket(AF_INET, SOCK_STREAM, 0); + BOOST_TEST_EQ(a, -1); + BOOST_TEST_EQ(errno, EMFILE); + BOOST_TEST(f.fired()); + int b = ::socket(AF_INET, SOCK_STREAM, 0); + BOOST_TEST(b >= 0); + BOOST_TEST(f.fired()); + ::close(b); + } + + // open_fds() backs every leak assertion in the backend suites, and + // on Darwin it reads a different directory; a build where it + // returns -1 would satisfy nothing. + void testOpenFdsProbeWorks() + { + BOOST_TEST(open_fds() > 0); + } + + void testTransparentWhenUnarmed() + { + int sv[2]; + BOOST_TEST_EQ(::socketpair(AF_UNIX, SOCK_STREAM, 0, sv), 0); + char const msg[] = "abcdefgh"; + BOOST_TEST_EQ(::write(sv[0], msg, sizeof(msg)), (ssize_t)sizeof(msg)); + char buf[16] = {}; + BOOST_TEST_EQ(::read(sv[1], buf, sizeof(buf)), (ssize_t)sizeof(msg)); + BOOST_TEST_CSTR_EQ(buf, msg); + ::close(sv[0]); + ::close(sv[1]); + } + + void testThreadIsolation() + { + fault_scope f(sys::socket, EMFILE); + int other = -2; + std::thread t([&]{ other = ::socket(AF_INET, SOCK_STREAM, 0); }); + t.join(); + BOOST_TEST(other >= 0); + BOOST_TEST(!f.fired()); + ::close(other); + int mine = ::socket(AF_INET, SOCK_STREAM, 0); + BOOST_TEST_EQ(mine, -1); + BOOST_TEST(f.fired()); + } + + // The library's file I/O and name resolution run on a thread pool, + // so a scope for those must be visible outside the test thread. + void testAnyThreadFires() + { + fault_scope f(sys::socket, EMFILE, 1, any_thread); + int other = -2; + int other_errno = 0; + std::thread t([&] + { + other = ::socket(AF_INET, SOCK_STREAM, 0); + other_errno = errno; + }); + t.join(); + BOOST_TEST_EQ(other, -1); + BOOST_TEST_EQ(other_errno, EMFILE); + BOOST_TEST(f.fired()); + } + + // A thread that armed its own slot keeps using it; the global is + // only the fallback. + void testThreadLocalWinsOverAnyThread() + { + fault_scope g(sys::listen, EOPNOTSUPP, 1, any_thread); + fault_scope f(sys::socket, EMFILE); + int a = ::socket(AF_INET, SOCK_STREAM, 0); + BOOST_TEST_EQ(a, -1); + BOOST_TEST(f.fired()); + BOOST_TEST(!g.fired()); + // With the thread-local slot spent, the global takes over here. + int b = ::socket(AF_INET, SOCK_STREAM, 0); + BOOST_TEST(b >= 0); + BOOST_TEST_EQ(::listen(b, 1), -1); + BOOST_TEST_EQ(errno, EOPNOTSUPP); + BOOST_TEST(g.fired()); + ::close(b); + } + + void testAnyThreadDisarmsOnScopeExit() + { + { + fault_scope f(sys::socket, EMFILE, 1, any_thread); + } + int other = -2; + std::thread t([&]{ other = ::socket(AF_INET, SOCK_STREAM, 0); }); + t.join(); + BOOST_TEST(other >= 0); + ::close(other); + } + + void testAnyThreadShortens() + { + int sv[2]; + BOOST_TEST_EQ(::socketpair(AF_UNIX, SOCK_STREAM, 0, sv), 0); + BOOST_TEST_EQ(::write(sv[0], "0123456789", 10), 10); + ssize_t n = -2; + { + auto f = fault_scope::returning_any_thread(sys::read, 3); + std::thread t([&] + { + char buf[16]; + n = ::read(sv[1], buf, sizeof(buf)); + }); + t.join(); + BOOST_TEST(f.fired()); + } + BOOST_TEST_EQ(n, 3); + ::close(sv[0]); + ::close(sv[1]); + } + + // Deferred-path tests park an operation with one arm and fail its + // reactor retry with another, so arms must be independent. + void testTwoArmsCoexist() + { + fault_scope f1(sys::socket, EMFILE); + fault_scope f2(sys::listen, EOPNOTSUPP); + int a = ::socket(AF_INET, SOCK_STREAM, 0); + BOOST_TEST_EQ(a, -1); + BOOST_TEST_EQ(errno, EMFILE); + BOOST_TEST(f1.fired()); + BOOST_TEST(!f2.fired()); + int b = ::socket(AF_INET, SOCK_STREAM, 0); + BOOST_TEST(b >= 0); + BOOST_TEST_EQ(::listen(b, 1), -1); + BOOST_TEST_EQ(errno, EOPNOTSUPP); + BOOST_TEST(f2.fired()); + ::close(b); + } + + // Two arms on the same symbol each count calls on their own, so + // nth selects which occurrence a given arm claims. + void testTwoArmsSameSymbolCountIndependently() + { + fault_scope f1(sys::socket, EMFILE, 1); + fault_scope f2(sys::socket, EACCES, 2); + int a = ::socket(AF_INET, SOCK_STREAM, 0); + BOOST_TEST_EQ(a, -1); + BOOST_TEST_EQ(errno, EMFILE); + BOOST_TEST(f1.fired()); + BOOST_TEST(!f2.fired()); + int b = ::socket(AF_INET, SOCK_STREAM, 0); + BOOST_TEST_EQ(b, -1); + BOOST_TEST_EQ(errno, EACCES); + BOOST_TEST(f2.fired()); + int c = ::socket(AF_INET, SOCK_STREAM, 0); + BOOST_TEST(c >= 0); + ::close(c); + } + + // Two arms can reach their nth on the same call, but only one + // fault is delivered; the loser must stay armed and keep counting + // rather than be silently marked fired. + void testSameNthOnlyOneArmFires() + { + fault_scope f1(sys::socket, EMFILE); + fault_scope f2(sys::socket, EACCES); + int a = ::socket(AF_INET, SOCK_STREAM, 0); + BOOST_TEST_EQ(a, -1); + BOOST_TEST_EQ(errno, EMFILE); + BOOST_TEST(f1.fired()); + BOOST_TEST(!f2.fired()); + int b = ::socket(AF_INET, SOCK_STREAM, 0); + BOOST_TEST_EQ(b, -1); + BOOST_TEST_EQ(errno, EACCES); + BOOST_TEST(f2.fired()); + int c = ::socket(AF_INET, SOCK_STREAM, 0); + BOOST_TEST(c >= 0); + ::close(c); + } + + void testOnlyMatchingSymbolFires() + { + fault_scope f(sys::listen, EOPNOTSUPP); + int a = ::socket(AF_INET, SOCK_STREAM, 0); + BOOST_TEST(a >= 0); + BOOST_TEST(!f.fired()); + BOOST_TEST_EQ(::listen(a, 1), -1); + BOOST_TEST_EQ(errno, EOPNOTSUPP); + BOOST_TEST(f.fired()); + ::close(a); + } + + void testEveryCensusSymbolFails() + { + // One representative call per shadow, each expected to fail. + int fd = ::socket(AF_INET, SOCK_STREAM, 0); + BOOST_TEST(fd >= 0); + auto expect = [&](sys s, auto&& call) + { + fault_scope f(s, EPERM); + auto r = call(); + BOOST_TEST(f.fired()); + BOOST_TEST_EQ((long)r, -1L); + BOOST_TEST_EQ(errno, EPERM); + }; + sockaddr_in sa{}; + sa.sin_family = AF_INET; + socklen_t len = sizeof(sa); + int one = 1; + char buf[4]; + iovec iov{buf, sizeof(buf)}; + msghdr mh{}; + mh.msg_iov = &iov; + mh.msg_iovlen = 1; + pollfd pfd{fd, POLLIN, 0}; + int pf[2]; + struct stat st; + struct sigaction sa_old; + addrinfo* ai = nullptr; + char host[64]; + timeval tv{0, 0}; + fd_set fds; + FD_ZERO(&fds); + int sv[2]; + + expect(sys::socketpair, [&]{ return ::socketpair(AF_UNIX, SOCK_STREAM, 0, sv); }); + expect(sys::bind, [&]{ return ::bind(fd, (sockaddr*)&sa, sizeof(sa)); }); + expect(sys::listen, [&]{ return ::listen(fd, 1); }); + expect(sys::accept, [&]{ return ::accept(fd, nullptr, nullptr); }); + expect(sys::connect, [&]{ return ::connect(fd, (sockaddr*)&sa, sizeof(sa)); }); + expect(sys::getsockname, [&]{ return ::getsockname(fd, (sockaddr*)&sa, &len); }); + expect(sys::getpeername, [&]{ return ::getpeername(fd, (sockaddr*)&sa, &len); }); + expect(sys::getsockopt, [&]{ len = sizeof(one); return ::getsockopt(fd, SOL_SOCKET, SO_TYPE, &one, &len); }); + expect(sys::setsockopt, [&]{ return ::setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, &one, sizeof(one)); }); + expect(sys::shutdown, [&]{ return ::shutdown(fd, SHUT_RD); }); + expect(sys::read, [&]{ return ::read(fd, buf, sizeof(buf)); }); + expect(sys::write, [&]{ return ::write(fd, buf, sizeof(buf)); }); + expect(sys::writev, [&]{ return ::writev(fd, &iov, 1); }); + expect(sys::readv, [&]{ return ::readv(fd, &iov, 1); }); + expect(sys::preadv, [&]{ return ::preadv(fd, &iov, 1, 0); }); + expect(sys::pwritev, [&]{ return ::pwritev(fd, &iov, 1, 0); }); + expect(sys::recv, [&]{ return ::recv(fd, buf, sizeof(buf), 0); }); + expect(sys::send, [&]{ return ::send(fd, buf, sizeof(buf), 0); }); + expect(sys::recvmsg, [&]{ return ::recvmsg(fd, &mh, 0); }); + expect(sys::sendmsg, [&]{ return ::sendmsg(fd, &mh, 0); }); + expect(sys::poll, [&]{ return ::poll(&pfd, 1, 0); }); + expect(sys::pipe, [&]{ return ::pipe(pf); }); + expect(sys::fcntl, [&]{ return ::fcntl(fd, F_GETFL); }); + expect(sys::ioctl, [&]{ return ::ioctl(fd, FIONREAD, &one); }); + expect(sys::open, [&]{ return ::open("/dev/null", O_RDONLY); }); + // fstat is an inline redirect to __fxstat on pre-2.33 glibc, so the + // "fstat" symbol has no shadow to arm there: the readback marks it + // not live and the call goes straight to libc. Expect not-live + // rather than asserting a fire that cannot happen. + if(hook_is_live(sys::fstat)) + expect(sys::fstat, [&]{ return ::fstat(fd, &st); }); + else + skip_dead_hook("fstat"); + expect(sys::lseek, [&]{ return (long)::lseek(fd, 0, SEEK_SET); }); + expect(sys::ftruncate, [&]{ return ::ftruncate(fd, 0); }); + expect(sys::fsync, [&]{ return ::fsync(fd); }); + expect(sys::unlink, [&]{ return ::unlink("/nonexistent/x"); }); + expect(sys::sigaction, [&]{ return ::sigaction(SIGUSR1, nullptr, &sa_old); }); + expect(sys::gethostname, [&]{ return ::gethostname(host, sizeof(host)); }); + expect(sys::select, [&]{ return ::select(1, &fds, nullptr, nullptr, &tv); }); +#if defined(__linux__) || defined(__FreeBSD__) + expect(sys::fdatasync, [&]{ return ::fdatasync(fd); }); +#endif +#if defined(__linux__) + expect(sys::accept4, [&]{ return ::accept4(fd, nullptr, nullptr, 0); }); + expect(sys::epoll_create1, [&]{ return ::epoll_create1(0); }); + expect(sys::epoll_ctl, [&]{ return ::epoll_ctl(fd, EPOLL_CTL_DEL, fd, nullptr); }); + expect(sys::epoll_wait, [&]{ epoll_event ev; return ::epoll_wait(fd, &ev, 1, 0); }); + expect(sys::eventfd, [&]{ return ::eventfd(0, 0); }); + expect(sys::timerfd_create, [&]{ return ::timerfd_create(CLOCK_MONOTONIC, 0); }); + expect(sys::timerfd_settime, [&]{ itimerspec its{}; return ::timerfd_settime(fd, 0, &its, nullptr); }); +#endif +#if defined(__APPLE__) || defined(__FreeBSD__) + expect(sys::kqueue, [&]{ return ::kqueue(); }); + // An invalid kq would fail anyway; the arm must be what fails it. + expect(sys::kevent, [&] + { + struct kevent ch{}; + return ::kevent(-1, &ch, 1, nullptr, 0, nullptr); + }); +#endif + expect(sys::close, [&]{ return ::close(fd); }); + // getaddrinfo / getnameinfo return the error, not -1 +#if defined(__linux__) || defined(__FreeBSD__) + { + fault_scope f(sys::posix_fadvise, EPERM); + BOOST_TEST_EQ(::posix_fadvise(fd, 0, 0, POSIX_FADV_NORMAL), EPERM); + BOOST_TEST(f.fired()); + } +#endif + { + fault_scope f(sys::getaddrinfo, EAI_FAIL); + BOOST_TEST_EQ(::getaddrinfo("localhost", nullptr, nullptr, &ai), EAI_FAIL); + BOOST_TEST(f.fired()); + } + { + fault_scope f(sys::getnameinfo, EAI_FAIL); + BOOST_TEST_EQ(::getnameinfo((sockaddr*)&sa, sizeof(sa), host, sizeof(host), nullptr, 0, 0), EAI_FAIL); + BOOST_TEST(f.fired()); + } + // freeaddrinfo has no failure to inject; the arm only proves + // the shadow saw the release, and the list is still freed. + { + ai = nullptr; + if(::getaddrinfo("localhost", nullptr, nullptr, &ai) == 0 && ai) + { + fault_scope f(sys::freeaddrinfo, EPERM); + ::freeaddrinfo(ai); + BOOST_TEST(f.fired()); + } + } + ::close(fd); + } + + void testReturningTruncatesAndForwards() + { + int sv[2]; + BOOST_TEST_EQ(::socketpair(AF_UNIX, SOCK_STREAM, 0, sv), 0); + char const msg[] = "0123456789"; + { + auto f = fault_scope::returning(sys::write, 4); + BOOST_TEST_EQ(::write(sv[0], msg, 10), 4); + BOOST_TEST(f.fired()); + } + char buf[16] = {}; + BOOST_TEST_EQ(::read(sv[1], buf, sizeof(buf)), 4); + BOOST_TEST_EQ(std::string_view(buf, 4), "0123"); + + BOOST_TEST_EQ(::write(sv[0], msg, 10), 10); + { + auto f = fault_scope::returning(sys::read, 3); + BOOST_TEST_EQ(::read(sv[1], buf, sizeof(buf)), 3); + BOOST_TEST(f.fired()); + BOOST_TEST_EQ(std::string_view(buf, 3), "012"); + } + BOOST_TEST_EQ(::read(sv[1], buf, sizeof(buf)), 7); + + // iovec truncation keeps the prefix of the scatter list + BOOST_TEST_EQ(::write(sv[0], msg, 10), 10); + char a[4] = {}, b[4] = {}; + iovec iov[2] = {{a, 4}, {b, 4}}; + { + auto f = fault_scope::returning(sys::readv, 6); + BOOST_TEST_EQ(::readv(sv[1], iov, 2), 6); + BOOST_TEST(f.fired()); + BOOST_TEST_EQ(std::string_view(a, 4), "0123"); + BOOST_TEST_EQ(std::string_view(b, 2), "45"); + } + BOOST_TEST_EQ(::read(sv[1], buf, sizeof(buf)), 4); + + // zero on the read side is EOF without touching the socket + BOOST_TEST_EQ(::write(sv[0], msg, 10), 10); + { + auto f = fault_scope::returning(sys::recv, 0); + BOOST_TEST_EQ(::recv(sv[1], buf, sizeof(buf), 0), 0); + BOOST_TEST(f.fired()); + } + BOOST_TEST_EQ(::recv(sv[1], buf, sizeof(buf), 0), 10); + ::close(sv[0]); + ::close(sv[1]); + } + +#if defined(__APPLE__) + // spells the call select$DARWIN_EXTSN under + // _DARWIN_C_SOURCE and plain select otherwise. This translation + // unit gets the plain spelling, so the suffixed shadow is reached + // by its asm name to prove both land on the same arm. + void testDarwinSelectAliasReachesHook() + { + fd_set fds; + FD_ZERO(&fds); + timeval tv{0, 0}; + { + fault_scope f(sys::select, EIO); + BOOST_TEST_EQ(::select(1, &fds, nullptr, nullptr, &tv), -1); + BOOST_TEST_EQ(errno, EIO); + BOOST_TEST(f.fired()); + } + { + fault_scope f(sys::select, EIO); + BOOST_TEST_EQ( + ::corosio_fault_select_extsn(1, &fds, nullptr, nullptr, &tv), + -1); + BOOST_TEST_EQ(errno, EIO); + BOOST_TEST(f.fired()); + } + // Unarmed, the alias still forwards to the real call. + FD_ZERO(&fds); + BOOST_TEST_EQ( + ::corosio_fault_select_extsn(0, &fds, nullptr, nullptr, &tv), 0); + } +#endif + +#if defined(__linux__) + void testChkAliasesReachHook() + { + int sv[2]; + BOOST_TEST_EQ(::socketpair(AF_UNIX, SOCK_STREAM, 0, sv), 0); + char buf[8]; + { + fault_scope f(sys::read, EIO); + BOOST_TEST_EQ(::__read_chk(sv[1], buf, sizeof(buf), sizeof(buf)), -1); + BOOST_TEST(f.fired()); + } + { + fault_scope f(sys::recv, EIO); + BOOST_TEST_EQ(::__recv_chk(sv[1], buf, sizeof(buf), sizeof(buf), 0), -1); + BOOST_TEST(f.fired()); + } + { + fault_scope f(sys::poll, EIO); + pollfd p{sv[1], POLLIN, 0}; + BOOST_TEST_EQ(::__poll_chk(&p, 1, 0, sizeof(p)), -1); + BOOST_TEST(f.fired()); + } + { + fault_scope f(sys::open, EIO); + BOOST_TEST_EQ(::__open_2("/dev/null", O_RDONLY), -1); + BOOST_TEST(f.fired()); + } + { + fault_scope f(sys::gethostname, EIO); + char host[64]; + BOOST_TEST_EQ(::__gethostname_chk(host, sizeof(host), sizeof(host)), -1); + BOOST_TEST(f.fired()); + } + ::close(sv[0]); + ::close(sv[1]); + } +#endif + + // `nth` arithmetic in the backend suites has to reach past calls + // the library makes on the way in. count() is what lets a test say + // how many there were instead of hard-coding the number. + void testCountTracksCalls() + { + fault_scope f(sys::socket, EMFILE, 3); + BOOST_TEST_EQ(f.count(), 0u); + int a = ::socket(AF_INET, SOCK_STREAM, 0); + int b = ::socket(AF_INET, SOCK_STREAM, 0); + BOOST_TEST_EQ(f.count(), 2u); + BOOST_TEST(!f.fired()); + BOOST_TEST_EQ(::socket(AF_INET, SOCK_STREAM, 0), -1); + BOOST_TEST_EQ(f.count(), 3u); + BOOST_TEST(f.fired()); + // A spent arm stops counting: it no longer claims calls. + int c = ::socket(AF_INET, SOCK_STREAM, 0); + BOOST_TEST_EQ(f.count(), 3u); + ::close(a); + ::close(b); + ::close(c); + } + + // Every backend suite asserts fired() on the arms it sets. A symbol + // this platform has no shadow for would fail that assertion with + // nothing to say why, so a portable test asks first. + void testHookIsLiveAnswersPerPlatform() + { + BOOST_TEST(hook_is_live(sys::socket)); + BOOST_TEST(hook_is_live(sys::getsockopt)); + BOOST_TEST(hook_is_live(sys::select)); + // No POSIX shadow spells a Win32 entry point. + BOOST_TEST(!hook_is_live(sys::WSASocketW)); + BOOST_TEST(!hook_is_live(sys::CreateIoCompletionPort)); +#if defined(__linux__) + BOOST_TEST(hook_is_live(sys::epoll_ctl)); + BOOST_TEST(hook_is_live(sys::accept4)); + BOOST_TEST(!hook_is_live(sys::kevent)); +#endif +#if defined(__APPLE__) || defined(__FreeBSD__) + BOOST_TEST(hook_is_live(sys::kevent)); + BOOST_TEST(!hook_is_live(sys::epoll_ctl)); +#endif +#if BOOST_COROSIO_HAVE_LIBURING + BOOST_TEST(hook_is_live(sys::io_uring_submit)); + BOOST_TEST(hook_is_live(sys::uring_sqe_full)); +#else + BOOST_TEST(!hook_is_live(sys::uring_sqe_full)); +#endif + } + + // The select backend rejects descriptors it cannot represent, and a + // descriptor number is not something a caller picks: the tests that + // reach those arms need these two seams to work. + void testHighFdHelpers() + { + int const fd = ::socket(AF_INET, SOCK_STREAM, 0); + BOOST_TEST(fd >= 0); + int const hi = dup_above_fd_setsize(fd); + if(hi < 0) + { + skip_no_high_fd("the high-descriptor helper self-test"); + ::close(fd); + return; + } + BOOST_TEST(hi >= FD_SETSIZE); + BOOST_TEST_EQ(::fcntl(hi, F_GETFD) == -1, false); + ::close(hi); + ::close(fd); + + { + fd_wall wall; + if(!wall.ok()) + { + skip_no_high_fd("the descriptor-wall self-test"); + return; + } + int const walled = ::socket(AF_INET, SOCK_STREAM, 0); + BOOST_TEST(walled >= FD_SETSIZE); + ::close(walled); + } + // The wall releases the numbers it held, or every later test + // would run against a table it did not ask for. + int const freed = ::socket(AF_INET, SOCK_STREAM, 0); + BOOST_TEST(freed >= 0); + BOOST_TEST(freed < FD_SETSIZE); + ::close(freed); + } + +#if BOOST_COROSIO_HAVE_LIBURING + void testUringSubmitFails() + { + ::io_uring ring; + io_uring_params p{}; + BOOST_TEST_EQ(io_uring_queue_init_params(4, &ring, &p), 0); + { + fault_scope f(sys::io_uring_submit, EBADF); + BOOST_TEST_EQ(io_uring_submit(&ring), -EBADF); + BOOST_TEST(f.fired()); + } + BOOST_TEST_EQ(io_uring_submit(&ring), 0); + io_uring_queue_exit(&ring); + } + + void testUringSqeFull() + { + fault_scope f(sys::uring_sqe_full, 0); + ::io_uring ring; + io_uring_params p{}; + BOOST_TEST_EQ(io_uring_queue_init_params(64, &ring, &p), 0); + // liburing may round the clamped entry count up to its own + // minimum instead of honoring 1 exactly. + BOOST_TEST(p.sq_entries <= 2); + BOOST_TEST(io_uring_get_sqe(&ring) != nullptr); + BOOST_TEST(io_uring_get_sqe(&ring) == nullptr); + BOOST_TEST_EQ(io_uring_submit(&ring), 0); + BOOST_TEST(f.fired()); + BOOST_TEST(io_uring_get_sqe(&ring) == nullptr); + io_uring_queue_exit(&ring); + } + + void testCqeRewrite() + { + ::io_uring ring; + io_uring_params p{}; + BOOST_TEST_EQ(io_uring_queue_init_params(4, &ring, &p), 0); + int sv[2]; + BOOST_TEST_EQ(::socketpair(AF_UNIX, SOCK_STREAM, 0, sv), 0); + BOOST_TEST_EQ(::write(sv[0], "xyz", 3), 3); + char buf[8]; + cqe_fault_scope c(sv[1], IORING_OP_RECV, -ECONNRESET); + auto* sqe = io_uring_get_sqe(&ring); + io_uring_prep_recv(sqe, sv[1], buf, sizeof(buf), 0); + io_uring_sqe_set_data64(sqe, 42); + BOOST_TEST_EQ(io_uring_submit(&ring), 1); + io_uring_cqe* cqe = nullptr; + BOOST_TEST_EQ(io_uring_wait_cqe_timeout(&ring, &cqe, nullptr), 0); + BOOST_TEST(c.fired()); + BOOST_TEST_EQ(cqe->user_data, 42u); + BOOST_TEST_EQ(cqe->res, -ECONNRESET); + io_uring_cqe_seen(&ring, cqe); + ::close(sv[0]); + ::close(sv[1]); + io_uring_queue_exit(&ring); + } + + // The multishot re-arm paths key off IORING_CQE_F_MORE, which only + // the kernel clears. Rewriting `res` alone cannot reach them. + void testCqeFlagsCleared() + { + ::io_uring ring; + io_uring_params p{}; + BOOST_TEST_EQ(io_uring_queue_init_params(4, &ring, &p), 0); + int sv[2]; + BOOST_TEST_EQ(::socketpair(AF_UNIX, SOCK_STREAM, 0, sv), 0); + { + // fd -1: match on the opcode alone, the way a test reaches + // a poll armed on a descriptor the library never handed out. + cqe_fault_scope c(-1, IORING_OP_POLL_ADD, POLLIN, + IORING_CQE_F_MORE); + auto* sqe = io_uring_get_sqe(&ring); + io_uring_prep_poll_multishot(sqe, sv[1], POLLIN); + io_uring_sqe_set_data64(sqe, 7); + BOOST_TEST_EQ(io_uring_submit(&ring), 1); + BOOST_TEST_EQ(::write(sv[0], "x", 1), 1); + io_uring_cqe* cqe = nullptr; + BOOST_TEST_EQ( + io_uring_wait_cqe_timeout(&ring, &cqe, nullptr), 0); + BOOST_TEST(c.fired()); + BOOST_TEST_EQ(cqe->user_data, 7u); + BOOST_TEST_EQ(cqe->flags & IORING_CQE_F_MORE, 0u); + io_uring_cqe_seen(&ring, cqe); + } + ::close(sv[0]); + ::close(sv[1]); + io_uring_queue_exit(&ring); + } +#endif + + void run() + { + if(skip_under_valgrind()) + return; + testFiresOnNth(); + testDisarmsOnScopeExit(); + testFiredScopeStaysFired(); + testOpenFdsProbeWorks(); + testTransparentWhenUnarmed(); + testThreadIsolation(); + testAnyThreadFires(); + testThreadLocalWinsOverAnyThread(); + testAnyThreadDisarmsOnScopeExit(); + testAnyThreadShortens(); + testTwoArmsCoexist(); + testTwoArmsSameSymbolCountIndependently(); + testSameNthOnlyOneArmFires(); + testOnlyMatchingSymbolFires(); + testEveryCensusSymbolFails(); + testReturningTruncatesAndForwards(); + testCountTracksCalls(); + testHookIsLiveAnswersPerPlatform(); + testHighFdHelpers(); +#if defined(__APPLE__) + testDarwinSelectAliasReachesHook(); +#endif +#if defined(__linux__) + testChkAliasesReachHook(); +#endif +#if BOOST_COROSIO_HAVE_LIBURING + testUringSubmitFails(); + testUringSqeFull(); + testCqeRewrite(); + testCqeFlagsCleared(); +#endif + } +}; + +TEST_SUITE(self_test, "boost.corosio.fault.self"); + +} // boost::corosio::test::fault + +#else + +#include +#include +#include + +// MinGW's stops short of this one; the library declares it +// for itself the same way. +#if defined(__MINGW32__) || defined(__MINGW64__) +extern "C" INT WSAAPI GetAddrInfoExCancel(LPHANDLE lpHandle); +#endif + +#include +#include +#include +#include + +namespace boost::corosio::test::fault { + +// BOOST_TEST_CSTR_EQ expands to a bare `string_view(...)`, which needs +// this in scope; capy's test_suite.hpp does not pull one in itself. +using std::string_view; + +namespace { + +// One arbitrary code, armed everywhere: what matters is that the value +// the arm carries is the value the caller reads back, not which code +// it is. +int constexpr test_err = ERROR_NOT_SUPPORTED; + +// Winsock and the kernel share the per-thread error slot, so one read +// covers both families. +bool err_is(int expected) noexcept +{ + return ::GetLastError() == static_cast(expected); +} + +// A connected loopback pair. The shortening hooks clamp a real call, +// so the bytes have to move through a real socket; Windows has no +// socketpair and nothing here builds an io_context to borrow +// connect_pair from. +bool make_loopback_pair(SOCKET& a, SOCKET& b) +{ + a = b = INVALID_SOCKET; + SOCKET acc = ::socket(AF_INET, SOCK_STREAM, IPPROTO_TCP); + if(acc == INVALID_SOCKET) + return false; + sockaddr_in sa{}; + sa.sin_family = AF_INET; + sa.sin_addr.s_addr = ::htonl(INADDR_LOOPBACK); + int len = static_cast(sizeof(sa)); + bool ok = ::bind(acc, reinterpret_cast(&sa), len) == 0 && + ::listen(acc, 1) == 0 && + ::getsockname(acc, reinterpret_cast(&sa), &len) == 0; + if(ok) + { + a = ::socket(AF_INET, SOCK_STREAM, IPPROTO_TCP); + ok = a != INVALID_SOCKET && + ::connect(a, reinterpret_cast(&sa), + static_cast(sizeof(sa))) == 0; + } + if(ok) + { + b = ::accept(acc, nullptr, nullptr); + ok = b != INVALID_SOCKET; + } + std::ignore = ::closesocket(acc); + return ok; +} + +// A stream may deliver a send in pieces. The assertions below are +// about what the hook did to the call it clamped, so the other side +// drains a count it already knows. +int recv_exactly(SOCKET s, char* p, int n) +{ + int got = 0; + while(got < n) + { + int const r = ::recv(s, p + got, n - got, 0); + if(r <= 0) + return got; + got += r; + } + return got; +} + +// Every entry point this file arms is also called from this +// translation unit, so the program imports all of them: a name with no +// thunk to patch here is census drift rather than a toolchain +// difference, and fails rather than skips. +bool require_hook(sys s, char const* name) +{ + if(hook_is_live(s)) + return true; + std::fprintf(stderr, "fault harness: %s is not hooked here\n", name); + BOOST_TEST(false); + return false; +} + +} // namespace + +struct self_test +{ + // Winsock has to be up before any socket call in this program; + // nothing here constructs an io_context to do it. + struct winsock_guard + { + winsock_guard() + { + WSADATA data; + BOOST_TEST_EQ(::WSAStartup(MAKEWORD(2, 2), &data), 0); + } + ~winsock_guard() { std::ignore = ::WSACleanup(); } + }; + + void testFiresOnNth() + { + fault_scope f(sys::socket, WSAEMFILE, 2); + SOCKET a = ::socket(AF_INET, SOCK_STREAM, IPPROTO_TCP); + BOOST_TEST(a != INVALID_SOCKET); + BOOST_TEST(!f.fired()); + SOCKET b = ::socket(AF_INET, SOCK_STREAM, IPPROTO_TCP); + BOOST_TEST(b == INVALID_SOCKET); + BOOST_TEST(err_is(WSAEMFILE)); + BOOST_TEST(f.fired()); + SOCKET c = ::socket(AF_INET, SOCK_STREAM, IPPROTO_TCP); + BOOST_TEST(c != INVALID_SOCKET); + std::ignore = ::closesocket(a); + std::ignore = ::closesocket(c); + } + + void testDisarmsOnScopeExit() + { + { + fault_scope f(sys::socket, WSAEMFILE, 5); + } + SOCKET a = ::socket(AF_INET, SOCK_STREAM, IPPROTO_TCP); + BOOST_TEST(a != INVALID_SOCKET); + std::ignore = ::closesocket(a); + } + + // `nth` arithmetic in the backend suites has to reach past calls + // the library makes on the way in. count() is what lets a test say + // how many there were instead of hard-coding the number. The arm + // model is shared with the POSIX harness, so what this pins down + // is that the Windows hooks feed it. + void testCountTracksCalls() + { + fault_scope f(sys::socket, WSAEMFILE, 3); + BOOST_TEST_EQ(f.count(), 0u); + SOCKET a = ::socket(AF_INET, SOCK_STREAM, IPPROTO_TCP); + SOCKET b = ::socket(AF_INET, SOCK_STREAM, IPPROTO_TCP); + BOOST_TEST_EQ(f.count(), 2u); + BOOST_TEST(!f.fired()); + BOOST_TEST(::socket(AF_INET, SOCK_STREAM, IPPROTO_TCP) == + INVALID_SOCKET); + BOOST_TEST_EQ(f.count(), 3u); + BOOST_TEST(f.fired()); + // A spent arm stops counting: it no longer claims calls. + SOCKET c = ::socket(AF_INET, SOCK_STREAM, IPPROTO_TCP); + BOOST_TEST_EQ(f.count(), 3u); + std::ignore = ::closesocket(a); + std::ignore = ::closesocket(b); + std::ignore = ::closesocket(c); + } + + void testFiredScopeStaysFired() + { + fault_scope f(sys::socket, WSAEMFILE); + BOOST_TEST(::socket(AF_INET, SOCK_STREAM, IPPROTO_TCP) == + INVALID_SOCKET); + BOOST_TEST(f.fired()); + SOCKET b = ::socket(AF_INET, SOCK_STREAM, IPPROTO_TCP); + BOOST_TEST(b != INVALID_SOCKET); + BOOST_TEST(f.fired()); + std::ignore = ::closesocket(b); + } + + void testOpenFdsProbeWorks() + { + BOOST_TEST(open_fds() > 0); + } + + void testTransparentWhenUnarmed() + { + SOCKET s = ::socket(AF_INET, SOCK_STREAM, IPPROTO_TCP); + BOOST_TEST(s != INVALID_SOCKET); + sockaddr_in sa{}; + sa.sin_family = AF_INET; + sa.sin_addr.s_addr = ::htonl(INADDR_LOOPBACK); + BOOST_TEST_EQ(::bind(s, reinterpret_cast(&sa), + static_cast(sizeof(sa))), 0); + BOOST_TEST_EQ(::listen(s, 1), 0); + std::ignore = ::closesocket(s); + } + + void testThreadIsolation() + { + fault_scope f(sys::socket, WSAEMFILE); + SOCKET other = INVALID_SOCKET; + std::thread t([&] + { + other = ::socket(AF_INET, SOCK_STREAM, IPPROTO_TCP); + }); + t.join(); + BOOST_TEST(other != INVALID_SOCKET); + BOOST_TEST(!f.fired()); + std::ignore = ::closesocket(other); + BOOST_TEST(::socket(AF_INET, SOCK_STREAM, IPPROTO_TCP) == + INVALID_SOCKET); + BOOST_TEST(f.fired()); + } + + // The library resolves names and moves file bytes on a thread + // pool, where the test thread's arms are never consulted. + void testAnyThreadFires() + { + fault_scope f(sys::socket, WSAEMFILE, 1, any_thread); + SOCKET other = INVALID_SOCKET; + DWORD other_err = 0; + std::thread t([&] + { + other = ::socket(AF_INET, SOCK_STREAM, IPPROTO_TCP); + other_err = ::GetLastError(); + }); + t.join(); + BOOST_TEST(other == INVALID_SOCKET); + BOOST_TEST_EQ(other_err, static_cast(WSAEMFILE)); + BOOST_TEST(f.fired()); + } + + void testThreadLocalWinsOverAnyThread() + { + fault_scope g(sys::listen, WSAEOPNOTSUPP, 1, any_thread); + fault_scope f(sys::socket, WSAEMFILE); + BOOST_TEST(::socket(AF_INET, SOCK_STREAM, IPPROTO_TCP) == + INVALID_SOCKET); + BOOST_TEST(f.fired()); + BOOST_TEST(!g.fired()); + SOCKET b = ::socket(AF_INET, SOCK_STREAM, IPPROTO_TCP); + BOOST_TEST(b != INVALID_SOCKET); + BOOST_TEST_EQ(::listen(b, 1), SOCKET_ERROR); + BOOST_TEST(err_is(WSAEOPNOTSUPP)); + BOOST_TEST(g.fired()); + std::ignore = ::closesocket(b); + } + + void testTwoArmsCoexist() + { + fault_scope f1(sys::socket, WSAEMFILE); + fault_scope f2(sys::listen, WSAEOPNOTSUPP); + BOOST_TEST(::socket(AF_INET, SOCK_STREAM, IPPROTO_TCP) == + INVALID_SOCKET); + BOOST_TEST(f1.fired()); + BOOST_TEST(!f2.fired()); + SOCKET b = ::socket(AF_INET, SOCK_STREAM, IPPROTO_TCP); + BOOST_TEST(b != INVALID_SOCKET); + BOOST_TEST_EQ(::listen(b, 1), SOCKET_ERROR); + BOOST_TEST(f2.fired()); + std::ignore = ::closesocket(b); + } + + // One call per hook, each expected to report its documented + // failure with the armed code. A hook the toolchain gave no import + // to patch is named and skipped rather than silently passing. + void testEveryCensusSymbolFails() + { + SOCKET fd = ::socket(AF_INET, SOCK_STREAM, IPPROTO_TCP); + BOOST_TEST(fd != INVALID_SOCKET); + HANDLE port = ::CreateIoCompletionPort(INVALID_HANDLE_VALUE, nullptr, + 0, 1); + BOOST_TEST(port != nullptr); + + // This translation unit references every hooked entry point, so + // the program imports every one of them: a hook with no thunk + // to patch here is census drift, not a toolchain difference. + auto expect = [&](sys s, char const* name, auto&& call) + { + if(!require_hook(s, name)) + return; + fault_scope f(s, test_err); + bool const failed = call(); + // Name the symbol: fifty anonymous BOOST_TEST lines say + // nothing about which hook came apart. + if(!failed || !f.fired()) + std::fprintf(stderr, + "fault harness: census failed for %s\n", name); + BOOST_TEST(failed); + BOOST_TEST(f.fired()); + }; + + sockaddr_in sa{}; + sa.sin_family = AF_INET; + sa.sin_addr.s_addr = ::htonl(INADDR_LOOPBACK); + int const salen = static_cast(sizeof(sa)); + int len = salen; + int one = 1; + char buf[8] = {}; + WSABUF wbuf{}; + wbuf.len = static_cast(sizeof(buf)); + wbuf.buf = buf; + DWORD bytes = 0; + DWORD flags = 0; + u_long mode = 1; + WSAPOLLFD pfd{}; + pfd.fd = fd; + pfd.events = static_cast(POLLRDNORM); + LARGE_INTEGER big{}; + wchar_t wide[64] = {}; + char narrow[64] = {}; + DWORD widelen = 64; + ULONG_PTR key = 0; + LPOVERLAPPED got = nullptr; + OVERLAPPED ov{}; + HANDLE cancel = nullptr; + + expect(sys::socket, "socket", [&] + { return ::socket(AF_INET, SOCK_STREAM, IPPROTO_TCP) == + INVALID_SOCKET && err_is(test_err); }); + expect(sys::WSASocketW, "WSASocketW", [&] + { return ::WSASocketW(AF_INET, SOCK_STREAM, IPPROTO_TCP, nullptr, + 0, WSA_FLAG_OVERLAPPED) == INVALID_SOCKET && + err_is(test_err); }); + expect(sys::bind, "bind", [&] + { return ::bind(fd, reinterpret_cast(&sa), salen) + == SOCKET_ERROR && err_is(test_err); }); + expect(sys::listen, "listen", [&] + { return ::listen(fd, 1) == SOCKET_ERROR && err_is(test_err); }); + expect(sys::accept, "accept", [&] + { return ::accept(fd, nullptr, nullptr) == INVALID_SOCKET && + err_is(test_err); }); + expect(sys::connect, "connect", [&] + { return ::connect(fd, reinterpret_cast(&sa), + salen) == SOCKET_ERROR && err_is(test_err); }); + expect(sys::shutdown, "shutdown", [&] + { return ::shutdown(fd, SD_RECEIVE) == SOCKET_ERROR && + err_is(test_err); }); + expect(sys::ioctlsocket, "ioctlsocket", [&] + { return ::ioctlsocket(fd, FIONBIO, &mode) == SOCKET_ERROR && + err_is(test_err); }); + expect(sys::getsockname, "getsockname", [&] + { return ::getsockname(fd, reinterpret_cast(&sa), &len) + == SOCKET_ERROR && err_is(test_err); }); + expect(sys::getpeername, "getpeername", [&] + { return ::getpeername(fd, reinterpret_cast(&sa), &len) + == SOCKET_ERROR && err_is(test_err); }); + expect(sys::getsockopt, "getsockopt", [&] + { int optlen = sizeof(one); + return ::getsockopt(fd, SOL_SOCKET, SO_TYPE, + reinterpret_cast(&one), &optlen) == SOCKET_ERROR && + err_is(test_err); }); + expect(sys::setsockopt, "setsockopt", [&] + { return ::setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, + reinterpret_cast(&one), sizeof(one)) == + SOCKET_ERROR && err_is(test_err); }); + expect(sys::send, "send", [&] + { return ::send(fd, buf, sizeof(buf), 0) == SOCKET_ERROR && + err_is(test_err); }); + expect(sys::recv, "recv", [&] + { return ::recv(fd, buf, sizeof(buf), 0) == SOCKET_ERROR && + err_is(test_err); }); + expect(sys::WSAConnect, "WSAConnect", [&] + { return ::WSAConnect(fd, reinterpret_cast(&sa), + salen, nullptr, nullptr, nullptr, nullptr) == + SOCKET_ERROR && err_is(test_err); }); + expect(sys::WSARecv, "WSARecv", [&] + { return ::WSARecv(fd, &wbuf, 1, &bytes, &flags, nullptr, + nullptr) == SOCKET_ERROR && err_is(test_err); }); + expect(sys::WSASend, "WSASend", [&] + { return ::WSASend(fd, &wbuf, 1, &bytes, 0, nullptr, nullptr) == + SOCKET_ERROR && err_is(test_err); }); + expect(sys::WSARecvFrom, "WSARecvFrom", [&] + { return ::WSARecvFrom(fd, &wbuf, 1, &bytes, &flags, nullptr, + nullptr, nullptr, nullptr) == SOCKET_ERROR && + err_is(test_err); }); + expect(sys::WSASendTo, "WSASendTo", [&] + { return ::WSASendTo(fd, &wbuf, 1, &bytes, 0, + reinterpret_cast(&sa), salen, nullptr, + nullptr) == SOCKET_ERROR && err_is(test_err); }); + expect(sys::WSAPoll, "WSAPoll", [&] + { return ::WSAPoll(&pfd, 1, 0) == SOCKET_ERROR && + err_is(test_err); }); + expect(sys::WSAIoctl, "WSAIoctl", [&] + { GUID g = WSAID_ACCEPTEX; void* p = nullptr; + return ::WSAIoctl(fd, SIO_GET_EXTENSION_FUNCTION_POINTER, &g, + sizeof(g), &p, sizeof(p), &bytes, nullptr, nullptr) == + SOCKET_ERROR && err_is(test_err); }); + expect(sys::closesocket, "closesocket", [&] + { return ::closesocket(fd) == SOCKET_ERROR && err_is(test_err); }); + expect(sys::WSAStartup, "WSAStartup", [&] + { WSADATA d; return ::WSAStartup(MAKEWORD(2, 2), &d) == + test_err; }); + expect(sys::WSACleanup, "WSACleanup", [&] + { return ::WSACleanup() == SOCKET_ERROR && err_is(test_err); }); + expect(sys::GetAddrInfoExW, "GetAddrInfoExW", [&] + { PADDRINFOEXW res = nullptr; + return ::GetAddrInfoExW(L"localhost", nullptr, NS_DNS, nullptr, + nullptr, &res, nullptr, nullptr, nullptr, nullptr) == + test_err; }); + expect(sys::GetAddrInfoExCancel, "GetAddrInfoExCancel", [&] + { return ::GetAddrInfoExCancel(&cancel) == test_err; }); + expect(sys::GetNameInfoW, "GetNameInfoW", [&] + { return ::GetNameInfoW(reinterpret_cast(&sa), + salen, wide, 64, nullptr, 0, 0) == test_err; }); + expect(sys::CreateIoCompletionPort, "CreateIoCompletionPort", [&] + { return ::CreateIoCompletionPort(INVALID_HANDLE_VALUE, nullptr, + 0, 1) == nullptr && err_is(test_err); }); + expect(sys::PostQueuedCompletionStatus, "PostQueuedCompletionStatus", + [&] { return ::PostQueuedCompletionStatus(port, 0, 0, &ov) == + FALSE && err_is(test_err); }); + expect(sys::GetQueuedCompletionStatus, "GetQueuedCompletionStatus", + [&] + { got = &ov; + return ::GetQueuedCompletionStatus(port, &bytes, &key, &got, 0) + == FALSE && got == nullptr && err_is(test_err); }); + expect(sys::CancelIoEx, "CancelIoEx", [&] + { return ::CancelIoEx(port, nullptr) == FALSE && + err_is(test_err); }); + expect(sys::CreateFileW, "CreateFileW", [&] + { return ::CreateFileW(L"nul", GENERIC_READ, 0, nullptr, + OPEN_EXISTING, 0, nullptr) == INVALID_HANDLE_VALUE && + err_is(test_err); }); + expect(sys::ReadFile, "ReadFile", [&] + { return ::ReadFile(port, buf, sizeof(buf), &bytes, nullptr) == + FALSE && err_is(test_err); }); + expect(sys::WriteFile, "WriteFile", [&] + { return ::WriteFile(port, buf, sizeof(buf), &bytes, nullptr) == + FALSE && err_is(test_err); }); + expect(sys::SetFilePointerEx, "SetFilePointerEx", [&] + { return ::SetFilePointerEx(port, big, nullptr, FILE_BEGIN) == + FALSE && err_is(test_err); }); + expect(sys::GetFileSizeEx, "GetFileSizeEx", [&] + { return ::GetFileSizeEx(port, &big) == FALSE && + err_is(test_err); }); + expect(sys::SetEndOfFile, "SetEndOfFile", [&] + { return ::SetEndOfFile(port) == FALSE && err_is(test_err); }); + expect(sys::FlushFileBuffers, "FlushFileBuffers", [&] + { return ::FlushFileBuffers(port) == FALSE && err_is(test_err); }); + expect(sys::DeleteFileA, "DeleteFileA", [&] + { return ::DeleteFileA("nonexistent-corosio-fault") == FALSE && + err_is(test_err); }); + expect(sys::CreateWaitableTimerW, "CreateWaitableTimerW", [&] + { return ::CreateWaitableTimerW(nullptr, TRUE, nullptr) == + nullptr && err_is(test_err); }); + expect(sys::SetWaitableTimer, "SetWaitableTimer", [&] + { return ::SetWaitableTimer(port, &big, 0, nullptr, nullptr, + FALSE) == FALSE && err_is(test_err); }); + expect(sys::WaitForSingleObject, "WaitForSingleObject", [&] + { return ::WaitForSingleObject(port, 0) == WAIT_FAILED && + err_is(test_err); }); + expect(sys::GetComputerNameExW, "GetComputerNameExW", [&] + { return ::GetComputerNameExW(ComputerNameDnsHostname, wide, + &widelen) == FALSE && err_is(test_err); }); + expect(sys::GetModuleHandleA, "GetModuleHandleA", [&] + { return ::GetModuleHandleA("kernel32") == nullptr && + err_is(test_err); }); + expect(sys::GetModuleHandleW, "GetModuleHandleW", [&] + { return ::GetModuleHandleW(L"kernel32") == nullptr && + err_is(test_err); }); + expect(sys::GetProcAddress, "GetProcAddress", [&] + { return ::GetProcAddress(::GetModuleHandleW(L"kernel32"), + "CloseHandle") == nullptr && err_is(test_err); }); + expect(sys::MultiByteToWideChar, "MultiByteToWideChar", [&] + { return ::MultiByteToWideChar(CP_UTF8, 0, "x", 1, wide, 64) == 0 + && err_is(test_err); }); + expect(sys::WideCharToMultiByte, "WideCharToMultiByte", [&] + { return ::WideCharToMultiByte(CP_UTF8, 0, L"x", 1, narrow, 64, + nullptr, nullptr) == 0 && err_is(test_err); }); + expect(sys::signal, "signal", [&] + { return ::signal(SIGINT, SIG_DFL) == SIG_ERR && + err_is(test_err); }); + // Last, since an armed CloseHandle leaves the port open and the + // real close has to follow. + expect(sys::CloseHandle, "CloseHandle", [&] + { return ::CloseHandle(port) == FALSE && err_is(test_err); }); + + std::ignore = ::CloseHandle(port); + std::ignore = ::closesocket(fd); + } + + // The shortening hooks are the one family that forwards a modified + // call rather than refusing it, and the non-zero clamps are + // otherwise unreached on this platform. + void testReturningTruncatesAndForwards() + { + SOCKET a = INVALID_SOCKET, b = INVALID_SOCKET; + if(!make_loopback_pair(a, b)) + { + BOOST_TEST(false); + return; + } + char const msg[] = "0123456789"; + char buf[16] = {}; + { + auto f = fault_scope::returning(sys::send, 4); + BOOST_TEST_EQ(::send(a, msg, 10, 0), 4); + BOOST_TEST(f.fired()); + } + BOOST_TEST_EQ(recv_exactly(b, buf, 4), 4); + BOOST_TEST_EQ(std::string_view(buf, 4), "0123"); + + // A two-entry WSABUF array walks truncate_wsabuf's prefix: the + // first buffer fills and the second takes the remainder. + BOOST_TEST_EQ(::send(a, msg, 10, 0), 10); + char p1[4] = {}, p2[4] = {}; + WSABUF wb[2]; + wb[0].buf = p1; + wb[0].len = 4; + wb[1].buf = p2; + wb[1].len = 4; + { + auto f = fault_scope::returning(sys::WSARecv, 6); + DWORD bytes = 0; + DWORD flags = 0; + BOOST_TEST_EQ(::WSARecv(b, wb, 2, &bytes, &flags, nullptr, + nullptr), 0); + BOOST_TEST(f.fired()); + BOOST_TEST_EQ(bytes, static_cast(6)); + BOOST_TEST_EQ(std::string_view(p1, 4), "0123"); + BOOST_TEST_EQ(std::string_view(p2, 2), "45"); + } + BOOST_TEST_EQ(recv_exactly(b, buf, 4), 4); + + // Zero on the read side is EOF without touching the socket. + BOOST_TEST_EQ(::send(a, msg, 10, 0), 10); + { + auto f = fault_scope::returning(sys::recv, 0); + BOOST_TEST_EQ(::recv(b, buf, sizeof(buf), 0), 0); + BOOST_TEST(f.fired()); + } + BOOST_TEST_EQ(recv_exactly(b, buf, 10), 10); + std::ignore = ::closesocket(a); + std::ignore = ::closesocket(b); + } + + // FreeAddrInfoExW has no failure mode, so its arm is a probe that + // the release ran rather than a fault: the hook forwards either + // way, since swallowing the call would leak the list. + void testFreeAddrInfoRunsUnderArm() + { + if(!require_hook(sys::FreeAddrInfoExW, "FreeAddrInfoExW")) + return; + PADDRINFOEXW res = nullptr; + if(::GetAddrInfoExW(L"localhost", nullptr, NS_DNS, nullptr, nullptr, + &res, nullptr, nullptr, nullptr, nullptr) != 0 || !res) + return; + fault_scope f(sys::FreeAddrInfoExW, test_err); + ::FreeAddrInfoExW(res); + BOOST_TEST(f.fired()); + } + + // The one path to the error branches of the completion handlers: + // an operation the kernel completed, reported as failed. + void testCompletionFaultRewrites() + { + if(!require_hook(sys::GetQueuedCompletionStatus, + "GetQueuedCompletionStatus")) + return; + HANDLE port = ::CreateIoCompletionPort(INVALID_HANDLE_VALUE, nullptr, + 0, 1); + BOOST_TEST(port != nullptr); + OVERLAPPED ov{}; + BOOST_TEST(::PostQueuedCompletionStatus(port, 7, 99, &ov) != FALSE); + { + completion_fault_scope c(ERROR_NETNAME_DELETED); + DWORD bytes = 0; + ULONG_PTR key = 0; + LPOVERLAPPED got = nullptr; + ::SetLastError(0); + BOOL const r = ::GetQueuedCompletionStatus(port, &bytes, &key, + &got, 1000); + DWORD const err = ::GetLastError(); + BOOST_TEST(r == FALSE); + BOOST_TEST_EQ(err, static_cast(ERROR_NETNAME_DELETED)); + // The real call ran first, so the packet is still delivered: + // that is what makes this an error on a real completion. + BOOST_TEST(got == &ov); + BOOST_TEST_EQ(key, static_cast(99)); + BOOST_TEST(c.fired()); + } + std::ignore = ::CloseHandle(port); + } + + void testCompletionFaultDisarmsOnScopeExit() + { + if(!require_hook(sys::GetQueuedCompletionStatus, + "GetQueuedCompletionStatus")) + return; + HANDLE port = ::CreateIoCompletionPort(INVALID_HANDLE_VALUE, nullptr, + 0, 1); + BOOST_TEST(port != nullptr); + OVERLAPPED ov{}; + BOOST_TEST(::PostQueuedCompletionStatus(port, 7, 99, &ov) != FALSE); + { + completion_fault_scope c(ERROR_NETNAME_DELETED, 2); + } + DWORD bytes = 0; + ULONG_PTR key = 0; + LPOVERLAPPED got = nullptr; + BOOST_TEST(::GetQueuedCompletionStatus(port, &bytes, &key, &got, 1000) + != FALSE); + BOOST_TEST(got == &ov); + std::ignore = ::CloseHandle(port); + } + + // AcceptEx and ConnectEx are never imported: the library asks + // WSAIoctl for them. The hook has to hand back a pointer of its own + // or those two are unreachable. + void testExtensionPointersAreWrapped() + { + if(!require_hook(sys::WSAIoctl, "WSAIoctl")) + return; + SOCKET s = ::socket(AF_INET, SOCK_STREAM, IPPROTO_TCP); + BOOST_TEST(s != INVALID_SOCKET); + DWORD bytes = 0; + GUID accept_guid = WSAID_ACCEPTEX; + LPFN_ACCEPTEX accept_ex = nullptr; + BOOST_TEST_EQ(::WSAIoctl(s, SIO_GET_EXTENSION_FUNCTION_POINTER, + &accept_guid, sizeof(accept_guid), &accept_ex, sizeof(accept_ex), + &bytes, nullptr, nullptr), 0); + BOOST_TEST(accept_ex != nullptr); + + HMODULE owner = nullptr; + auto const* addr = reinterpret_cast(accept_ex); + BOOST_TEST(::GetModuleHandleExW( + GET_MODULE_HANDLE_EX_FLAG_FROM_ADDRESS | + GET_MODULE_HANDLE_EX_FLAG_UNCHANGED_REFCOUNT, + static_cast(addr), &owner) != FALSE); + BOOST_TEST(owner == ::GetModuleHandleW(nullptr)); + + { + fault_scope f(sys::AcceptEx, test_err); + BOOST_TEST(accept_ex(s, s, nullptr, 0, 0, 0, &bytes, nullptr) == + FALSE); + BOOST_TEST(err_is(test_err)); + BOOST_TEST(f.fired()); + } + + GUID connect_guid = WSAID_CONNECTEX; + LPFN_CONNECTEX connect_ex = nullptr; + BOOST_TEST_EQ(::WSAIoctl(s, SIO_GET_EXTENSION_FUNCTION_POINTER, + &connect_guid, sizeof(connect_guid), &connect_ex, + sizeof(connect_ex), &bytes, nullptr, nullptr), 0); + BOOST_TEST(connect_ex != nullptr); + { + fault_scope f(sys::ConnectEx, test_err); + sockaddr_in sa{}; + sa.sin_family = AF_INET; + sa.sin_addr.s_addr = ::htonl(INADDR_LOOPBACK); + BOOST_TEST(connect_ex(s, reinterpret_cast(&sa), + static_cast(sizeof(sa)), nullptr, 0, &bytes, nullptr) == + FALSE); + BOOST_TEST(err_is(test_err)); + BOOST_TEST(f.fired()); + } + std::ignore = ::closesocket(s); + } + + // The library reaches the two ntdll entry points through + // GetProcAddress, so the substitution has to survive the same + // lookup the library performs. + void testNtPointersAreWrapped() + { + if(!require_hook(sys::GetProcAddress, "GetProcAddress")) + return; + HMODULE ntdll = ::GetModuleHandleW(L"ntdll.dll"); + BOOST_TEST(ntdll != nullptr); + using nt_set_fn = LONG(NTAPI*)(HANDLE, ULONG_PTR*, void*, ULONG, + ULONG); + auto const fn = reinterpret_cast( + reinterpret_cast( + ::GetProcAddress(ntdll, "NtSetInformationFile"))); + BOOST_TEST(fn != nullptr); + HMODULE owner = nullptr; + auto const* addr = reinterpret_cast(fn); + BOOST_TEST(::GetModuleHandleExW( + GET_MODULE_HANDLE_EX_FLAG_FROM_ADDRESS | + GET_MODULE_HANDLE_EX_FLAG_UNCHANGED_REFCOUNT, + static_cast(addr), &owner) != FALSE); + BOOST_TEST(owner == ::GetModuleHandleW(nullptr)); + { + fault_scope f(sys::NtSetInformationFile, test_err); + ULONG_PTR iosb[2] = {0, 0}; + void* info[2] = {nullptr, nullptr}; + BOOST_TEST(fn(INVALID_HANDLE_VALUE, iosb, &info, + static_cast(sizeof(info)), 61) != 0); + BOOST_TEST(f.fired()); + } + + // The file services resolve this one through the ANSI spelling + // of the module name, so the substitution has to survive that + // lookup too. + using nt_flush_fn = LONG(NTAPI*)(HANDLE, ULONG, void*, ULONG, + void*); + auto const flush = reinterpret_cast( + reinterpret_cast(::GetProcAddress( + ::GetModuleHandleA("NTDLL"), "NtFlushBuffersFileEx"))); + // Absent before Windows 8; there is nothing to substitute then. + if(!flush) + return; + HMODULE flush_owner = nullptr; + auto const* flush_addr = reinterpret_cast(flush); + BOOST_TEST(::GetModuleHandleExW( + GET_MODULE_HANDLE_EX_FLAG_FROM_ADDRESS | + GET_MODULE_HANDLE_EX_FLAG_UNCHANGED_REFCOUNT, + static_cast(flush_addr), &flush_owner) != FALSE); + BOOST_TEST(flush_owner == ::GetModuleHandleW(nullptr)); + { + fault_scope f(sys::NtFlushBuffersFileEx, test_err); + ULONG_PTR iosb[2] = {0, 0}; + BOOST_TEST(flush(INVALID_HANDLE_VALUE, 1, nullptr, 0, iosb) + != 0); + BOOST_TEST(f.fired()); + } + } + + void run() + { + winsock_guard guard; + testFiresOnNth(); + testDisarmsOnScopeExit(); + testCountTracksCalls(); + testFiredScopeStaysFired(); + testOpenFdsProbeWorks(); + testTransparentWhenUnarmed(); + testThreadIsolation(); + testAnyThreadFires(); + testThreadLocalWinsOverAnyThread(); + testTwoArmsCoexist(); + testEveryCensusSymbolFails(); + testReturningTruncatesAndForwards(); + testFreeAddrInfoRunsUnderArm(); + testCompletionFaultRewrites(); + testCompletionFaultDisarmsOnScopeExit(); + testExtensionPointersAreWrapped(); + testNtPointersAreWrapped(); + } +}; + +TEST_SUITE(self_test, "boost.corosio.fault.self"); + +} // boost::corosio::test::fault + +#endif diff --git a/test/unit/fault/signal_pipe_faults.cpp b/test/unit/fault/signal_pipe_faults.cpp new file mode 100644 index 000000000..71aef6c0f --- /dev/null +++ b/test/unit/fault/signal_pipe_faults.cpp @@ -0,0 +1,180 @@ +// +// Copyright (c) 2026 Steve Gerbino +// +// Distributed under the Boost Software License, Version 1.0. (See accompanying +// file LICENSE_1_0.txt or copy at http://www.boost.org/LICENSE_1_0.txt) +// +// Official repository: https://github.com/cppalliance/corosio +// + +#include "fault.hpp" +#include "fault_test_utils.hpp" +#include "context.hpp" +#include "test_suite.hpp" + +#include +#include + +#include +#include +#include +#include + +#if BOOST_COROSIO_POSIX + +namespace boost::corosio::test::fault { + +/* Creating the signal self-pipe, in a process where it does not exist. + + The pipe is a process-wide singleton latched on success, so every + fault below is reachable exactly once per process and only while it + is still unopened. That is what makes this a suite of its own rather + than a test: CTest runs one process per suite, and b2 one executable + per source. The suite name sorts after every other fault suite, so a + single-process run of the whole executable still meets the tests in + an order where the pipe is still closed when they need it to be. + + Within the suite the order is load-bearing too, and run() spells it + out: the creation faults first, then the one test that lets creation + succeed, then the registration faults that need a pipe to register. +*/ +struct signal_pipe_faults +{ + void testPipeCreateFails() + { + { + io_context ioc(one_backend); + signal_set ss(ioc); + int const before = open_fds(); + fault_scope f(sys::pipe, EMFILE); + auto ec = ss.add(SIGUSR2); + BOOST_TEST(f.fired()); + BOOST_TEST(ec == std::errc::too_many_files_open); + BOOST_TEST_EQ(open_fds(), before); + } + // Three fcntl calls configure each end, read end first: 1-3 + // fail the read end, 4-6 the write end. Either way both ends + // are closed before the error is reported. + for(unsigned nth : {1u, 2u, 3u, 4u, 5u, 6u}) + { + io_context ioc(one_backend); + signal_set ss(ioc); + int const before = open_fds(); + fault_scope f(sys::fcntl, EINVAL, nth); + auto ec = ss.add(SIGUSR2); + BOOST_TEST(f.fired()); + BOOST_TEST(ec == std::errc::invalid_argument); + BOOST_TEST_EQ(open_fds(), before); + } + } + + /* The first test that lets the pipe be created, and it creates it + above FD_SETSIZE on purpose: the select scheduler watches the + read end through an fd_set, so a number it cannot represent is + refused there rather than in the pipe's own setup. Every later + test in the process inherits that high-numbered pipe, which the + other backends have no trouble with. + */ + void testSelectReaderRejectsHighFd() + { +#if BOOST_COROSIO_HAS_SELECT + io_context ioc(select); + signal_set ss(ioc); + fd_wall wall; + if(!wall.ok()) + { + skip_no_high_fd("testSelectReaderRejectsHighFd"); + return; + } + auto ec = ss.add(SIGUSR2); + BOOST_TEST(ec == std::errc::too_many_files_open); +#endif + } + + void testReaderRegisterFails() + { +#if BOOST_COROSIO_HAS_EPOLL + constexpr sys register_call = sys::epoll_ctl; +#elif BOOST_COROSIO_HAS_KQUEUE + constexpr sys register_call = sys::kevent; +#else + return; +#endif +#if BOOST_COROSIO_HAS_EPOLL || BOOST_COROSIO_HAS_KQUEUE + io_context ioc(one_backend); + signal_set ss(ioc); + std::error_code ec; + { + fault_scope f(register_call, ENOMEM); + ec = ss.add(SIGUSR2); + BOOST_TEST(f.fired()); + } + BOOST_TEST(ec == std::errc::not_enough_memory); + // Success-latched, so a failed registration is retried by the + // next add() rather than lost. + BOOST_TEST(!ss.add(SIGUSR2)); + BOOST_TEST(!ss.clear()); +#endif + } + +#if BOOST_COROSIO_HAS_IO_URING + void testUringReaderSubmitFails() + { + io_context ioc(io_uring); + signal_set ss(ioc); + std::error_code ec; + { + // The wakeup eventfd's submit is spent building the ring, + // before this arm, so the reader's is the first it sees. + fault_scope f(sys::io_uring_submit, EBADF); + ec = ss.add(SIGUSR2); + BOOST_TEST(f.fired()); + } + BOOST_TEST(ec == std::errc::bad_file_descriptor); + BOOST_TEST(!ss.add(SIGUSR2)); + BOOST_TEST(!ss.clear()); + } + + /* A ring clamped to one SQE spends it on the wakeup poll, leaving + none for the signal reader's multishot poll. The submit that + follows the failed acquisition succeeds because it has nothing + left to submit, which is not the same as a reader watching the + pipe, so the arm has to report rather than assume. + */ + void testUringReaderSqFull() + { + // The clamp sizes the ring as it is created, which happens + // while the context is built, so it is armed before that. + std::optional f; + f.emplace(sys::uring_sqe_full, 0); + io_context ioc(io_uring); + signal_set ss(ioc); + std::error_code const ec = ss.add(SIGUSR2); + BOOST_TEST(f->fired()); + f.reset(); + BOOST_TEST(ec == std::errc::resource_unavailable_try_again); + // With the SQ flushable again the next add() arms the reader. + BOOST_TEST(!ss.add(SIGUSR2)); + BOOST_TEST(!ss.clear()); + } +#endif + + void run() + { + if(skip_under_valgrind()) + return; + testPipeCreateFails(); + testSelectReaderRejectsHighFd(); + testReaderRegisterFails(); +#if BOOST_COROSIO_HAS_IO_URING + testUringReaderSubmitFails(); + testUringReaderSqFull(); +#endif + } +}; + +TEST_SUITE(signal_pipe_faults, "boost.corosio.fault.signal_pipe"); + +} // boost::corosio::test::fault + +#endif diff --git a/test/unit/fault/signal_sigaction_faults.cpp b/test/unit/fault/signal_sigaction_faults.cpp new file mode 100644 index 000000000..0ef39be18 --- /dev/null +++ b/test/unit/fault/signal_sigaction_faults.cpp @@ -0,0 +1,99 @@ +// +// Copyright (c) 2026 Steve Gerbino +// +// Distributed under the Boost Software License, Version 1.0. (See accompanying +// file LICENSE_1_0.txt or copy at http://www.boost.org/LICENSE_1_0.txt) +// +// Official repository: https://github.com/cppalliance/corosio +// + +#include "fault.hpp" +#include "fault_test_utils.hpp" +#include "context.hpp" +#include "test_suite.hpp" + +#include +#include + +#include +#include +#include + +#if BOOST_COROSIO_POSIX + +namespace boost::corosio::test::fault { + +/* Installing and restoring a signal disposition. + + sigaction() is only reached on the transition at each end of a + signal's global registration count, so each test here has to own + that count for the signal it uses and hand it back before the next + one runs. A suite of its own for the same reason the pipe faults + have one: the disposition is process state, and a suite that shares + a process with tests that register signals cannot say what the + count was when it started. +*/ +struct signal_sigaction_faults +{ + void testAddFails() + { + io_context ioc(one_backend); + signal_set ss(ioc); + { + fault_scope f(sys::sigaction, EINVAL); + auto ec = ss.add(SIGUSR2); + BOOST_TEST(f.fired()); + BOOST_TEST(ec == std::errc::invalid_argument); + } + // The half-built registration is destroyed rather than linked, + // so the retry starts from an empty set and installs. + BOOST_TEST(!ss.add(SIGUSR2)); + BOOST_TEST(!ss.clear()); + } + + void testRemoveFails() + { + io_context ioc(one_backend); + signal_set ss(ioc); + BOOST_TEST(!ss.add(SIGUSR2)); + { + fault_scope f(sys::sigaction, EINVAL); + BOOST_TEST(ss.remove(SIGUSR2) == std::errc::invalid_argument); + BOOST_TEST(f.fired()); + } + // A failed restore leaves the registration in place, so the + // count and the disposition stay in step and the retry works. + BOOST_TEST(!ss.remove(SIGUSR2)); + BOOST_TEST(!ss.clear()); + } + + void testClearFails() + { + io_context ioc(one_backend); + signal_set ss(ioc); + BOOST_TEST(!ss.add(SIGUSR2)); + { + fault_scope f(sys::sigaction, EINVAL); + BOOST_TEST(ss.clear() == std::errc::invalid_argument); + BOOST_TEST(f.fired()); + } + // clear() reports the first failure but still empties the set, + // so there is nothing left for a retry to find. + BOOST_TEST(!ss.clear()); + } + + void run() + { + if(skip_under_valgrind()) + return; + testAddFails(); + testRemoveFails(); + testClearFails(); + } +}; + +TEST_SUITE(signal_sigaction_faults, "boost.corosio.fault.signal_sigaction"); + +} // boost::corosio::test::fault + +#endif diff --git a/test/unit/fault/uring_faults.cpp b/test/unit/fault/uring_faults.cpp new file mode 100644 index 000000000..e304e8baf --- /dev/null +++ b/test/unit/fault/uring_faults.cpp @@ -0,0 +1,828 @@ +// +// Copyright (c) 2026 Steve Gerbino +// +// Distributed under the Boost Software License, Version 1.0. (See accompanying +// file LICENSE_1_0.txt or copy at http://www.boost.org/LICENSE_1_0.txt) +// +// Official repository: https://github.com/cppalliance/corosio +// + +#include "fault.hpp" +#include "fault_test_utils.hpp" +#include "context.hpp" +#include "test_suite.hpp" +#include "test_utils.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +#if BOOST_COROSIO_HAS_IO_URING + +#include + +#include +#include +#include + +namespace boost::corosio::test::fault { + +namespace { + +endpoint uring_loopback() +{ + return endpoint(ipv4_address::loopback(), 0); +} + +} // namespace + +struct uring_faults +{ + // The ring is created at the end of io_context construction, so a + // ring the kernel refuses leaves no context behind to run: the + // failure surfaces from the constructor, and what it opened on the + // way in is released as the constructor unwinds. + void testRingInitFails() + { + auto expect = [](sys s, int err, std::errc code) + { + int const before = open_fds(); + fault_scope f(s, err); + expect_system_error([&]{ io_context ioc(io_uring); }, code); + BOOST_TEST(f.fired()); + BOOST_TEST_EQ(open_fds(), before); + }; + expect(sys::io_uring_queue_init_params, ENOMEM, + std::errc::not_enough_memory); + expect(sys::eventfd, EMFILE, std::errc::too_many_files_open); + // The wakeup poll's submit is the only one ring creation issues. + expect(sys::io_uring_submit, EBADF, + std::errc::bad_file_descriptor); + } + + /* Every io_uring object type creates and configures its descriptor + with plain syscalls, and each of those returns rather than + throwing. None of the error legs had run: the ring's own faults + reach the operations, not the object surface underneath them. + */ + void testObjectSurfaceErrors() + { + io_context ioc(io_uring); + auto expect_open = [&](auto&& obj, auto&& open_it) + { + int const before = open_fds(); + fault_scope f(sys::socket, EMFILE); + BOOST_TEST(open_it() == std::errc::too_many_files_open); + BOOST_TEST(f.fired()); + BOOST_TEST(!obj.is_open()); + BOOST_TEST_EQ(open_fds(), before); + }; + { + tcp_acceptor acc(ioc); + expect_open(acc, [&]{ return acc.open(); }); + BOOST_TEST(!acc.open()); + BOOST_TEST(!acc.bind(uring_loopback())); + fault_scope f(sys::listen, EADDRINUSE); + BOOST_TEST(acc.listen() == std::errc::address_in_use); + BOOST_TEST(f.fired()); + } + { + udp_socket u(ioc); + expect_open(u, [&]{ return u.open(udp::v4()); }); + } + { + local_stream_socket ls(ioc); + expect_open(ls, [&]{ return ls.open(); }); + } + { + local_datagram_socket ld(ioc); + expect_open(ld, [&]{ return ld.open(); }); + } + auto path = temp_path("ura"); + ::unlink(path.c_str()); + { + local_stream_acceptor acc(ioc); + expect_open(acc, [&]{ return acc.open(); }); + BOOST_TEST(!acc.open()); + BOOST_TEST(!acc.bind(corosio::local_endpoint(path))); + fault_scope f(sys::listen, EADDRINUSE); + BOOST_TEST(acc.listen() == std::errc::address_in_use); + BOOST_TEST(f.fired()); + } + ::unlink(path.c_str()); + { + local_datagram_socket ld(ioc); + BOOST_TEST(!ld.open()); + fault_scope f(sys::bind, EACCES); + BOOST_TEST(ld.bind(corosio::local_endpoint(path)) == + std::errc::permission_denied); + BOOST_TEST(f.fired()); + BOOST_TEST(ld.is_open()); + } + ::unlink(path.c_str()); + { + auto [a, b] = test::make_socket_pair(ioc); + std::ignore = b; + fault_scope f(sys::shutdown, ENOTCONN); + BOOST_TEST(a.shutdown(shutdown_both) == std::errc::not_connected); + BOOST_TEST(f.fired()); + } + { + local_stream_socket a(ioc), b(ioc); + BOOST_TEST(!connect_pair(a, b)); + fault_scope f(sys::shutdown, ENOTCONN); + BOOST_TEST(a.shutdown(shutdown_both) == std::errc::not_connected); + BOOST_TEST(f.fired()); + } + { + local_datagram_socket a(ioc), b(ioc); + BOOST_TEST(!connect_pair(a, b)); + fault_scope f(sys::shutdown, ENOTCONN); + BOOST_TEST(a.shutdown(shutdown_both) == std::errc::not_connected); + BOOST_TEST(f.fired()); + } + } + + /* Multishot accept fails immediately on a socket that is not + listening, so an adopted descriptor is probed before it is + armed. A kernel that refuses the query is treated as reporting a + listener, which is what keeps adoption working on a platform + that has no SO_ACCEPTCONN. + */ + void testAcceptorAssignProbeFails() + { + io_context ioc(io_uring); + auto h = make_native_socket(AF_INET, SOCK_STREAM); + BOOST_TEST(static_cast(h) >= 0); + make_native_adoptable(h); + sockaddr_in sa{}; + sa.sin_family = AF_INET; + sa.sin_addr.s_addr = htonl(INADDR_LOOPBACK); + BOOST_TEST_EQ(::bind(static_cast(h), + reinterpret_cast(&sa), sizeof(sa)), 0); + BOOST_TEST_EQ(::listen(static_cast(h), 1), 0); + tcp_acceptor acc(ioc); + { + // 1 is the SO_TYPE query adoption validates with; 2 is the + // SO_ACCEPTCONN probe. + fault_scope f(sys::getsockopt, EBADF, 2); + BOOST_TEST(!acc.assign(h)); + BOOST_TEST(f.fired()); + BOOST_TEST_EQ(f.count(), 2u); + } + BOOST_TEST(acc.is_open()); + acc.close(); + } + + /* Cancellation is best-effort when the submission queue is full. + + A ring clamped to one SQE spends it on the wakeup poll, and the + flush that would free it is a no-op while the arm is alive, so + every cancel below finds the queue still full after its own + retry and gives up. Nothing observable changes — the descriptor + is closed either way — which is why the arm's own report is the + only witness that the give-up was taken. + */ + void testCancelSqFull() + { + std::optional f; + f.emplace(sys::uring_sqe_full, 0); + io_context ioc(io_uring); + { + // close() submits the cancel while the descriptor is still + // open, so the kernel resolves it before the number can be + // recycled. + tcp_socket s(ioc); + BOOST_TEST(!s.open(tcp::v4())); + int const before = open_fds(); + s.close(); + BOOST_TEST(!s.is_open()); + BOOST_TEST_EQ(open_fds(), before - 1); + } + { + // cancel() reaches the by-descriptor cancel without going + // through a close. + tcp_socket s(ioc); + BOOST_TEST(!s.open(tcp::v4())); + s.cancel(); + BOOST_TEST(s.is_open()); + } + { + tcp_acceptor acc(ioc); + BOOST_TEST(!acc.open()); + BOOST_TEST(!acc.bind(uring_loopback())); + BOOST_TEST(!acc.listen()); + } + BOOST_TEST(f->fired()); + f.reset(); + } + + void testWaitFails() + { + { + io_context ioc(io_uring); + fault_scope f(sys::io_uring_wait_cqe_timeout, EINTR); + bool done = false; + auto body = [&]() -> capy::task<> + { + std::ignore = co_await corosio::delay( + std::chrono::milliseconds(1)); + done = true; + }; + capy::run_async(ioc.get_executor())(body()); + ioc.run(); + BOOST_TEST(f.fired()); + BOOST_TEST(done); + } + { + io_context ioc(io_uring); + fault_scope f(sys::io_uring_wait_cqe_timeout, EBADF); + auto body = [&]() -> capy::task<> + { + std::ignore = co_await corosio::delay( + std::chrono::milliseconds(1)); + }; + capy::run_async(ioc.get_executor())(body()); + expect_system_error([&]{ ioc.run(); }, + std::errc::bad_file_descriptor); + BOOST_TEST(f.fired()); + } + } + + // The acceptor's close path drains its multishot CQEs; a failing + // submit_and_wait_timeout breaks that loop silently, so the only + // observable is that the fault was reached. + void testAcceptorDrainSubmitFails() + { + io_context ioc(io_uring); + fault_scope f(sys::io_uring_submit_and_wait_timeout, EBADF); + { + tcp_acceptor acc(ioc, uring_loopback()); + tcp_socket s(ioc); + std::error_code aec; + auto accept_body = [&]() -> capy::task<> + { + auto [ec] = co_await acc.accept(s); + aec = ec; + }; + capy::run_async(ioc.get_executor())(accept_body()); + ioc.poll(); + acc.close(); + ioc.poll(); + BOOST_TEST(aec == capy::error::canceled); + } + BOOST_TEST(f.fired()); + } + + // A ring clamped to one SQE spends it on the wakeup poll, and the + // deferred flush that would free it is failed too, so the first + // user op finds the SQ still full after its own flush retry. Every + // op kind reports the same EAGAIN there, whatever it was going to + // do with the SQE. The pairs are built with raw syscalls and + // adopted: connecting through a ring this crippled would deadlock + // before the test began. + void testSqFull() + { + // A read that never reached the kernel is not an end of file. + { + int sv[2]; + if(::socketpair(AF_UNIX, SOCK_STREAM, 0, sv) != 0) + { + BOOST_TEST(false); + return; + } + make_native_adoptable(sv[1]); + fault_scope f(sys::uring_sqe_full, 0); + fault_scope g(sys::io_uring_submit_and_get_events, EBADF); + io_context ioc(io_uring); + local_stream_socket b(ioc); + BOOST_TEST(!b.assign(sv[1])); + char buf[8]; + std::error_code rec, rec2; + std::size_t n1 = 1, n2 = 0; + auto read_body = [&]() -> capy::task<> + { + { + auto [ec, n] = co_await b.read_some( + capy::mutable_buffer(buf, 8)); + rec = ec; + n1 = n; + } + // Nothing was consumed, so the peer's next bytes still + // arrive: the follow-up read needs no SQE because the + // speculative readv answers it. + BOOST_TEST_EQ(::write(sv[0], "abcd", 4), 4); + { + auto [ec, n] = co_await b.read_some( + capy::mutable_buffer(buf, 8)); + rec2 = ec; + n2 = n; + } + }; + capy::run_async(ioc.get_executor())(read_body()); + ioc.run(); + BOOST_TEST(f.fired()); + BOOST_TEST(g.fired()); + BOOST_TEST(rec == std::errc::resource_unavailable_try_again); + BOOST_TEST_EQ(n1, 0u); + BOOST_TEST(b.is_open()); + BOOST_TEST(!rec2); + BOOST_TEST_EQ(n2, 4u); + BOOST_TEST_EQ(std::memcmp(buf, "abcd", 4), 0); + ::close(sv[0]); + } + // A write that never reached the kernel moved no bytes, so + // reporting success with a zero count would lose the payload. + { + int sv[2]; + if(::socketpair(AF_UNIX, SOCK_STREAM, 0, sv) != 0) + { + BOOST_TEST(false); + return; + } + make_native_adoptable(sv[1]); + fault_scope f(sys::uring_sqe_full, 0); + fault_scope g(sys::io_uring_submit_and_get_events, EBADF); + io_context ioc(io_uring); + local_stream_socket b(ioc); + BOOST_TEST(!b.assign(sv[1])); + std::error_code wec; + std::size_t wn = 1; + auto write_body = [&]() -> capy::task<> + { + // The socket is writable, so the speculative sendmsg + // would answer the write without ever needing an SQE. + fault_scope s(sys::sendmsg, EAGAIN); + auto [ec, n] = co_await b.write_some( + capy::const_buffer("abcd", 4)); + wec = ec; + wn = n; + BOOST_TEST(s.fired()); + }; + capy::run_async(ioc.get_executor())(write_body()); + ioc.run(); + BOOST_TEST(f.fired()); + BOOST_TEST(g.fired()); + BOOST_TEST(wec == std::errc::resource_unavailable_try_again); + BOOST_TEST_EQ(wn, 0u); + BOOST_TEST(b.is_open()); + ::close(sv[0]); + } + // A readiness wait carries no byte count to give it away: an + // unsubmitted poll that reported success would have the caller + // read a socket nothing said was readable. + { + int sv[2]; + if(::socketpair(AF_UNIX, SOCK_STREAM, 0, sv) != 0) + { + BOOST_TEST(false); + return; + } + make_native_adoptable(sv[1]); + fault_scope f(sys::uring_sqe_full, 0); + fault_scope g(sys::io_uring_submit_and_get_events, EBADF); + io_context ioc(io_uring); + local_stream_socket b(ioc); + BOOST_TEST(!b.assign(sv[1])); + std::error_code pec; + auto wait_body = [&]() -> capy::task<> + { + auto [ec] = co_await b.wait(wait_type::read); + pec = ec; + }; + capy::run_async(ioc.get_executor())(wait_body()); + ioc.run(); + BOOST_TEST(f.fired()); + BOOST_TEST(g.fired()); + BOOST_TEST(pec == std::errc::resource_unavailable_try_again); + BOOST_TEST(b.is_open()); + ::close(sv[0]); + } + } + + void testConnectCqeRewrite() + { + io_context ioc(io_uring); + tcp_acceptor acc(ioc, uring_loopback()); + tcp_socket c(ioc); + BOOST_TEST(!c.open(tcp::v4())); + std::error_code cec; + bool fired = false; + { + cqe_fault_scope q( + c.native_handle(), IORING_OP_CONNECT, -ECONNREFUSED); + auto connect_body = [&]() -> capy::task<> + { + auto [ec] = co_await c.connect(acc.local_endpoint()); + cec = ec; + }; + capy::run_async(ioc.get_executor())(connect_body()); + ioc.run(); + fired = q.fired(); + } + BOOST_TEST(fired); + BOOST_TEST(cec == std::errc::connection_refused); + // A refused connect reports through the return channel and + // leaves the descriptor with the caller. + BOOST_TEST(c.is_open()); + } + + // The multishot accept SQE is prepared by listen(), so the arm has + // to be in place before the listener is armed rather than before + // the accept call. + void testAcceptCqeRewrite() + { + io_context ioc(io_uring); + tcp_acceptor acc(ioc); + BOOST_TEST(!acc.open()); + BOOST_TEST(!acc.bind(uring_loopback())); + tcp_socket c(ioc), s(ioc); + std::error_code aec; + bool fired = false; + { + cqe_fault_scope q( + acc.native_handle(), IORING_OP_ACCEPT, -EMFILE); + BOOST_TEST(!acc.listen()); + auto accept_body = [&]() -> capy::task<> + { + auto [ec] = co_await acc.accept(s); + aec = ec; + }; + capy::run_async(ioc.get_executor())(accept_body()); + auto connect_body = [&]() -> capy::task<> + { + std::ignore = co_await c.connect(acc.local_endpoint()); + }; + capy::run_async(ioc.get_executor())(connect_body()); + ioc.run(); + fired = q.fired(); + } + BOOST_TEST(fired); + BOOST_TEST(aec == std::errc::too_many_files_open); + // A failed accept adopts nothing into the peer socket. + BOOST_TEST(!s.is_open()); + } + + // Stream reads and writes try a synchronous readv/sendmsg before + // they submit, so each rewrite needs the speculation pushed off the + // fast path with its own EAGAIN arm. + void testStreamCqeRewrites() + { + io_context ioc(io_uring); + auto [a, b] = test::make_socket_pair(ioc); + char buf[8] = "1234567"; + std::error_code sec, rec, pec, smec, rvec; + bool sfired = false, rfired = false, pfired = false; + bool smfired = false, rvfired = false; + auto body = [&]() -> capy::task<> + { + { + fault_scope f(sys::sendmsg, EAGAIN); + cqe_fault_scope q( + a.native_handle(), IORING_OP_SEND, -EPIPE); + auto [ec, n] = co_await a.write_some( + capy::const_buffer(buf, 7)); + std::ignore = n; + sec = ec; + BOOST_TEST(f.fired()); + sfired = q.fired(); + } + { + fault_scope f(sys::readv, EAGAIN); + cqe_fault_scope q( + b.native_handle(), IORING_OP_RECV, -ECONNRESET); + auto [ec, n] = co_await b.read_some( + capy::mutable_buffer(buf, 7)); + std::ignore = n; + rec = ec; + BOOST_TEST(f.fired()); + rfired = q.fired(); + } + // The two faulted completions carried a negative res, which + // never re-arms speculation, so the scatter-gather pair + // below reaches its SQE without an EAGAIN arm of its own. + { + cqe_fault_scope q( + a.native_handle(), IORING_OP_SENDMSG, -ENOBUFS); + std::array cb{ + capy::const_buffer(buf, 4), + capy::const_buffer(buf + 4, 3)}; + auto [ec, n] = co_await a.write_some(cb); + std::ignore = n; + smec = ec; + smfired = q.fired(); + } + { + char x[4] = {}, y[4] = {}; + cqe_fault_scope q( + b.native_handle(), IORING_OP_READV, -EIO); + std::array mb{ + capy::mutable_buffer(x, 4), + capy::mutable_buffer(y, 3)}; + auto [ec, n] = co_await b.read_some(mb); + std::ignore = n; + rvec = ec; + rvfired = q.fired(); + } + { + // The rewritten RECV really ran, so b's buffer is empty + // again; the poll needs data on the other side or it + // would park forever instead of delivering a CQE. + auto [ec, n] = co_await b.write_some( + capy::const_buffer(buf, 7)); + std::ignore = n; + BOOST_TEST(!ec); + } + { + cqe_fault_scope q( + a.native_handle(), IORING_OP_POLL_ADD, -EBADF); + auto [ec] = co_await a.wait(wait_type::read); + pec = ec; + pfired = q.fired(); + } + }; + capy::run_async(ioc.get_executor())(body()); + ioc.run(); + BOOST_TEST(sfired); + BOOST_TEST(rfired); + BOOST_TEST(smfired); + BOOST_TEST(rvfired); + BOOST_TEST(pfired); + BOOST_TEST(sec == std::errc::broken_pipe); + BOOST_TEST(rec == std::errc::connection_reset); + BOOST_TEST(smec == std::errc::no_buffer_space); + BOOST_TEST(rvec == std::errc::io_error); + BOOST_TEST(pec == std::errc::bad_file_descriptor); + } + + void testDatagramCqeRewrites() + { + io_context ioc(io_uring); + udp_socket a(ioc), b(ioc); + BOOST_TEST(!a.open(udp::v4())); + BOOST_TEST(!b.open(udp::v4())); + BOOST_TEST(!a.bind(uring_loopback())); + BOOST_TEST(!b.bind(uring_loopback())); + char buf[8] = "1234567"; + std::error_code sec, rec; + bool sfired = false, rfired = false; + auto body = [&]() -> capy::task<> + { + { + fault_scope f(sys::sendmsg, EAGAIN); + cqe_fault_scope q( + a.native_handle(), IORING_OP_SENDMSG, -EIO); + auto [ec, n] = co_await a.send_to( + capy::const_buffer(buf, 7), b.local_endpoint()); + std::ignore = n; + sec = ec; + BOOST_TEST(f.fired()); + sfired = q.fired(); + } + { + fault_scope f(sys::recvmsg, EAGAIN); + cqe_fault_scope q( + b.native_handle(), IORING_OP_RECVMSG, -EIO); + endpoint from; + auto [ec, n] = co_await b.recv_from( + capy::mutable_buffer(buf, 7), from); + std::ignore = n; + rec = ec; + BOOST_TEST(f.fired()); + rfired = q.fired(); + } + }; + capy::run_async(ioc.get_executor())(body()); + ioc.run(); + BOOST_TEST(sfired); + BOOST_TEST(rfired); + BOOST_TEST(sec == std::errc::io_error); + BOOST_TEST(rec == std::errc::io_error); + } + + // Ring file I/O never reaches preadv/pwritev, so the failure that + // the POSIX file tests inject through those calls is only + // reachable here, on the completion of the READV/WRITEV SQE. + void testFileCqeRewrites() + { + io_context ioc(io_uring); + auto sf_path = temp_path("uring_sf"); + auto rf_path = temp_path("uring_raf"); + stream_file sf(ioc); + random_access_file rf(ioc); + BOOST_TEST(!sf.open(sf_path, + file_base::read_write | file_base::create)); + BOOST_TEST(!rf.open(rf_path, + file_base::read_write | file_base::create)); + char buf[8] = "1234567"; + std::error_code swec, srec, rwec, rrec; + bool swf = false, srf = false, rwf = false, rrf = false; + auto body = [&]() -> capy::task<> + { + { + cqe_fault_scope q( + sf.native_handle(), IORING_OP_WRITEV, -EIO); + auto [ec, n] = co_await sf.write_some( + capy::const_buffer(buf, 7)); + std::ignore = n; + swec = ec; + swf = q.fired(); + } + { + // A faulted completion is not sticky: the file still + // takes writes, and this one gives the read below + // something to find. + auto [ec, n] = co_await sf.write_some( + capy::const_buffer(buf, 7)); + std::ignore = n; + BOOST_TEST(!ec); + } + { + auto [ec, pos] = sf.seek(0, file_base::seek_set); + std::ignore = pos; + BOOST_TEST(!ec); + } + { + cqe_fault_scope q( + sf.native_handle(), IORING_OP_READV, -EIO); + auto [ec, n] = co_await sf.read_some( + capy::mutable_buffer(buf, 7)); + std::ignore = n; + srec = ec; + srf = q.fired(); + } + { + cqe_fault_scope q( + rf.native_handle(), IORING_OP_WRITEV, -EIO); + auto [ec, n] = co_await rf.write_some_at( + 0, capy::const_buffer(buf, 7)); + std::ignore = n; + rwec = ec; + rwf = q.fired(); + } + { + // Same for the random-access file: the faulted + // completion leaves it usable, and this write seeds the + // read below. + auto [ec, n] = co_await rf.write_some_at( + 0, capy::const_buffer(buf, 7)); + std::ignore = n; + BOOST_TEST(!ec); + } + { + cqe_fault_scope q( + rf.native_handle(), IORING_OP_READV, -EIO); + auto [ec, n] = co_await rf.read_some_at( + 0, capy::mutable_buffer(buf, 7)); + std::ignore = n; + rrec = ec; + rrf = q.fired(); + } + }; + capy::run_async(ioc.get_executor())(body()); + ioc.run(); + BOOST_TEST(swf); + BOOST_TEST(srf); + BOOST_TEST(rwf); + BOOST_TEST(rrf); + BOOST_TEST(swec == std::errc::io_error); + BOOST_TEST(srec == std::errc::io_error); + BOOST_TEST(rwec == std::errc::io_error); + BOOST_TEST(rrec == std::errc::io_error); + sf.close(); + rf.close(); + ::unlink(sf_path.c_str()); + ::unlink(rf_path.c_str()); + } + + /* The multishot accept arm is the one submission nothing counted. + + Every other op is paid for by a `work_started()` before it is + submitted, which is what lets the SQ-full path queue it as + completed: the run loop spends a `work_finished()` on everything + it dispatches. The arm is a persistent internal mechanism with no + such credit, so queueing it would spend one the context never + owed. Its failure comes back through the acceptor instead, which + is also the only channel that can say anything at all: an arm the + ring never took produces no CQE, so an accept that parked on a + delivery would park for good. + */ + void testAcceptorArmSqFull() + { + std::optional f, g; + f.emplace(sys::uring_sqe_full, 0); + g.emplace(sys::io_uring_submit_and_get_events, EBADF); + io_context ioc(io_uring); + // The listen inside the constructor is what arms the multishot + // accept, and it finds the one SQE already spent on the wakeup + // poll. + tcp_acceptor acc(ioc, uring_loopback()); + tcp_socket s(ioc); + std::error_code aec, wec; + auto body = [&]() -> capy::task<> + { + { + auto [ec] = co_await acc.accept(s); + aec = ec; + } + { + // Readiness on this backend means a future delivery, + // which the failed arm has ruled out just as squarely. + auto [ec] = co_await acc.wait(wait_type::read); + wec = ec; + } + }; + capy::run_async(ioc.get_executor())(body()); + ioc.run(); + BOOST_TEST(f->fired()); + // The deferred flush is failed too, so the SQ is still full + // when the arm makes its own retry. + BOOST_TEST(g->fired()); + BOOST_TEST(aec == std::errc::resource_unavailable_try_again); + BOOST_TEST(wec == std::errc::resource_unavailable_try_again); + BOOST_TEST(!s.is_open()); + + // A failed arm has to be recoverable, or an acceptor that hit + // a full SQ once would report EAGAIN for the rest of its life: + // the arming it is left holding covers nothing, so a re-listen + // must be allowed to replace it rather than being read as one + // already in place. + f.reset(); + g.reset(); + ioc.restart(); + BOOST_TEST(!acc.listen()); + tcp_socket c(ioc), s2(ioc); + std::error_code cec, aec2; + // The accept goes first and finds nothing queued, so the + // synchronous accept4 answers EAGAIN and the connection can + // only reach it through the arming. That is what makes this an + // assertion about the arming rather than about accept4: with + // the failed arm still counted as one in place, listen() would + // not have replaced it and this accept would report EAGAIN + // instead of parking. + auto accept_body = [&]() -> capy::task<> + { + auto [ec] = co_await acc.accept(s2); + aec2 = ec; + }; + auto client_body = [&]() -> capy::task<> + { + auto [ec] = co_await c.connect(acc.local_endpoint()); + cec = ec; + }; + capy::run_async(ioc.get_executor())(accept_body()); + capy::run_async(ioc.get_executor())(client_body()); + ioc.run(); + BOOST_TEST(!cec); + BOOST_TEST(!aec2); + BOOST_TEST(s2.is_open()); + } + + void run() + { + if(skip_under_valgrind()) + return; + testRingInitFails(); + testObjectSurfaceErrors(); + testAcceptorAssignProbeFails(); + testCancelSqFull(); + testWaitFails(); + testAcceptorDrainSubmitFails(); + testAcceptorArmSqFull(); + testSqFull(); + testConnectCqeRewrite(); + testAcceptCqeRewrite(); + testStreamCqeRewrites(); + testDatagramCqeRewrites(); + testFileCqeRewrites(); + } +}; + +TEST_SUITE(uring_faults, "boost.corosio.fault.io_uring"); + +} // boost::corosio::test::fault + +#endif diff --git a/test/unit/fault/win_faults.cpp b/test/unit/fault/win_faults.cpp new file mode 100644 index 000000000..07a9ed042 --- /dev/null +++ b/test/unit/fault/win_faults.cpp @@ -0,0 +1,897 @@ +// +// Copyright (c) 2026 Steve Gerbino +// +// Distributed under the Boost Software License, Version 1.0. (See accompanying +// file LICENSE_1_0.txt or copy at http://www.boost.org/LICENSE_1_0.txt) +// +// Official repository: https://github.com/cppalliance/corosio +// + +#include "fault.hpp" +#include "fault_test_utils.hpp" +#include "context.hpp" +#include "test_suite.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#if BOOST_COROSIO_HAS_IOCP + +namespace boost::corosio::test::fault { + +namespace { + +void remove_file(std::string const& path) +{ + std::error_code ec; + std::ignore = std::filesystem::remove(std::filesystem::path(path), ec); +} + +// A file handle the library did not open, in the mode adoption needs. +// The path is one temp_path built, so widening it a character at a +// time is enough. +HANDLE make_native_file(std::string const& path) +{ + std::wstring wide(path.begin(), path.end()); + return ::CreateFileW(wide.c_str(), GENERIC_READ | GENERIC_WRITE, + FILE_SHARE_READ | FILE_SHARE_WRITE, nullptr, OPEN_ALWAYS, + FILE_FLAG_OVERLAPPED, nullptr); +} + +// Run connect_pair with a deadline. A rendezvous that cannot finish +// would otherwise stall until the CI job runs out of time, which reads +// as an infrastructure failure rather than as this test; leaving the +// stuck thread behind and carrying on is not an option either, since +// it holds references into the caller's frame. +std::error_code +connect_pair_bounded(local_stream_socket& a, local_stream_socket& b) +{ + std::error_code ec; + std::promise done; + auto ready = done.get_future(); + std::thread t([&]{ ec = connect_pair(a, b); done.set_value(); }); + if(ready.wait_for(std::chrono::seconds(5)) != std::future_status::ready) + { + BOOST_TEST(false); + std::fprintf(stderr, + "fault harness: connect_pair did not return within 5s\n"); + std::fflush(stderr); + std::_Exit(1); + } + t.join(); + return ec; +} + +// Bound a run loop an assertion failure could leave running. Only the +// deferred-post test needs one: every other test here either drives a +// synchronous call or awaits an operation the fault completes on the +// spot, whereas a post that never reaches the deferred drain leaves +// run() with work outstanding and nothing to deliver it. +capy::task<> stop_guard(io_context& ioc, bool& expired) +{ + std::ignore = co_await corosio::delay(std::chrono::seconds(2)); + expired = true; + ioc.stop(); +} + +} // namespace + +/* Faults on the Windows entry points that are not the IOCP backend's + own: the file services, the resolver, host_name and the CRT signal + registration. The IOCP socket paths live in iocp_faults.cpp. +*/ +struct win_common_faults +{ + void testHostNameFails() + { + { + // The size query is the only call that reports through + // GetLastError alone, and host_name passes its code + // through untouched. + fault_scope f(sys::GetComputerNameExW, ERROR_ACCESS_DENIED); + auto [ec, name] = host_name(); + BOOST_TEST(f.fired()); + BOOST_TEST(ec == win_err(ERROR_ACCESS_DENIED)); + BOOST_TEST(name.empty()); + } + { + // 1 is the size query, which has to report ERROR_MORE_DATA + // for the fetch to happen at all. + fault_scope f(sys::GetComputerNameExW, ERROR_INVALID_PARAMETER, 2); + auto [ec, name] = host_name(); + BOOST_TEST(f.fired()); + BOOST_TEST(ec == win_err(ERROR_INVALID_PARAMETER)); + BOOST_TEST(name.empty()); + } + { + // Sizing conversion: returns 0, so `needed <= 0`. + fault_scope f(sys::WideCharToMultiByte, ERROR_INSUFFICIENT_BUFFER); + auto [ec, name] = host_name(); + BOOST_TEST(f.fired()); + BOOST_TEST(ec == win_err(ERROR_INSUFFICIENT_BUFFER)); + BOOST_TEST(name.empty()); + } + { + // Converting conversion: 0 written where `needed` was + // positive. + fault_scope f(sys::WideCharToMultiByte, ERROR_INVALID_PARAMETER, 2); + auto [ec, name] = host_name(); + BOOST_TEST(f.fired()); + BOOST_TEST(ec == win_err(ERROR_INVALID_PARAMETER)); + BOOST_TEST(name.empty()); + } + // Unfaulted, the call must still work: the arms above are the + // only reason any of them failed. + auto [ec, name] = host_name(); + BOOST_TEST(!ec); + BOOST_TEST(!name.empty()); + } + + void testStreamFileOpenFails() + { + io_context ioc(iocp); + auto path = temp_path("winsf"); + stream_file sf(ioc); + { + fault_scope f(sys::CreateFileW, ERROR_ACCESS_DENIED); + auto ec = sf.open(path, file_base::read_write | + file_base::create); + BOOST_TEST(f.fired()); + BOOST_TEST(ec == win_err(ERROR_ACCESS_DENIED)); + BOOST_TEST(!sf.is_open()); + } + // The handle exists when the association fails, so the + // failure path owns closing it. + expect_no_handle_leak([&]{ + fault_scope f(sys::CreateIoCompletionPort, + ERROR_INVALID_PARAMETER); + auto ec = sf.open(path, file_base::read_write | + file_base::create); + BOOST_TEST(f.fired()); + BOOST_TEST(ec == win_err(ERROR_INVALID_PARAMETER)); + BOOST_TEST(!sf.is_open()); + }); + // create|truncate lowers to OPEN_ALWAYS plus an explicit + // SetEndOfFile; every other mode leaves it to the disposition. + expect_no_handle_leak([&]{ + fault_scope f(sys::SetEndOfFile, ERROR_DISK_FULL); + auto ec = sf.open(path, file_base::read_write | + file_base::create | file_base::truncate); + BOOST_TEST(f.fired()); + BOOST_TEST(ec == win_err(ERROR_DISK_FULL)); + BOOST_TEST(!sf.is_open()); + }); + // Only an appending open seeds its own offset from the file + // size. + expect_no_handle_leak([&]{ + fault_scope f(sys::GetFileSizeEx, ERROR_INVALID_HANDLE); + auto ec = sf.open(path, file_base::write_only | + file_base::create | file_base::append); + BOOST_TEST(f.fired()); + BOOST_TEST(ec == win_err(ERROR_INVALID_HANDLE)); + BOOST_TEST(!sf.is_open()); + }); + remove_file(path); + } + + void testStreamFileSyncOps() + { + io_context ioc(iocp); + auto path = temp_path("winsf2"); + stream_file sf(ioc); + BOOST_TEST(!sf.open(path, file_base::read_write | file_base::create)); + { + fault_scope f(sys::GetFileSizeEx, ERROR_INVALID_HANDLE); + expect_system_error( + [&]{ std::ignore = sf.size(); }, + win_err(ERROR_INVALID_HANDLE)); + BOOST_TEST(f.fired()); + } + { + fault_scope f(sys::SetFilePointerEx, ERROR_INVALID_PARAMETER); + BOOST_TEST(sf.resize(16) == win_err(ERROR_INVALID_PARAMETER)); + BOOST_TEST(f.fired()); + } + { + fault_scope f(sys::SetEndOfFile, ERROR_DISK_FULL); + BOOST_TEST(sf.resize(16) == win_err(ERROR_DISK_FULL)); + BOOST_TEST(f.fired()); + } + { + // With the data-only flush declined the full flush is the + // only path left, and it succeeds: declining is not a + // failure to report. + fault_scope nt(sys::NtFlushBuffersFileEx, ERROR_INVALID_FUNCTION); + BOOST_TEST(!sf.sync_data()); + } + { + // sync_data tries the data-only NT flush first and only + // falls back to FlushFileBuffers when that fails, so the + // fallback needs both arms. Where the NT entry point never + // resolved, its arm has nothing to fail and the fallback + // was already the only path. + fault_scope nt(sys::NtFlushBuffersFileEx, ERROR_INVALID_FUNCTION); + fault_scope f(sys::FlushFileBuffers, ERROR_WRITE_FAULT); + BOOST_TEST(sf.sync_data() == win_err(ERROR_WRITE_FAULT)); + BOOST_TEST(f.fired()); + } + { + fault_scope f(sys::FlushFileBuffers, ERROR_WRITE_FAULT); + BOOST_TEST(sf.sync_all() == win_err(ERROR_WRITE_FAULT)); + BOOST_TEST(f.fired()); + } + { + // Only seek_end asks the file for its size. + fault_scope f(sys::GetFileSizeEx, ERROR_INVALID_HANDLE); + auto [ec, pos] = sf.seek(0, file_base::seek_end); + BOOST_TEST(f.fired()); + BOOST_TEST(ec == win_err(ERROR_INVALID_HANDLE)); + BOOST_TEST_EQ(pos, 0u); + } + sf.close(); + remove_file(path); + } + + void testStreamFileIoFails() + { + io_context ioc(iocp); + auto path = temp_path("winsf3"); + stream_file sf(ioc); + BOOST_TEST(!sf.open(path, file_base::read_write | file_base::create)); + char buf[8] = "1234567"; + std::error_code wec, rec, cec, eec; + std::size_t rn = 99, en = 99; + auto t = [&]() -> capy::task<> + { + { + fault_scope f(sys::WriteFile, ERROR_ACCESS_DENIED); + auto [ec, n] = co_await sf.write_some( + capy::const_buffer(buf, 7)); + std::ignore = n; + wec = ec; + BOOST_TEST(f.fired()); + } + { + auto [ec, n] = co_await sf.write_some( + capy::const_buffer(buf, 7)); + std::ignore = n; + BOOST_TEST(!ec); + } + { + auto [ec, pos] = sf.seek(0, file_base::seek_set); + std::ignore = pos; + BOOST_TEST(!ec); + } + { + fault_scope f(sys::ReadFile, ERROR_ACCESS_DENIED); + auto [ec, n] = co_await sf.read_some( + capy::mutable_buffer(buf, 7)); + rec = ec; + rn = n; + BOOST_TEST(f.fired()); + } + { + // The kernel result of a queued read cannot be armed + // where it was started; the completion carries it. + completion_fault_scope q(ERROR_LOCK_VIOLATION); + auto [ec, n] = co_await sf.read_some( + capy::mutable_buffer(buf, 7)); + std::ignore = n; + cec = ec; + BOOST_TEST(q.fired()); + } + { + // A zero-length ReadFile completes with zero bytes, + // which the stream contract reads as end of file. + auto f = fault_scope::returning(sys::ReadFile, 0); + auto [ec, n] = co_await sf.read_some( + capy::mutable_buffer(buf, 7)); + eec = ec; + en = n; + BOOST_TEST(f.fired()); + } + }; + capy::run_async(ioc.get_executor())(t()); + ioc.run(); + BOOST_TEST(wec == win_err(ERROR_ACCESS_DENIED)); + BOOST_TEST(rec == win_err(ERROR_ACCESS_DENIED)); + BOOST_TEST_EQ(rn, 0u); + BOOST_TEST(cec == win_err(ERROR_LOCK_VIOLATION)); + BOOST_TEST(eec == capy::error::eof); + BOOST_TEST_EQ(en, 0u); + sf.close(); + remove_file(path); + } + + void testRandomAccessFileFails() + { + io_context ioc(iocp); + auto path = temp_path("winraf"); + random_access_file rf(ioc); + { + fault_scope f(sys::CreateFileW, ERROR_ACCESS_DENIED); + BOOST_TEST(rf.open(path, file_base::read_write | + file_base::create) == win_err(ERROR_ACCESS_DENIED)); + BOOST_TEST(f.fired()); + } + expect_no_handle_leak([&]{ + fault_scope f(sys::CreateIoCompletionPort, + ERROR_INVALID_PARAMETER); + BOOST_TEST(rf.open(path, file_base::read_write | + file_base::create) == win_err(ERROR_INVALID_PARAMETER)); + BOOST_TEST(f.fired()); + }); + // create|truncate lowers to OPEN_ALWAYS plus an explicit + // SetEndOfFile; every other mode leaves it to the disposition. + expect_no_handle_leak([&]{ + fault_scope f(sys::SetEndOfFile, ERROR_DISK_FULL); + BOOST_TEST(rf.open(path, file_base::read_write | + file_base::create | file_base::truncate) == + win_err(ERROR_DISK_FULL)); + BOOST_TEST(f.fired()); + BOOST_TEST(!rf.is_open()); + }); + BOOST_TEST(!rf.open(path, file_base::read_write | file_base::create)); + { + fault_scope f(sys::GetFileSizeEx, ERROR_INVALID_HANDLE); + expect_system_error( + [&]{ std::ignore = rf.size(); }, + win_err(ERROR_INVALID_HANDLE)); + BOOST_TEST(f.fired()); + } + { + fault_scope f(sys::SetFilePointerEx, ERROR_INVALID_PARAMETER); + BOOST_TEST(rf.resize(16) == win_err(ERROR_INVALID_PARAMETER)); + BOOST_TEST(f.fired()); + } + { + fault_scope f(sys::SetEndOfFile, ERROR_DISK_FULL); + BOOST_TEST(rf.resize(16) == win_err(ERROR_DISK_FULL)); + BOOST_TEST(f.fired()); + } + { + fault_scope nt(sys::NtFlushBuffersFileEx, ERROR_INVALID_FUNCTION); + BOOST_TEST(!rf.sync_data()); + } + { + fault_scope nt(sys::NtFlushBuffersFileEx, ERROR_INVALID_FUNCTION); + fault_scope f(sys::FlushFileBuffers, ERROR_WRITE_FAULT); + BOOST_TEST(rf.sync_data() == win_err(ERROR_WRITE_FAULT)); + BOOST_TEST(f.fired()); + } + { + fault_scope f(sys::FlushFileBuffers, ERROR_WRITE_FAULT); + BOOST_TEST(rf.sync_all() == win_err(ERROR_WRITE_FAULT)); + BOOST_TEST(f.fired()); + } + char buf[8] = "1234567"; + std::error_code wec, rec, cec, eec; + auto t = [&]() -> capy::task<> + { + { + fault_scope f(sys::WriteFile, ERROR_ACCESS_DENIED); + auto [ec, n] = co_await rf.write_some_at( + 0, capy::const_buffer(buf, 7)); + std::ignore = n; + wec = ec; + BOOST_TEST(f.fired()); + } + { + auto [ec, n] = co_await rf.write_some_at( + 0, capy::const_buffer(buf, 7)); + std::ignore = n; + BOOST_TEST(!ec); + } + { + fault_scope f(sys::ReadFile, ERROR_ACCESS_DENIED); + auto [ec, n] = co_await rf.read_some_at( + 0, capy::mutable_buffer(buf, 7)); + std::ignore = n; + rec = ec; + BOOST_TEST(f.fired()); + } + { + completion_fault_scope q(ERROR_LOCK_VIOLATION); + auto [ec, n] = co_await rf.read_some_at( + 0, capy::mutable_buffer(buf, 7)); + std::ignore = n; + cec = ec; + BOOST_TEST(q.fired()); + } + { + auto f = fault_scope::returning(sys::ReadFile, 0); + auto [ec, n] = co_await rf.read_some_at( + 0, capy::mutable_buffer(buf, 7)); + std::ignore = n; + eec = ec; + BOOST_TEST(f.fired()); + } + }; + capy::run_async(ioc.get_executor())(t()); + ioc.run(); + BOOST_TEST(wec == win_err(ERROR_ACCESS_DENIED)); + BOOST_TEST(rec == win_err(ERROR_ACCESS_DENIED)); + BOOST_TEST(cec == win_err(ERROR_LOCK_VIOLATION)); + BOOST_TEST(eec == capy::error::eof); + rf.close(); + remove_file(path); + } + + void testConnectPairFails() + { + io_context ioc(iocp); + // The pair is built by hand: a listening AF_UNIX socket put + // into non-blocking mode, a worker thread that connects, a + // polled accept here, and the accepted socket put back into + // blocking mode. Every one of those calls is faulted below, + // and each has a cleanup path of its own + // (local_connect_pair.cpp, make_pair_sockets). + { + local_stream_socket a(ioc), b(ioc); + fault_scope f(sys::WSASocketW, WSAEAFNOSUPPORT); + auto ec = connect_pair(a, b); + BOOST_TEST(f.fired()); + BOOST_TEST(ec == std::errc::address_family_not_supported); + BOOST_TEST(!a.is_open() && !b.is_open()); + } + { + local_stream_socket a(ioc), b(ioc); + fault_scope f(sys::bind, WSAEADDRINUSE); + auto ec = connect_pair(a, b); + BOOST_TEST(f.fired()); + BOOST_TEST(ec == std::errc::address_in_use); + BOOST_TEST(!a.is_open() && !b.is_open()); + } + { + local_stream_socket a(ioc), b(ioc); + fault_scope f(sys::listen, WSAEOPNOTSUPP); + auto ec = connect_pair(a, b); + BOOST_TEST(f.fired()); + BOOST_TEST(ec == std::errc::operation_not_supported); + BOOST_TEST(!a.is_open() && !b.is_open()); + } + { + // The worker still connects, so the accept fault is the + // one caller-side failure that does not strand it. + local_stream_socket a(ioc), b(ioc); + fault_scope f(sys::accept, WSAENOTSOCK); + auto ec = connect_pair(a, b); + BOOST_TEST(f.fired()); + BOOST_TEST(ec == std::errc::not_a_socket); + BOOST_TEST(!a.is_open() && !b.is_open()); + } + { + // The listener cannot be polled while it blocks, so this + // fails before the worker is even started. + local_stream_socket a(ioc), b(ioc); + fault_scope f(sys::ioctlsocket, WSAENOBUFS); + auto ec = connect_pair(a, b); + BOOST_TEST(f.fired()); + BOOST_TEST(ec == win_err(WSAENOBUFS)); + BOOST_TEST(!a.is_open() && !b.is_open()); + } + // The two worker-side failures: neither ever produces a + // connection, so the accept has to give up on its own and + // hand back what the worker saw. The arms are process-wide + // because the call they fail is on the worker thread, and the + // socket arm is the second WSASocketW because the listener is + // created first. + expect_no_handle_leak([&]{ + local_stream_socket a(ioc), b(ioc); + fault_scope f(sys::WSASocketW, WSAEMFILE, 2u, any_thread); + auto ec = connect_pair_bounded(a, b); + BOOST_TEST(f.fired()); + BOOST_TEST(ec == win_err(WSAEMFILE)); + BOOST_TEST(!a.is_open() && !b.is_open()); + }); + expect_no_handle_leak([&]{ + local_stream_socket a(ioc), b(ioc); + fault_scope f(sys::connect, WSAENETDOWN, 1u, any_thread); + auto ec = connect_pair_bounded(a, b); + BOOST_TEST(f.fired()); + BOOST_TEST(ec == win_err(WSAENETDOWN)); + BOOST_TEST(!a.is_open() && !b.is_open()); + }); + // A poll that fails abandons the accept while the worker is + // still connecting, so the socket the worker hands back has + // to be closed here. Process-wide, since the arm has to + // survive the hop onto the thread the deadline runs it on. + expect_no_handle_leak([&]{ + local_stream_socket a(ioc), b(ioc); + fault_scope f(sys::WSAPoll, WSAEINTR, 1u, any_thread); + auto ec = connect_pair_bounded(a, b); + BOOST_TEST(f.fired()); + BOOST_TEST(ec == win_err(WSAEINTR)); + BOOST_TEST(!a.is_open() && !b.is_open()); + }); + // Restoring the accepted socket's blocking mode is the last + // thing that can fail, and the only failure with a complete + // pair in hand: both ends are the library's to close. + expect_no_handle_leak([&]{ + local_stream_socket a(ioc), b(ioc); + fault_scope f(sys::ioctlsocket, WSAEINVAL, 2u); + auto ec = connect_pair(a, b); + BOOST_TEST(f.fired()); + BOOST_TEST(ec == win_err(WSAEINVAL)); + BOOST_TEST(!a.is_open() && !b.is_open()); + }); + // Adoption of the first descriptor fails; both are the + // library's to close. + expect_no_handle_leak([&]{ + local_stream_socket a(ioc), b(ioc); + fault_scope f(sys::getsockopt, WSAENOTSOCK); + auto ec = connect_pair(a, b); + BOOST_TEST(f.fired()); + BOOST_TEST(ec == std::errc::not_a_socket); + BOOST_TEST(!a.is_open() && !b.is_open()); + }); + // A readiness report the accept cannot satisfy is not an + // error: the loop goes back to polling and the pair forms. + // The accept is on this thread, so a thread-local arm reaches + // it without touching the worker's own calls. + { + local_stream_socket a(ioc), b(ioc); + fault_scope f(sys::accept, WSAEWOULDBLOCK); + auto ec = connect_pair(a, b); + BOOST_TEST(f.fired()); + BOOST_TEST(!ec); + BOOST_TEST(a.is_open() && b.is_open()); + } + // Adoption of the second descriptor fails after the first + // took: one end is the library's to close and the other is + // still a bare socket, and both have to go. + expect_no_handle_leak([&]{ + local_stream_socket a(ioc), b(ioc); + fault_scope f(sys::getsockopt, WSAENOTSOCK, 2u); + auto ec = connect_pair(a, b); + BOOST_TEST(f.fired()); + BOOST_TEST(ec == std::errc::not_a_socket); + BOOST_TEST(!a.is_open() && !b.is_open()); + }); + // Unfaulted, a pair still forms. + local_stream_socket a(ioc), b(ioc); + BOOST_TEST(!connect_pair_bounded(a, b)); + } + + void testAvailableThrows() + { + io_context ioc(iocp); + local_stream_socket a(ioc), b(ioc); + BOOST_TEST(!connect_pair(a, b)); + // WSAEINVAL is one of the codes make_err passes through, so + // available() reports it as the raw Winsock code either way. + fault_scope f(sys::ioctlsocket, WSAEINVAL); + expect_system_error( + [&]{ std::ignore = a.available(); }, win_err(WSAEINVAL)); + BOOST_TEST(f.fired()); + BOOST_TEST(a.is_open()); + } + + void testResolverFails() + { + io_context ioc(iocp); + resolver r(ioc); + std::error_code fec, rec; + auto t = [&]() -> capy::task<> + { + { + // GetAddrInfoExW reports a synchronous failure through + // its return value and the last-error slot alike. + fault_scope f(sys::GetAddrInfoExW, WSAEAFNOSUPPORT); + auto [ec, results] = co_await r.resolve("localhost", "80"); + std::ignore = results; + fec = ec; + BOOST_TEST(f.fired()); + } + { + // GetNameInfoW blocks, so it runs on a pool thread + // where the thread-local arms are never consulted. + fault_scope f(sys::GetNameInfoW, WSAEAFNOSUPPORT, 1, + any_thread); + auto [ec, result] = co_await r.resolve( + endpoint(ipv4_address::loopback(), 80)); + std::ignore = result; + rec = ec; + BOOST_TEST(f.fired()); + } + }; + capy::run_async(ioc.get_executor())(t()); + ioc.run(); + BOOST_TEST(fec == std::errc::address_family_not_supported); + BOOST_TEST(rec == std::errc::address_family_not_supported); + } + + // resolver_detail::to_wide gives up when MultiByteToWideChar + // reports a length of zero or less and hands back an empty string. + // Faulting the length probe of each conversion in turn leaves both + // the node and the service name empty, and win_resolver::resolve + // passes a null pointer for an empty one -- a lookup with neither + // is the documented WSAHOST_NOT_FOUND. The conversions run on the + // calling thread, so a thread-local arm reaches them. + void testResolverWideConversionFails() + { + io_context ioc(iocp); + resolver r(ioc); + std::error_code rec; + bool armed_fired = false; + auto t = [&]() -> capy::task<> + { + // The host conversion is calls 1 and 2 and the service + // conversion 3 and 4; failing the first probe skips call 2, + // so nth 2 is the service's own probe. + fault_scope host(sys::MultiByteToWideChar, + ERROR_INVALID_PARAMETER, 1); + fault_scope service(sys::MultiByteToWideChar, + ERROR_INVALID_PARAMETER, 2); + auto [ec, results] = co_await r.resolve("localhost", "80"); + std::ignore = results; + rec = ec; + armed_fired = host.fired() && service.fired(); + }; + capy::run_async(ioc.get_executor())(t()); + ioc.run(); + BOOST_TEST(armed_fired); + if(rec != win_err(WSAHOST_NOT_FOUND)) + std::fprintf(stderr, + "fault harness: name-less lookup reported %d (%s)\n", + rec.value(), rec.message().c_str()); + BOOST_TEST(rec == win_err(WSAHOST_NOT_FOUND)); + } + + void testResolverCancelIgnored() + { + io_context ioc(iocp); + resolver r(ioc); + std::error_code rec; + bool cancel_fired = false; + auto body = [&]() -> capy::task<> + { + auto [ec, results] = co_await r.resolve( + "corosio-fault-nonexistent.invalid", "80"); + std::ignore = results; + rec = ec; + }; + auto canceller = [&]() -> capy::task<> + { + fault_scope f(sys::GetAddrInfoExCancel, WSAEINVAL); + r.cancel(); + cancel_fired = f.fired(); + co_return; + }; + capy::run_async(ioc.get_executor())(body()); + capy::run_async(ioc.get_executor())(canceller()); + ioc.run(); + // A lookup answered from the resolver cache completes before + // the canceller runs and never records a cancel handle, so the + // discarded return value is only asserted on the pending path. + if(rec == capy::error::canceled) + BOOST_TEST(cancel_fired); + } + + /* Adoption of a handle the library did not open. + + The only thing it does is associate the handle with the + completion port, so a refusal there is its only failure. It + closes what the object held before it tries, which is why the + caller's handle is still theirs afterwards. + */ + void testFileAssignFails() + { + io_context ioc(iocp); + auto path = temp_path("winassign"); + { + HANDLE h = make_native_file(path); + BOOST_TEST(h != INVALID_HANDLE_VALUE); + stream_file sf(ioc); + { + fault_scope f(sys::CreateIoCompletionPort, + ERROR_INVALID_PARAMETER); + BOOST_TEST( + sf.assign(reinterpret_cast(h)) == + win_err(ERROR_INVALID_PARAMETER)); + BOOST_TEST(f.fired()); + BOOST_TEST(!sf.is_open()); + } + BOOST_TEST(::CloseHandle(h) != FALSE); + } + { + HANDLE h = make_native_file(path); + BOOST_TEST(h != INVALID_HANDLE_VALUE); + random_access_file rf(ioc); + { + fault_scope f(sys::CreateIoCompletionPort, + ERROR_INVALID_PARAMETER); + BOOST_TEST( + rf.assign(reinterpret_cast(h)) == + win_err(ERROR_INVALID_PARAMETER)); + BOOST_TEST(f.fired()); + BOOST_TEST(!rf.is_open()); + } + BOOST_TEST(::CloseHandle(h) != FALSE); + } + remove_file(path); + } + + /* A completion the thread pool never carried. + + A GetAddrInfoExW that fails without going asynchronous is + finished on the calling thread, and win_resolver::resolve posts + the operation rather than resuming it inline. That post is the + scheduler's scheduler_op overload, whose only fallback when the + completion port refuses the packet is the deferred queue, which + the run loop drains on its next turn. + */ + void testResolverPostFallbackRuns() + { + io_context ioc(iocp); + resolver r(ioc); + std::error_code fec; + bool fired = false; + auto t = [&]() -> capy::task<> + { + fault_scope g(sys::GetAddrInfoExW, WSAEAFNOSUPPORT); + fault_scope p(sys::PostQueuedCompletionStatus, + ERROR_NO_SYSTEM_RESOURCES); + auto [ec, results] = co_await r.resolve("localhost", "80"); + std::ignore = results; + fec = ec; + fired = g.fired() && p.fired(); + ioc.stop(); + }; + bool expired = false; + capy::run_async(ioc.get_executor())(t()); + capy::run_async(ioc.get_executor())(stop_guard(ioc, expired)); + ioc.run(); + BOOST_TEST(!expired); + BOOST_TEST(fired); + BOOST_TEST(fec == std::errc::address_family_not_supported); + } + + /* The conversion on the way back out. + + A reverse lookup's answer arrives wide and is converted on the + pool thread, so the arm has to be process-wide. A size query of + zero leaves the name empty and reports nothing + (resolver_detail::from_wide): the lookup itself succeeded. + */ + void testResolverReverseWideConversionFails() + { + io_context ioc(iocp); + resolver r(ioc); + std::error_code rec; + std::string host = "unset"; + bool fired = false; + auto t = [&]() -> capy::task<> + { + fault_scope f(sys::WideCharToMultiByte, + ERROR_NO_UNICODE_TRANSLATION, 1, any_thread); + auto [ec, result] = co_await r.resolve( + endpoint(ipv4_address::loopback(), 80)); + rec = ec; + host = result.host_name(); + fired = f.fired(); + }; + capy::run_async(ioc.get_executor())(t()); + ioc.run(); + BOOST_TEST(fired); + BOOST_TEST(!rec); + BOOST_TEST(host.empty()); + } + + void run() + { + testHostNameFails(); + testStreamFileOpenFails(); + testStreamFileSyncOps(); + testStreamFileIoFails(); + testRandomAccessFileFails(); + testFileAssignFails(); + testConnectPairFails(); + testAvailableThrows(); + testResolverFails(); + testResolverPostFallbackRuns(); + testResolverWideConversionFails(); + testResolverReverseWideConversionFails(); + testResolverCancelIgnored(); + } +}; + +TEST_SUITE(win_common_faults, "boost.corosio.fault.win"); + +/* The CRT's signal registration is process-wide and counted, so a + second signal_set in the same process would find the handler already + installed and never reach the faulted call. There is no fork on + Windows to isolate that in, so these live in a suite of their own: + CTest runs each suite as its own process. +*/ +struct win_signal_faults +{ + // SIGTERM is defined by every Windows CRT and is never raised by + // the OS, so registering and restoring it disturbs nothing. + static constexpr int signum = SIGTERM; + + void testAddFails() + { + io_context ioc(iocp); + signal_set ss(ioc); + { + fault_scope f(sys::signal, ERROR_INVALID_PARAMETER); + auto ec = ss.add(signum); + BOOST_TEST(f.fired()); + BOOST_TEST(ec == std::errc::invalid_argument); + } + // Not latched: the failed add left no registration behind, so + // the retry installs the handler. + BOOST_TEST(!ss.add(signum)); + BOOST_TEST(!ss.clear()); + } + + void testRemoveFails() + { + io_context ioc(iocp); + signal_set ss(ioc); + BOOST_TEST(!ss.add(signum)); + { + fault_scope f(sys::signal, ERROR_INVALID_PARAMETER); + auto ec = ss.remove(signum); + BOOST_TEST(f.fired()); + BOOST_TEST(ec == std::errc::invalid_argument); + } + // The failed remove returns before unlinking, so the + // registration is still there for the retry to take out + // (win_signals::remove_signal). + BOOST_TEST(!ss.remove(signum)); + BOOST_TEST(!ss.clear()); + } + + void testClearFails() + { + io_context ioc(iocp); + signal_set ss(ioc); + BOOST_TEST(!ss.add(signum)); + { + fault_scope f(sys::signal, ERROR_INVALID_PARAMETER); + auto ec = ss.clear(); + BOOST_TEST(f.fired()); + BOOST_TEST(ec == std::errc::invalid_argument); + } + // clear() reports the first failure but still unlinks every + // registration, so the set is empty either way. + BOOST_TEST(!ss.clear()); + BOOST_TEST(!ss.add(signum)); + BOOST_TEST(!ss.clear()); + } + + void run() + { + testAddFails(); + testRemoveFails(); + testClearFails(); + } +}; + +TEST_SUITE(win_signal_faults, "boost.corosio.fault.win.signals"); + +} // boost::corosio::test::fault + +#endif diff --git a/test/unit/local_datagram_socket.cpp b/test/unit/local_datagram_socket.cpp index f05a09a39..f19780426 100644 --- a/test/unit/local_datagram_socket.cpp +++ b/test/unit/local_datagram_socket.cpp @@ -883,6 +883,46 @@ struct local_datagram_socket_test BOOST_TEST(recv_ec == capy::cond::canceled); } + // Destroy the io_context with a receive completion already queued. + // Both sockets have a datagram waiting while the receives are + // parked, so the one handler the loop dispatches leaves the other + // completion for the scheduler's shutdown to drain. + void testDestroyWithQueuedReceive() + { + int resumed = 0; + int before_destroy = 0; + { + io_context ioc(Backend); + auto ex = ioc.get_executor(); + local_datagram_socket a1(ioc), b1(ioc), a2(ioc), b2(ioc); + if (auto ec = connect_pair(a1, b1)) + throw std::system_error(ec, "connect_pair"); + if (auto ec = connect_pair(a2, b2)) + throw std::system_error(ec, "connect_pair"); + + char buf1[8], buf2[8]; + local_endpoint from1, from2; + auto reader = [&](local_datagram_socket& s, char* p, + local_endpoint& from) -> capy::task<> { + std::ignore = co_await s.recv_from( + capy::mutable_buffer(p, 8), from); + ++resumed; + }; + capy::run_async(ex)(reader(a1, buf1, from1)); + capy::run_async(ex)(reader(a2, buf2, from2)); + std::ignore = ioc.run_one(); + std::ignore = ioc.run_one(); + + BOOST_TEST(::send(b1.native_handle(), "x", 1, 0) == 1); + BOOST_TEST(::send(b2.native_handle(), "x", 1, 0) == 1); + + std::ignore = ioc.run_one(); + before_destroy = resumed; + } + BOOST_TEST(before_destroy < 2); + BOOST_TEST_EQ(resumed, before_destroy); + } + void run() { testConstruction(); @@ -912,6 +952,10 @@ struct local_datagram_socket_test testDatagramBoundary(); testRecvPeek(); testRecvFromPeek(); +#if !COROSIO_TEST_HAS_ASAN + // Abandon parked coroutine frames by design; see context.hpp. + testDestroyWithQueuedReceive(); +#endif #ifdef __linux__ testAbstractSocket(); #endif diff --git a/test/unit/local_stream_socket.cpp b/test/unit/local_stream_socket.cpp index 4288ce9dc..29eabf271 100644 --- a/test/unit/local_stream_socket.cpp +++ b/test/unit/local_stream_socket.cpp @@ -1188,6 +1188,147 @@ struct local_stream_socket_test BOOST_TEST_PASS(); } + // Destroy the io_context with a read completion already queued. + // Both pairs are made readable before the loop runs, so the single + // dispatched handler leaves the other completion behind and the + // scheduler's shutdown has to drain it rather than deliver it. + void testDestroyWithQueuedRead() + { + int resumed = 0; + int before_destroy = 0; + { + io_context ioc(Backend); + auto ex = ioc.get_executor(); + local_stream_socket a1(ioc), b1(ioc), a2(ioc), b2(ioc); + if (auto ec = connect_pair(a1, b1)) + throw std::system_error(ec, "connect_pair"); + if (auto ec = connect_pair(a2, b2)) + throw std::system_error(ec, "connect_pair"); + + char buf1[8], buf2[8]; + auto reader = [&](local_stream_socket& s, + char* p) -> capy::task<> { + std::ignore = co_await s.read_some( + capy::mutable_buffer(p, 8)); + ++resumed; + }; + capy::run_async(ex)(reader(a1, buf1)); + capy::run_async(ex)(reader(a2, buf2)); + // Two handlers, one per coroutine, park both reads. + std::ignore = ioc.run_one(); + std::ignore = ioc.run_one(); + + BOOST_TEST(::send(b1.native_handle(), "x", 1, 0) == 1); + BOOST_TEST(::send(b2.native_handle(), "x", 1, 0) == 1); + + std::ignore = ioc.run_one(); + before_destroy = resumed; + } + BOOST_TEST(before_destroy < 2); + BOOST_TEST_EQ(resumed, before_destroy); + } + + // Destroy the io_context with a connect completion already queued. + // Both handshakes finish before the loop dispatches, so one is + // still waiting in the queue when the scheduler shuts down. + void testDestroyWithQueuedConnect() + { + int resumed = 0; + int before_destroy = 0; + { + io_context ioc(Backend); + auto ex = ioc.get_executor(); + test::temp_socket_dir tmp; + + local_stream_acceptor acc(ioc); + BOOST_TEST(!acc.open()); + auto const ep = local_endpoint(tmp.path()); + BOOST_TEST(!acc.bind(ep)); + BOOST_TEST(!acc.listen()); + + local_stream_socket c1(ioc), c2(ioc); + auto client = [&](local_stream_socket& s) -> capy::task<> { + std::ignore = co_await s.connect(ep); + ++resumed; + }; + capy::run_async(ex)(client(c1)); + capy::run_async(ex)(client(c2)); + std::ignore = ioc.run_one(); + std::ignore = ioc.run_one(); + std::ignore = ioc.run_one(); + before_destroy = resumed; + } + BOOST_TEST_EQ(resumed, before_destroy); + } + + // Destroy the io_context with a wait completion already queued. + // Both sockets become readable while the waits are parked, so the + // one handler the loop dispatches leaves the other wait for the + // scheduler's shutdown to drain. + void testDestroyWithQueuedWait() + { + int resumed = 0; + int before_destroy = 0; + { + io_context ioc(Backend); + auto ex = ioc.get_executor(); + local_stream_socket a1(ioc), b1(ioc), a2(ioc), b2(ioc); + if (auto ec = connect_pair(a1, b1)) + throw std::system_error(ec, "connect_pair"); + if (auto ec = connect_pair(a2, b2)) + throw std::system_error(ec, "connect_pair"); + + auto waiter = [&](local_stream_socket& s) -> capy::task<> { + std::ignore = co_await s.wait(wait_type::read); + ++resumed; + }; + capy::run_async(ex)(waiter(a1)); + capy::run_async(ex)(waiter(a2)); + std::ignore = ioc.run_one(); + std::ignore = ioc.run_one(); + + BOOST_TEST(::send(b1.native_handle(), "x", 1, 0) == 1); + BOOST_TEST(::send(b2.native_handle(), "x", 1, 0) == 1); + + std::ignore = ioc.run_one(); + before_destroy = resumed; + } + BOOST_TEST(before_destroy < 2); + BOOST_TEST_EQ(resumed, before_destroy); + } + + // Destroy the io_context with a write completion already queued. + // Only the proactor backends reach that state: a POSIX write to a + // socket with room finishes in the initiator, so there the scope + // ends with nothing outstanding and the test is a no-op. + void testDestroyWithQueuedWrite() + { + int resumed = 0; + int before_destroy = 0; + { + io_context ioc(Backend); + auto ex = ioc.get_executor(); + local_stream_socket a1(ioc), b1(ioc), a2(ioc), b2(ioc); + if (auto ec = connect_pair(a1, b1)) + throw std::system_error(ec, "connect_pair"); + if (auto ec = connect_pair(a2, b2)) + throw std::system_error(ec, "connect_pair"); + + auto writer = [&](local_stream_socket& s) -> capy::task<> { + std::ignore = + co_await s.write_some(capy::const_buffer("x", 1)); + ++resumed; + }; + capy::run_async(ex)(writer(a1)); + capy::run_async(ex)(writer(a2)); + std::ignore = ioc.run_one(); + std::ignore = ioc.run_one(); + std::ignore = ioc.run_one(); + before_destroy = resumed; + } + BOOST_TEST_EQ(resumed, before_destroy); + } + void testAcceptorOnClosedNoOp() { // cancel/close on a never-opened acceptor are no-ops. @@ -1662,6 +1803,10 @@ struct local_stream_socket_test // Abandon parked coroutine frames by design; see context.hpp. testDestroyWithParkedAccept(); testDestroyWithParkedRead(); + testDestroyWithQueuedRead(); + testDestroyWithQueuedConnect(); + testDestroyWithQueuedWait(); + testDestroyWithQueuedWrite(); #endif testAcceptorOnClosedNoOp(); testAcceptorBindClosedThrows(); diff --git a/test/unit/pool_teardown.hpp b/test/unit/pool_teardown.hpp new file mode 100644 index 000000000..188a286ad --- /dev/null +++ b/test/unit/pool_teardown.hpp @@ -0,0 +1,121 @@ +// +// Copyright (c) 2026 Steve Gerbino +// +// Distributed under the Boost Software License, Version 1.0. (See accompanying +// file LICENSE_1_0.txt or copy at http://www.boost.org/LICENSE_1_0.txt) +// +// Official repository: https://github.com/cppalliance/corosio +// + +#ifndef BOOST_COROSIO_TEST_POOL_TEARDOWN_HPP +#define BOOST_COROSIO_TEST_POOL_TEARDOWN_HPP + +#include +#include +#include + +#include +#include + +namespace boost::corosio::test { + +/** A pool work item that occupies a worker until it is released. + + Post one of these to a context's thread pool and every item posted + after it stays queued behind it. That is what holds a real + operation's pool work undone across the context's destruction, + without asking the test to time anything. + + Declare the blocker before the context: the pool joins its workers + while the context is being destroyed, so the item has to outlive it. + + @par Thread Safety + Distinct objects: Safe. + Shared objects: Safe. +*/ +class pool_blocker : public detail::pool_work_item +{ + std::mutex mutex_; + std::condition_variable cv_; + bool released_ = false; + + static void run(detail::pool_work_item* w) noexcept + { + auto* self = static_cast(w); + std::unique_lock lock(self->mutex_); + self->cv_.wait(lock, [self] { return self->released_; }); + } + +public: + /// Construct an item that blocks the worker running it. + pool_blocker() noexcept + { + func_ = &pool_blocker::run; + } + + /// Let the occupied worker go. + void release() + { + { + std::lock_guard lock(mutex_); + released_ = true; + } + cv_.notify_one(); + } +}; + +/** A service that releases a @ref pool_blocker as the context shuts down. + + Create it last so the newest-first shutdown walk runs it before any + library service: the blocked worker is let go at the very top of + teardown, and the operation queued behind it therefore completes + while the context is being destroyed rather than before. +*/ +class pool_release_gate final + : public capy::execution_context::service +{ + pool_blocker* blocker_ = nullptr; + + void shutdown() override + { + if (blocker_) + blocker_->release(); + } + +public: + /// The lookup key for this service. + using key_type = pool_release_gate; + + /// Construct an unarmed gate. + explicit pool_release_gate(capy::execution_context&) noexcept {} + + /** Arm the gate. + + @param b The blocker to release when the context shuts down. + */ + void arm(pool_blocker& b) noexcept + { + blocker_ = &b; + } +}; + +/** Occupy the context's thread pool with a blocker armed for teardown. + + @param ioc The context whose pool is occupied. + @param blocker The item to post; must outlive `ioc`. + + @return True if the pool accepted the item. +*/ +inline bool +park_pool_worker(io_context& ioc, pool_blocker& blocker) +{ + auto* pool = ioc.find_service(); + if (!pool) + return false; + ioc.use_service().arm(blocker); + return !pool->post(&blocker); +} + +} // namespace boost::corosio::test + +#endif // BOOST_COROSIO_TEST_POOL_TEARDOWN_HPP diff --git a/test/unit/random_access_file.cpp b/test/unit/random_access_file.cpp index 0f3daabd6..9f572d077 100644 --- a/test/unit/random_access_file.cpp +++ b/test/unit/random_access_file.cpp @@ -17,24 +17,31 @@ #endif #include +#include +#include #include #include +#include #include #include #include "context.hpp" +#include "pool_teardown.hpp" #include "test_suite.hpp" +#include #include #include #include #include +#include #include #include #include #include #include #include +#include #include @@ -633,6 +640,83 @@ struct random_access_file_test BOOST_TEST_EQ(completed.load(), num_ops); } + // Destroy the io_context with a file the service still owns. The + // file and the parked accept share a coroutine frame that the + // accept never unwinds, so the service reclaims a live implementation + // at shutdown instead of an empty list. + void testDestroyWithLiveFile() + { + temp_file tmp("raf_teardown_", "hello world"); + // Copy out of the anonymous-namespace type: capturing it + // by reference gives the lambda a member whose type has + // internal linkage, which -Wsubobject-linkage rejects. + auto const path = tmp.path; + bool resumed = false; + { + io_context ioc(Backend); + auto keeper = [&]() -> capy::task<> { + random_access_file f(ioc); + std::ignore = f.open(path, file_base::read_only); + tcp_acceptor acc(ioc); + std::ignore = acc.open(); + std::ignore = acc.bind( + endpoint(ipv4_address::loopback(), 0)); + std::ignore = acc.listen(); + tcp_socket peer(ioc); + std::ignore = co_await acc.accept(peer); + resumed = true; + }; + capy::run_async(ioc.get_executor())(keeper()); + // One handler carries the coroutine to the parked accept. + std::ignore = ioc.run_one(); + } + BOOST_TEST(!resumed); + } + + // A read queued behind a worker that is released only once teardown + // has begun. The pool has to join before the scheduler drains, or + // the completion the worker posts on its way out is neither run nor + // destroyed and the operation's keepalive leaks. + // + // The task is started by hand and owned by the test, so the frame + // the library abandons at teardown is destroyed here rather than + // leaked: what LeakSanitizer sees left over is the defect alone. + void testDestroyWithPoolWorkQueued() + { +#if BOOST_COROSIO_HAS_IO_URING + // io_uring reads through the ring, never through the pool. + if constexpr (std::is_same_v< + std::remove_const_t, io_uring_t>) + return; +#endif + temp_file tmp("raf_pool_teardown_", "hello world"); + auto const path = tmp.path; + bool resumed = false; + test::pool_blocker blocker; + std::optional ex; + std::optional env; + std::optional> parked; + { + io_context ioc(Backend); + BOOST_TEST(test::park_pool_worker(ioc, blocker)); + + random_access_file f(ioc); + std::ignore = f.open(path, file_base::read_only); + auto reader = [&]() -> capy::task<> { + char buf[16]; + std::ignore = co_await f.read_some_at( + 0, capy::mutable_buffer(buf, sizeof(buf))); + resumed = true; + }; + + ex.emplace(ioc.get_executor()); + env.emplace(capy::io_env{*ex, std::stop_token{}, nullptr}); + parked.emplace(reader()); + parked->await_suspend(std::noop_coroutine(), &*env).resume(); + } + BOOST_TEST(!resumed); + } + void run() { testConstruction(); @@ -684,6 +768,16 @@ struct random_access_file_test testReadAtPastEofErrorPath(); testCancelInflightOperation(); testCancelWithStoppedToken(); + +#if BOOST_COROSIO_POSIX + // POSIX file work runs on the pool; IOCP uses overlapped I/O. + testDestroyWithPoolWorkQueued(); +#endif + +#if !COROSIO_TEST_HAS_ASAN + // Abandon parked coroutine frames by design; see context.hpp. + testDestroyWithLiveFile(); +#endif } // Operations on closed file diff --git a/test/unit/resolver.cpp b/test/unit/resolver.cpp index b27c6cf4f..bafcb80da 100644 --- a/test/unit/resolver.cpp +++ b/test/unit/resolver.cpp @@ -17,12 +17,20 @@ #endif #include +#include +#include #include +#include #include #include +#include +#include #include +#include +#include "context.hpp" +#include "pool_teardown.hpp" #include "test_suite.hpp" namespace boost::corosio { @@ -1076,6 +1084,149 @@ struct resolver_test BOOST_TEST(converted == ep); } + // Destroy the io_context with a resolver the service still owns. + // The resolver and the parked accept share a coroutine frame that + // the accept never unwinds, so the service reclaims a live + // implementation at shutdown instead of an empty list. + void testDestroyWithLiveResolver() + { + bool resumed = false; + { + io_context ioc; + auto keeper = [&]() -> capy::task<> { + resolver r(ioc); + tcp_acceptor acc(ioc); + std::ignore = acc.open(); + std::ignore = acc.bind( + endpoint(ipv4_address::loopback(), 0)); + std::ignore = acc.listen(); + tcp_socket peer(ioc); + std::ignore = co_await acc.accept(peer); + resumed = true; + }; + capy::run_async(ioc.get_executor())(keeper()); + // One handler carries the coroutine to the parked accept. + std::ignore = ioc.run_one(); + } + BOOST_TEST(!resumed); + } + +#if BOOST_COROSIO_POSIX + // A resolve queued behind a worker that is released only once + // teardown has begun. The pool has to join before the scheduler + // drains, or the completion the worker posts on its way out is + // neither run nor destroyed and the operation's keepalive leaks. + // + // The task is started by hand and owned by the test, so the frame + // the library abandons at teardown is destroyed here rather than + // leaked: what LeakSanitizer sees left over is the defect alone. + // + // Forward resolution reaches the pool on POSIX only; IOCP resolves + // through GetAddrInfoExW and the completion port. + void testDestroyWithPoolResolveQueued() + { + bool resumed = false; + test::pool_blocker blocker; + std::optional ex; + std::optional env; + std::optional> parked; + { + io_context ioc; + BOOST_TEST(test::park_pool_worker(ioc, blocker)); + + resolver r(ioc); + auto query = [&]() -> capy::task<> { + // Numeric, so the queued work needs no name service. + std::ignore = co_await r.resolve( + "127.0.0.1", "80", + resolve_flags::numeric_host + | resolve_flags::numeric_service); + resumed = true; + }; + + ex.emplace(ioc.get_executor()); + env.emplace(capy::io_env{*ex, std::stop_token{}, nullptr}); + parked.emplace(query()); + parked->await_suspend(std::noop_coroutine(), &*env).resume(); + } + BOOST_TEST(!resumed); + } +#endif + + // The reverse half of the test above, which reaches the pool on + // every platform. + void testDestroyWithPoolReverseQueued() + { + bool resumed = false; + test::pool_blocker blocker; + std::optional ex; + std::optional env; + std::optional> parked; + { + io_context ioc; + BOOST_TEST(test::park_pool_worker(ioc, blocker)); + + resolver r(ioc); + auto query = [&]() -> capy::task<> { + // Numeric, so the queued work needs no name service. + std::ignore = co_await r.resolve( + endpoint(ipv4_address::loopback(), 80), + reverse_flags::numeric_host + | reverse_flags::numeric_service); + resumed = true; + }; + + ex.emplace(ioc.get_executor()); + env.emplace(capy::io_env{*ex, std::stop_token{}, nullptr}); + parked.emplace(query()); + parked->await_suspend(std::noop_coroutine(), &*env).resume(); + } + BOOST_TEST(!resumed); + } + +#if BOOST_COROSIO_HAS_IOCP + // Forward resolution on IOCP dispatches through GetAddrInfoExW and + // posts its completion (resolve_op, embedded in the win_resolver) to + // the scheduler queue. Unlike the reverse path and both POSIX paths, + // the forward op takes no shared_from_this()/impl_ptr keepalive, so + // destroying the resolver frees the win_resolver the queued + // resolve_op lives in before teardown drains that op -- a + // use-after-free that ASan catches. localhost resolves from the hosts + // file synchronously, so GetAddrInfoExW posts the op inline and it is + // reliably queued when the resolver is freed. + // + // The task is started by hand and owned by the test, so the frame the + // library abandons at teardown is destroyed here rather than leaked: + // what a sanitizer reports is the defect alone. The bug is observable + // only under ASan; without a sanitizer the freed read is silent. + // + // Contrast the reverse path, which holds the keepalive on + // reverse_op_.impl_ptr across the queued completion + // (win_resolver_service.hpp do_reverse_resolve_work / do_complete), + // and so survives teardown intact. + void testDestroyWithForwardResolveQueued() + { + bool resumed = false; + std::optional ex; + std::optional env; + std::optional> parked; + { + io_context ioc; + resolver r(ioc); + auto query = [&]() -> capy::task<> { + std::ignore = co_await r.resolve("localhost", "80"); + resumed = true; + }; + + ex.emplace(ioc.get_executor()); + env.emplace(capy::io_env{*ex, std::stop_token{}, nullptr}); + parked.emplace(query()); + parked->await_suspend(std::noop_coroutine(), &*env).resume(); + } + BOOST_TEST(!resumed); + } +#endif + void run() { // Construction and move semantics @@ -1140,6 +1291,20 @@ struct resolver_test testReverseFlagsOperators(); testSequentialReverseResolves(); testMixedResolveAndReverseResolve(); + +#if BOOST_COROSIO_POSIX + testDestroyWithPoolResolveQueued(); +#endif + testDestroyWithPoolReverseQueued(); + +#if BOOST_COROSIO_HAS_IOCP + testDestroyWithForwardResolveQueued(); +#endif + +#if !COROSIO_TEST_HAS_ASAN + // Abandon parked coroutine frames by design; see context.hpp. + testDestroyWithLiveResolver(); +#endif } }; diff --git a/test/unit/signal_set.cpp b/test/unit/signal_set.cpp index 41f841721..46af57705 100644 --- a/test/unit/signal_set.cpp +++ b/test/unit/signal_set.cpp @@ -25,6 +25,10 @@ #include "context.hpp" #include "test_suite.hpp" +#if BOOST_COROSIO_POSIX +#include +#endif + namespace boost::corosio { // Signal set tests @@ -364,21 +368,131 @@ struct signal_set_test void testShutdownWithPendingSignalSet() { - // Construct a signal_set that owns a signal registration, then let - // the io_context shutdown drain the impl_list (covers shutdown - // loop deleting registrations). + // A set destroyed in the documented order gives its registrations + // back through destroy(), so the service's shutdown finds an + // empty impl_list_ here; the walk itself is reached by + // testShutdownReleasesRegistration below. [[maybe_unused]] int destroyed = 0; { io_context ioc(Backend); [[maybe_unused]] signal_set s(ioc, SIGINT, SIGTERM); - // No run() — drop directly into io_context destruction so the - // service's shutdown path walks impl_list_ and frees both - // signal_registration nodes. } BOOST_TEST_PASS(); } + // The process signal table outlives every io_context, so a set still + // registered when its context shuts down -- which an abandoned frame + // is the only way to arrange -- has to hand the registration back + // there: a stale entry keeps the signal installed with the old flags + // and refuses the next add() of it. + void testShutdownReleasesRegistration() + { +#if BOOST_COROSIO_POSIX + constexpr auto parked_flags = signal_set::restart; + constexpr auto reuse_flags = signal_set::no_defer; +#else + constexpr auto parked_flags = signal_set::none; + constexpr auto reuse_flags = signal_set::none; +#endif + bool resumed = false; + { + io_context ioc(Backend); + auto keeper = [&]() -> capy::task<> { + signal_set sig(ioc); + BOOST_TEST(!sig.add(SIGINT, parked_flags)); + std::ignore = co_await sig.wait(); + resumed = true; + }; + capy::run_async(ioc.get_executor())(keeper()); + // Exactly one handler, the coroutine start: anything else + // would leave the wait unparked and the set unregistered by + // the time the context is destroyed. + BOOST_TEST(ioc.run_one() == 1); + } + BOOST_TEST(!resumed); + + io_context ioc(Backend); + signal_set s(ioc); + auto add_ec = s.add(SIGINT, reuse_flags); + BOOST_TEST(!add_ec); + // Raising with no handler installed would kill the process. + if (add_ec) + return; + + // Raised before the wait starts, so nothing here is timed. + std::raise(SIGINT); + + bool completed = false; + int received_signal = 0; + + auto wait_task = [](signal_set& s_ref, int& sig_out, + bool& done_out) -> capy::task<> { + auto [ec, signum] = co_await s_ref.wait(); + sig_out = signum; + done_out = !ec; + }; + capy::run_async(ioc.get_executor())( + wait_task(s, received_signal, completed)); + + ioc.run(); + BOOST_TEST(completed); + BOOST_TEST_EQ(received_signal, SIGINT); + } + + // The walk gives back one entry, not the signal: with a second + // context registered on SIGINT the disposition has to survive the + // first context's teardown, and the survivor has to keep receiving. + void testShutdownKeepsOtherContextRegistered() + { + io_context survivor(Backend); + signal_set kept(survivor); + BOOST_TEST(!kept.add(SIGINT)); + + bool resumed = false; + { + io_context ioc(Backend); + auto keeper = [&]() -> capy::task<> { + signal_set sig(ioc); + BOOST_TEST(!sig.add(SIGINT)); + std::ignore = co_await sig.wait(); + resumed = true; + }; + capy::run_async(ioc.get_executor())(keeper()); + BOOST_TEST(ioc.run_one() == 1); + } + BOOST_TEST(!resumed); + +#if BOOST_COROSIO_POSIX + // A disposition restored out from under the survivor would make + // the raise below kill the process, so say so as a failure. + struct sigaction cur = {}; + BOOST_TEST(::sigaction(SIGINT, nullptr, &cur) == 0); + BOOST_TEST(cur.sa_handler != SIG_DFL); + if (cur.sa_handler == SIG_DFL) + return; +#endif + + // Raised before the wait starts, so nothing here is timed. + std::raise(SIGINT); + + bool completed = false; + int received_signal = 0; + + auto wait_task = [](signal_set& s_ref, int& sig_out, + bool& done_out) -> capy::task<> { + auto [ec, signum] = co_await s_ref.wait(); + sig_out = signum; + done_out = !ec; + }; + capy::run_async(survivor.get_executor())( + wait_task(kept, received_signal, completed)); + + survivor.run(); + BOOST_TEST(completed); + BOOST_TEST_EQ(received_signal, SIGINT); + } + // Multiple signal set tests void testMultipleSignalSetsOnSameSignal() @@ -916,6 +1030,12 @@ struct signal_set_test // Signal flags tests (Windows only) testFlagsNotSupportedOnWindows(); #endif + +#if !COROSIO_TEST_HAS_ASAN + // Abandon parked coroutine frames by design; see context.hpp. + testShutdownReleasesRegistration(); + testShutdownKeepsOtherContextRegistered(); +#endif } }; diff --git a/test/unit/stream_file.cpp b/test/unit/stream_file.cpp index 36a1c826b..fbdbd5919 100644 --- a/test/unit/stream_file.cpp +++ b/test/unit/stream_file.cpp @@ -17,24 +17,31 @@ #endif #include +#include +#include #include #include +#include #include #include #include "context.hpp" +#include "pool_teardown.hpp" #include "test_suite.hpp" +#include #include #include #include #include +#include #include #include #include #include #include #include +#include #include #include @@ -935,6 +942,83 @@ struct stream_file_test } } + // Destroy the io_context with a file the service still owns. The + // file and the parked accept share a coroutine frame that the + // accept never unwinds, so the service reclaims a live implementation + // at shutdown instead of an empty list. + void testDestroyWithLiveFile() + { + temp_file tmp("sf_teardown_", "hello world"); + // Copy out of the anonymous-namespace type: capturing it + // by reference gives the lambda a member whose type has + // internal linkage, which -Wsubobject-linkage rejects. + auto const path = tmp.path; + bool resumed = false; + { + io_context ioc(Backend); + auto keeper = [&]() -> capy::task<> { + stream_file f(ioc); + std::ignore = f.open(path, file_base::read_only); + tcp_acceptor acc(ioc); + std::ignore = acc.open(); + std::ignore = acc.bind( + endpoint(ipv4_address::loopback(), 0)); + std::ignore = acc.listen(); + tcp_socket peer(ioc); + std::ignore = co_await acc.accept(peer); + resumed = true; + }; + capy::run_async(ioc.get_executor())(keeper()); + // One handler carries the coroutine to the parked accept. + std::ignore = ioc.run_one(); + } + BOOST_TEST(!resumed); + } + + // A read queued behind a worker that is released only once teardown + // has begun. The pool has to join before the scheduler drains, or + // the completion the worker posts on its way out is neither run nor + // destroyed and the operation's keepalive leaks. + // + // The task is started by hand and owned by the test, so the frame + // the library abandons at teardown is destroyed here rather than + // leaked: what LeakSanitizer sees left over is the defect alone. + void testDestroyWithPoolWorkQueued() + { +#if BOOST_COROSIO_HAS_IO_URING + // io_uring reads through the ring, never through the pool. + if constexpr (std::is_same_v< + std::remove_const_t, io_uring_t>) + return; +#endif + temp_file tmp("sf_pool_teardown_", "hello world"); + auto const path = tmp.path; + bool resumed = false; + test::pool_blocker blocker; + std::optional ex; + std::optional env; + std::optional> parked; + { + io_context ioc(Backend); + BOOST_TEST(test::park_pool_worker(ioc, blocker)); + + stream_file f(ioc); + std::ignore = f.open(path, file_base::read_only); + auto reader = [&]() -> capy::task<> { + char buf[16]; + std::ignore = co_await f.read_some( + capy::mutable_buffer(buf, sizeof(buf))); + resumed = true; + }; + + ex.emplace(ioc.get_executor()); + env.emplace(capy::io_env{*ex, std::stop_token{}, nullptr}); + parked.emplace(reader()); + parked->await_suspend(std::noop_coroutine(), &*env).resume(); + } + BOOST_TEST(!resumed); + } + void run() { testConstruction(); @@ -983,6 +1067,16 @@ struct stream_file_test testAssignOverOpenAdopts(); testSeekNegative(); testCancelWithStoppedToken(); + +#if BOOST_COROSIO_POSIX + // POSIX file work runs on the pool; IOCP uses overlapped I/O. + testDestroyWithPoolWorkQueued(); +#endif + +#if !COROSIO_TEST_HAS_ASAN + // Abandon parked coroutine frames by design; see context.hpp. + testDestroyWithLiveFile(); +#endif } // Cancellation diff --git a/test/unit/tcp_acceptor.cpp b/test/unit/tcp_acceptor.cpp index eedbcb915..a5fa75d53 100644 --- a/test/unit/tcp_acceptor.cpp +++ b/test/unit/tcp_acceptor.cpp @@ -303,6 +303,104 @@ struct tcp_acceptor_test acc.close(); } + // Destroy the io_context with an accept completion already queued. + // Two listeners each have a connection waiting before the accepts + // are posted, so the one handler the loop dispatches leaves the + // other completion for the scheduler's shutdown to drain. + void testDestroyWithQueuedAccept() + { + int resumed = 0; + int before_destroy = 0; + { + io_context ioc(Backend); + auto ex = ioc.get_executor(); + + tcp_acceptor acc1(ioc), acc2(ioc); + endpoint eps[2]; + tcp_acceptor* accs[2] = {&acc1, &acc2}; + for (int i = 0; i < 2; ++i) + { + BOOST_TEST(!accs[i]->open()); + accs[i]->set_option(socket_option::reuse_address(true)); + BOOST_TEST( + !accs[i]->bind(endpoint(ipv4_address::loopback(), 0))); + BOOST_TEST(!accs[i]->listen()); + eps[i] = endpoint( + ipv4_address::loopback(), accs[i]->local_endpoint().port()); + } + + tcp_socket c1(ioc), c2(ioc), s1(ioc), s2(ioc); + auto connect_both = [&]() -> capy::task<> { + std::ignore = co_await c1.connect(eps[0]); + std::ignore = co_await c2.connect(eps[1]); + }; + capy::run_async(ex)(connect_both()); + ioc.run(); + ioc.restart(); + + auto accepter = [&](tcp_acceptor& a, + tcp_socket& s) -> capy::task<> { + std::ignore = co_await a.accept(s); + ++resumed; + }; + capy::run_async(ex)(accepter(acc1, s1)); + capy::run_async(ex)(accepter(acc2, s2)); + std::ignore = ioc.run_one(); + std::ignore = ioc.run_one(); + std::ignore = ioc.run_one(); + before_destroy = resumed; + } + BOOST_TEST_EQ(resumed, before_destroy); + } + + // Destroy the io_context with an acceptor-wait completion already + // queued. The acceptor wait has a handler of its own, distinct from + // the accept's, and only its shutdown arm is reached this way. + void testDestroyWithQueuedAcceptorWait() + { + int resumed = 0; + int before_destroy = 0; + { + io_context ioc(Backend); + auto ex = ioc.get_executor(); + + tcp_acceptor acc1(ioc), acc2(ioc); + endpoint eps[2]; + tcp_acceptor* accs[2] = {&acc1, &acc2}; + for (int i = 0; i < 2; ++i) + { + BOOST_TEST(!accs[i]->open()); + accs[i]->set_option(socket_option::reuse_address(true)); + BOOST_TEST( + !accs[i]->bind(endpoint(ipv4_address::loopback(), 0))); + BOOST_TEST(!accs[i]->listen()); + eps[i] = endpoint( + ipv4_address::loopback(), accs[i]->local_endpoint().port()); + } + + tcp_socket c1(ioc), c2(ioc); + auto connect_both = [&]() -> capy::task<> { + std::ignore = co_await c1.connect(eps[0]); + std::ignore = co_await c2.connect(eps[1]); + }; + capy::run_async(ex)(connect_both()); + ioc.run(); + ioc.restart(); + + auto waiter = [&](tcp_acceptor& a) -> capy::task<> { + std::ignore = co_await a.wait(wait_type::read); + ++resumed; + }; + capy::run_async(ex)(waiter(acc1)); + capy::run_async(ex)(waiter(acc2)); + std::ignore = ioc.run_one(); + std::ignore = ioc.run_one(); + std::ignore = ioc.run_one(); + before_destroy = resumed; + } + BOOST_TEST_EQ(resumed, before_destroy); + } + // Destroy the io_context with a read still parked on a connected // socket; service shutdown must drain the abandoned operation // without resuming it. @@ -1742,6 +1840,8 @@ struct tcp_acceptor_test // Abandon parked coroutine frames by design; see context.hpp. testDestroyWithParkedAccept(); testDestroyWithParkedRead(); + testDestroyWithQueuedAccept(); + testDestroyWithQueuedAcceptorWait(); #endif // IPv6 diff --git a/test/unit/tcp_socket.cpp b/test/unit/tcp_socket.cpp index 6735c8b89..ab956f8a5 100644 --- a/test/unit/tcp_socket.cpp +++ b/test/unit/tcp_socket.cpp @@ -35,6 +35,7 @@ #include #include #include +#include #if BOOST_COROSIO_POSIX #include // getpid() @@ -895,6 +896,94 @@ struct tcp_socket_test s2.close(); } + // An error wait must not cost the context its readiness + // machinery. What a platform makes of the reset itself differs -- + // select reports no exceptional condition for one -- but a wait + // registered afterwards has to be answered either way. + void testWaitForErrorThenWait() + { + io_context ioc(Backend); + auto ex = ioc.get_executor(); + // The default pair lingers zero, so close() sends RST and + // leaves an error condition behind on the survivor. + auto [s1, s2] = test::make_socket_pair(ioc); + auto [s3, s4] = test::make_socket_pair(ioc); + + std::error_code wait_ec; + bool wait_done = false; + + auto waiter = [&]() -> capy::task<> { + auto [ec] = co_await s1.wait(wait_type::error); + wait_ec = ec; + wait_done = true; + }; + auto closer = [&]() -> capy::task<> { + s2.close(); + // Bound the wait: a backend that does not report a reset + // as an error condition would park it for good. Stepped, + // so a backend that reports it pays one step and not the + // whole bound. + for(int i = 0; i < 20 && !wait_done; ++i) + { + std::ignore = co_await corosio::delay( + std::chrono::milliseconds(10)); + } + if(!wait_done) + s1.cancel(); + }; + + capy::run_async(ex)(waiter()); + capy::run_async(ex)(closer()); + ioc.run(); + BOOST_TEST(wait_done); +#if BOOST_COROSIO_HAS_SELECT + // select's exceptional set carries out-of-band data and not a + // reset, so on that backend the bound above is what ends the + // wait and there is nothing to insist on. + constexpr bool is_select = std::is_same_v< + std::remove_const_t, select_t>; +#else + constexpr bool is_select = false; +#endif +#if BOOST_COROSIO_HAS_IO_URING + constexpr bool is_uring = std::is_same_v< + std::remove_const_t, io_uring_t>; +#else + constexpr bool is_uring = false; +#endif + if constexpr (!is_select) + { + // Never the cancel: that would say the reset reached + // nothing and the bound is what ended the wait. + BOOST_TEST(wait_ec != capy::cond::canceled); + // Whether the completion also names the error is the + // backend's own: the reactors read it out of SO_ERROR and + // IOCP substitutes an abort where that reads zero, while + // io_uring reports the poll's readiness with no error. + if constexpr (!is_uring) + BOOST_TEST(wait_ec); + } + + // The wait above is what the readiness machinery had to + // survive; this one is what says it did. + ioc.restart(); + std::error_code write_ec; + bool write_done = false; + auto writer = [&]() -> capy::task<> { + auto [ec] = co_await s3.wait(wait_type::write); + write_ec = ec; + write_done = true; + }; + capy::run_async(ex)(writer()); + ioc.run(); + BOOST_TEST(write_done); + BOOST_TEST(!write_ec); + + s1.close(); + s3.close(); + s4.close(); + } + // Composed Operations void testReadFull() @@ -1710,6 +1799,99 @@ struct tcp_socket_test s2.close(); } + // Destroy the io_context with a write completion already queued. + // The loop dispatches one handler, so any completion that arrived + // with it is left for the scheduler's shutdown to drain. + void testDestroyWithQueuedWrite() + { + int resumed = 0; + int before_destroy = 0; + { + io_context ioc(Backend); + auto ex = ioc.get_executor(); + auto [a1, b1] = + test::make_socket_pair(ioc); + auto [a2, b2] = + test::make_socket_pair(ioc); + + auto writer = [&](tcp_socket& s) -> capy::task<> { + std::ignore = + co_await s.write_some(capy::const_buffer("x", 1)); + ++resumed; + }; + capy::run_async(ex)(writer(a1)); + capy::run_async(ex)(writer(a2)); + std::ignore = ioc.run_one(); + std::ignore = ioc.run_one(); + std::ignore = ioc.run_one(); + before_destroy = resumed; + } + BOOST_TEST_EQ(resumed, before_destroy); + } + + // The connect twin: two handshakes finish before the loop + // dispatches, so one of them is still queued at destruction. + void testDestroyWithQueuedConnect() + { + int resumed = 0; + int before_destroy = 0; + { + io_context ioc(Backend); + auto ex = ioc.get_executor(); + tcp_acceptor acc(ioc); + BOOST_TEST(!acc.open()); + acc.set_option(socket_option::reuse_address(true)); + BOOST_TEST(!acc.bind(endpoint(ipv4_address::loopback(), 0))); + BOOST_TEST(!acc.listen()); + auto const ep = endpoint( + ipv4_address::loopback(), acc.local_endpoint().port()); + + tcp_socket c1(ioc), c2(ioc); + auto client = [&](tcp_socket& s) -> capy::task<> { + std::ignore = co_await s.connect(ep); + ++resumed; + }; + capy::run_async(ex)(client(c1)); + capy::run_async(ex)(client(c2)); + std::ignore = ioc.run_one(); + std::ignore = ioc.run_one(); + std::ignore = ioc.run_one(); + before_destroy = resumed; + } + BOOST_TEST_EQ(resumed, before_destroy); + } + + // The wait twin: both sockets are readable before the loop runs. + void testDestroyWithQueuedWait() + { + int resumed = 0; + int before_destroy = 0; + { + io_context ioc(Backend); + auto ex = ioc.get_executor(); + auto [a1, b1] = + test::make_socket_pair(ioc); + auto [a2, b2] = + test::make_socket_pair(ioc); + + auto waiter = [&](tcp_socket& s) -> capy::task<> { + std::ignore = co_await s.wait(wait_type::read); + ++resumed; + }; + capy::run_async(ex)(waiter(a1)); + capy::run_async(ex)(waiter(a2)); + std::ignore = ioc.run_one(); + std::ignore = ioc.run_one(); + + BOOST_TEST(::send(b1.native_handle(), "x", 1, 0) == 1); + BOOST_TEST(::send(b2.native_handle(), "x", 1, 0) == 1); + + std::ignore = ioc.run_one(); + before_destroy = resumed; + } + BOOST_TEST_EQ(resumed, before_destroy); + } + void run() { testConstruction(); @@ -1753,6 +1935,7 @@ struct tcp_socket_test testCancelRead(); testCloseWhileReading(); testStopTokenCancellation(); + testWaitForErrorThenWait(); // Socket options testNoDelay(); @@ -1806,6 +1989,13 @@ struct tcp_socket_test testRelease(); testReleaseClosedThrows(); testAssignV6(); + +#if !COROSIO_TEST_HAS_ASAN + // Abandon parked coroutine frames by design; see context.hpp. + testDestroyWithQueuedWrite(); + testDestroyWithQueuedConnect(); + testDestroyWithQueuedWait(); +#endif } void testConnectV6() diff --git a/test/unit/thread_pool.cpp b/test/unit/thread_pool.cpp index 092819d84..aabe675b1 100644 --- a/test/unit/thread_pool.cpp +++ b/test/unit/thread_pool.cpp @@ -15,10 +15,52 @@ #include #include +#if defined(__linux__) +#include +#include +#include +#endif + #include "test_suite.hpp" namespace boost::corosio { +#if defined(__linux__) + +// Count this process's live pool workers by the name each one gives +// itself before it can run anything, so what is counted is the +// spawning and not the timing of it. +inline int +pool_thread_count() +{ + auto* d = ::opendir("/proc/self/task"); + if (!d) + return -1; + int n = 0; + while (auto* e = ::readdir(d)) + { + if (e->d_name[0] == '.') + continue; + // Sized for the longest name the directory entry can carry, + // so the path is never truncated. + char path[sizeof("/proc/self/task//comm") + sizeof(e->d_name)]; + std::snprintf( + path, sizeof(path), "/proc/self/task/%s/comm", e->d_name); + auto* f = std::fopen(path, "r"); + if (!f) + continue; + char name[32] = {}; + if (std::fgets(name, sizeof(name), f) && + std::strncmp(name, "tpool-svc-", 10) == 0) + ++n; + std::fclose(f); + } + ::closedir(d); + return n; +} + +#endif + struct test_work : detail::pool_work_item { std::atomic* counter = nullptr; @@ -45,7 +87,7 @@ struct thread_pool_test { items[i].counter = &counter; items[i].func_ = &test_work::execute; - BOOST_TEST(pool.post(&items[i])); + BOOST_TEST(!pool.post(&items[i])); } // Shutdown should drain all queued tasks @@ -73,7 +115,7 @@ struct thread_pool_test flag_work fw; fw.flag = &ran; fw.func_ = &flag_work::execute; - pool.post(&fw); + BOOST_TEST(!pool.post(&fw)); // Give it a moment to process while (!ran.load()) @@ -90,12 +132,14 @@ struct thread_pool_test pool.shutdown(); - // post() must return false after shutdown + // A shutdown pool answers a post with the cancellation its + // callers report, and starts no thread doing it. test_work tw; std::atomic counter{0}; tw.counter = &counter; tw.func_ = &test_work::execute; - BOOST_TEST(!pool.post(&tw)); + BOOST_TEST(pool.post(&tw) == capy::error::canceled); + BOOST_TEST(pool.worker_count() == 0); BOOST_TEST(counter.load() == 0); // Second shutdown must not hang @@ -123,13 +167,17 @@ struct thread_pool_test std::atomic* arrived; unsigned expected; std::atomic* done; + std::atomic* give_up; static void execute(detail::pool_work_item* p) noexcept { auto* self = static_cast(p); self->arrived->fetch_add(1); - // Spin until all threads have arrived - while (self->arrived->load() < self->expected) + // Spin until all threads have arrived, or until the + // test says the arrival it is waiting for is one the + // pool never started a worker for. + while (self->arrived->load() < self->expected && + !self->give_up->load()) std::this_thread::yield(); self->done->fetch_add(1); } @@ -137,14 +185,31 @@ struct thread_pool_test std::atomic arrived{0}; std::atomic done{0}; + std::atomic give_up{false}; barrier_work items[num_threads]; for (unsigned i = 0; i < num_threads; ++i) { items[i].arrived = &arrived; items[i].expected = num_threads; items[i].done = &done; + items[i].give_up = &give_up; items[i].func_ = &barrier_work::execute; - pool.post(&items[i]); + BOOST_TEST(!pool.post(&items[i])); + + // The first post starts the whole set. The barrier below + // waits for every one of them, so a short start has to + // fail the test rather than hang it. + if (i == 0) + { + bool const full = pool.worker_count() == num_threads; + BOOST_TEST(full); + if (!full) + { + give_up.store(true); + pool.shutdown(); + return; + } + } } pool.shutdown(); @@ -153,6 +218,44 @@ struct thread_pool_test BOOST_TEST(done.load() == static_cast(num_threads)); } + void testLazyWorkers() + { +#if defined(__linux__) + // A worker joined by an earlier suite can outlive its join in + // /proc for a moment. A count that starts at zero says the + // moment has passed, and from there only this context can add + // to it. + bool const countable = pool_thread_count() == 0; +#endif + io_context ioc; + auto* pool = ioc.find_service(); + + // The context creates the service, not its workers. + BOOST_TEST(pool != nullptr); + BOOST_TEST(pool->worker_count() == 0); +#if defined(__linux__) + // The counted skip keeps a dirty environment from passing as + // a proof: what stands down is the check, visibly. + BOOST_TEST(!countable || pool_thread_count() == 0); +#endif + + std::atomic counter{0}; + test_work w; + w.counter = &counter; + w.func_ = &test_work::execute; + BOOST_TEST(!pool->post(&w)); + BOOST_TEST(pool->worker_count() == 1); + while (counter.load() == 0) + std::this_thread::yield(); + +#if defined(__linux__) + // The worker that ran the item is still parked on the queue. + BOOST_TEST(!countable || pool_thread_count() == 1); +#endif + pool->shutdown(); + BOOST_TEST(pool->worker_count() == 0); + } + void run() { testDrainOnShutdown(); @@ -160,6 +263,7 @@ struct thread_pool_test testPostAfterShutdown(); testZeroThreads(); testMultipleThreads(); + testLazyWorkers(); } }; diff --git a/test/unit/udp_socket.cpp b/test/unit/udp_socket.cpp index a725ed7e9..691ac1e8c 100644 --- a/test/unit/udp_socket.cpp +++ b/test/unit/udp_socket.cpp @@ -1650,6 +1650,54 @@ struct udp_socket_test BOOST_TEST(done); } + // Destroy the io_context with datagram completions already queued. + // Both receives are parked before either datagram is sent, so the + // one handler the loop dispatches leaves the rest of that batch + // for the scheduler's shutdown to drain. + void testDestroyWithQueuedDatagrams() + { + int resumed = 0; + int before_destroy = 0; + { + io_context ioc(Backend); + auto ex = ioc.get_executor(); + udp_socket r1(ioc), r2(ioc), s1(ioc), s2(ioc); + BOOST_TEST(!r1.open()); + BOOST_TEST(!r2.open()); + BOOST_TEST(!s1.open()); + BOOST_TEST(!s2.open()); + BOOST_TEST(!r1.bind(endpoint(ipv4_address::loopback(), 0))); + BOOST_TEST(!r2.bind(endpoint(ipv4_address::loopback(), 0))); + + auto const ep1 = r1.local_endpoint(); + auto const ep2 = r2.local_endpoint(); + + char b1[8], b2[8]; + endpoint from1, from2; + auto reader = [&](udp_socket& s, char* p, + endpoint& from) -> capy::task<> { + std::ignore = co_await s.recv_from( + capy::mutable_buffer(p, 8), from); + ++resumed; + }; + auto writer = [&](udp_socket& s, endpoint dest) -> capy::task<> { + std::ignore = + co_await s.send_to(capy::const_buffer("x", 1), dest); + ++resumed; + }; + capy::run_async(ex)(reader(r1, b1, from1)); + capy::run_async(ex)(reader(r2, b2, from2)); + capy::run_async(ex)(writer(s1, ep1)); + capy::run_async(ex)(writer(s2, ep2)); + // Four handlers start the four coroutines; the fifth + // delivers one completion out of the batch they produce. + for (int i = 0; i < 5; ++i) + std::ignore = ioc.run_one(); + before_destroy = resumed; + } + BOOST_TEST_EQ(resumed, before_destroy); + } + void run() { testConstruction(); @@ -1703,6 +1751,11 @@ struct udp_socket_test testRelease(); testReleaseClosedThrows(); testAssignV6(); + +#if !COROSIO_TEST_HAS_ASAN + // Abandon parked coroutine frames by design; see context.hpp. + testDestroyWithQueuedDatagrams(); +#endif } }; diff --git a/test/unit/wait.cpp b/test/unit/wait.cpp index 554209642..b16b6c029 100644 --- a/test/unit/wait.cpp +++ b/test/unit/wait.cpp @@ -935,6 +935,54 @@ struct wait_test local_endpoint(tmp.path())); } +#if BOOST_COROSIO_POSIX + // TCP urgent (out-of-band) data is a readiness condition, not a + // fault: a read on an otherwise-healthy socket that also carries an + // urgent byte must return the normal data, never io_error. select + // maps the urgent byte onto except_fds -> reactor_event_error and, + // with SO_ERROR still zero, synthesizes EIO; epoll/kqueue/io_uring do + // not treat OOB as an error. + void testOobDoesNotFaultRead() + { + io_context ioc(Backend); + auto ex = ioc.get_executor(); + auto [s1, s2] = test::make_socket_pair(ioc); + + std::error_code read_ec; + std::size_t bytes_read = 0; + bool read_done = false; + std::array buf{}; + + auto reader = [&]() -> capy::task<> { + auto [ec, n] = co_await s1.read_some( + capy::mutable_buffer(buf.data(), buf.size())); + read_ec = ec; + bytes_read = n; + read_done = true; + }; + // Spawn order is park order: the read parks before the writer's + // urgent byte trips the exceptional condition and the normal + // byte satisfies the read. + auto writer = [&]() -> capy::task<> { + int fd = static_cast(s2.native_handle()); + char urg = '!'; + ::send(fd, &urg, 1, MSG_OOB); + char normal = 'x'; + ::send(fd, &normal, 1, 0); + co_return; + }; + + capy::run_async(ex)(reader()); + capy::run_async(ex)(writer()); + ioc.run(); + + BOOST_TEST(read_done); + BOOST_TEST(read_ec != std::errc::io_error); + BOOST_TEST(!read_ec); + BOOST_TEST_EQ(bytes_read, 1u); + } +#endif + void run() { testWaitReadAndNoConsume(); @@ -944,6 +992,9 @@ struct wait_test testWaitWriteCancelDoesNotLeak(); testAcceptorWait(); testAcceptorErrorWaitCancel(); +#if BOOST_COROSIO_POSIX + testOobDoesNotFaultRead(); +#endif testWaitOnLocalStream(); testWaitOnUdp(); testWaitReadAfterShortRead(); @@ -1045,4 +1096,61 @@ struct wait_closed_test COROSIO_BACKEND_TESTS(wait_closed_test, "boost.corosio.wait_closed") +// A faulted socket's wait(error) must name why it faulted: the delivered +// error_code must be a real, non-empty code (SO_ERROR, e.g. +// connection_reset), never an empty error_code — which is +// indistinguishable from a benign readiness signal — and never the +// canceled condition. +// +// Scoped to epoll (the control, which reads SO_ERROR and names the code) +// and io_uring (which completes the POLL_ADD with res>=0 and therefore an +// empty error_code — the bug). Not run on select, where a peer RST does +// not set except_fds and the error wait would never fire, nor on kqueue, +// which is not exercised on this host. +#if BOOST_COROSIO_HAS_EPOLL && BOOST_COROSIO_HAS_IO_URING +struct error_wait_names_reset_test +{ + template + void check() + { + io_context ioc(Backend); + auto ex = ioc.get_executor(); + // Both ends linger with a zero timeout, so closing the peer + // sends an RST rather than a graceful FIN. + auto [s1, s2] = test::make_socket_pair(ioc); + + std::error_code wait_ec; + bool wait_done = false; + + auto waiter = [&]() -> capy::task<> { + auto [ec] = co_await s1.wait(wait_type::error); + wait_ec = ec; + wait_done = true; + }; + // Spawn order is park order: the error wait is outstanding + // before the peer's RST reaches the socket. + auto resetter = [&]() -> capy::task<> { + s2.close(); + co_return; + }; + + capy::run_async(ex)(waiter()); + capy::run_async(ex)(resetter()); + ioc.run(); + + BOOST_TEST(wait_done); + BOOST_TEST(wait_ec); + BOOST_TEST(wait_ec != capy::cond::canceled); + } + + void run() + { + check(); // control: names the code, passes + check(); // bug D1: empty error_code, fails + } +}; + +TEST_SUITE(error_wait_names_reset_test, "boost.corosio.wait_error_reset"); +#endif + } // namespace boost::corosio