Skip to content

core-cpp 0.2.0

Choose a tag to compare

@github-actions github-actions released this 24 Sep 03:19
· 159 commits to master since this release

Patch-level fixes and the API fastcached needed to move onto core-cpp, found by migrating it: the MSVC ARM64 exception-table defect (fastcached#1546), port sharing, socket options on accepted sockets, adoptSocket, static-CRT variants, a closed-waiter idle bug, and per-request parity with fastcached's own reactor. Minor version because of one Breaking entry and new API.

Breaking

  • The await_ready of seven public awaiters answers a constexpr constant false:
    core::net::DelayAwaiter, the awaiter whenAll and whenAny return, AsyncQueue<T>::PopAwaiter,
    and the four TuiRuntime awaiters (which also became noexcept). It is the fix for
    fastcached#1546 under Fixed: the decision it made is await_suspend's now. (Task's two
    awaiters answer as before -- true for a task owning no frame or already finished -- but from a
    member their constructor sets, and are not on this list.) A co_await behaves as before; code that called await_ready()
    directly to learn whether an await would park gets false where it got true -- for instance
    sleepUntil(nullptr, t).await_ready() -- and core-cpp's own SleepUntil_test.cpp and
    AsyncQueue_test.cpp asserted exactly that. It stays a const member rather than a static
    one, which clang-tidy's readability-static-accessed-through-instance would report at every
    co_await in a caller's code. ResultAwaitable::await_ready still answers whether the operation
    settled inline.

    Migration: co_await the awaiter, and never call await_ready() on it directly; it is the
    compiler's half of the protocol, not a question a caller can ask. A test that asserted an await
    resolves without parking asserts it through the flow instead: that it has finished after one
    turn of the loop, or that its continuation ran exactly once. fastcached's SleepUntil_test.cpp
    asserts true on its own copy, and changes with the migration.

Fixed

  • EventLoop::runUntilIdle and testing::TestLoop::drain no longer return while the waiters of
    a closed handle are still queued.
    notifyHandleClosing records the parks on a closing handle
    and the next turn queues their waiters after its wait; that turn still reported itself idle,
    because none of its counters counted them, so a drain returned with a closed listener's pending
    accept -- or any flow parked through waitReadable/waitWritable -- woken and not yet run. A
    caller that tore its objects down next left ~EventLoop to resume or free those frames after
    their owners were gone: fastcached's server teardown reported it as a heap-use-after-free under
    ASan and TSan. A turn is now idle only if it also leaves the ready queue empty, which covers
    every step that queues work rather than the four that had a counter. ClosedParkIdle_test.cpp
    closes a listener under a parked accept and asks one runUntilIdle to finish it.

  • An OperationCancelled thrown out of a co_await on delay or sleepUntil, and on twelve
    other awaiters, is caught again under MSVC 19.44 on ARM64

    (fastcached#1546). That compiler's
    ARM64 code generator drops the enclosing try of a co_await on a temporary awaiter whose
    await_ready makes a call, so the exception passed a typed catch and catch (...) alike and
    reached whatever awaited the flow. The new windows (cl-release-arm64) leg then found the same
    loss with no call at all: an await_suspend that returned the awaiting coroutine's own handle,
    followed by an await_resume that threw -- Task's awaiter over a task owning no frame, whose
    std::logic_error passed the awaiting coroutine's catch. So Task's two awaiters decide in
    their constructor, where the awaiter is made by the co_await, and await_ready reads the
    answer. ResultAwaitable, every socket operation's awaitable, transfers back into an
    OperationCancelled for a flow already stopped too; on the same leg it keeps its handler, which
    CancelRead_test.cpp now holds. core::net::DelayAwaiter::await_ready read the clock through
    the virtual IClock::now(). The 0.1.0 notes say this awaiter carried fastcached's fix when
    TuiRuntime moved onto it; it did not, and every delay and sleepUntil had the shape. Every
    await_ready in src/core now reads a member or answers a constant, and the decision it made is
    the constructor's or await_suspend's, asked before the flow's stop token is read, so what a co_await observes is
    unchanged (a direct call of await_ready() is not; see Breaking): an elapsed deadline, a null loop, a finished task, an empty whenAll, a queued item,
    a free TLS gate, a finished lookup and a buffered input event or agent message all resume without
    parking, and a flow that is already stopped still resumes normally on each of them. The awaiters:
    DelayAwaiter, interruptibleSleepUntil's, Task<T>::Awaiter and Task<void>::Awaiter,
    whenAll's and whenAny's, AsyncQueue::PopAwaiter, ResultAwaitable, the threaded resolver's
    and the TLS serial gate's, and TuiRuntime's NextInputEventAwaiter, NextEventForAwaiter,
    NextActivityAwaiter and NextAgentReadyAwaiter.

  • A socket a listener accepts carries TCP_NODELAY, as a dialled one always did. Only the
    dial paths set it (posix/DialPrimitives.cpp, windows/DialPrimitives.cpp, and through them the
    completion-port dial); accept4/accept on POSIX, the WFMO listener's accept and the IOCP
    listener's AcceptEx handed out sockets with close-on-exec and nothing else, so a server's small
    replies waited on Nagle for the client's delayed ACK. Every dial and every accept on every
    platform now goes through one helper, detail::applyStreamSocketOptions, which sets close-on-exec
    (a non-inheritable handle on Windows), TCP_NODELAY, and keepalive when a dial asks for it; the
    buffer sizes below are detail::applySocketBufferSizes's, before the connection exists. WindowsSocket::native() joins PosixSocket::native() and
    IocpSocket::native(), for diagnostics and tests.

  • The WFMO backend's TCP listener claims its port exclusively. It bound with SO_REUSEADDR,
    which on Windows lets a second socket bind a port a live listener serves and take its
    connections, where the IOCP listener has always used SO_EXCLUSIVEADDRUSE. It uses that too
    now, and fails the bind if the option is refused. BackendKind::Wfmo is not the Windows
    default, so only a caller that asked for it by name was exposed.

  • testing::TestLoop::pendingSubmissions() and pendingTimers() count what was handed over
    between turns.
    A submit or schedule from a thread that is not the loop's worker -- the
    case's own thread outside a turn included -- goes to the inbound queue until turn step 1, and the
    two counters read only the ready queue and the park table, so a case that submitted and then
    counted read 0 (at least 12 of fastcached's cases, on migration). They now add the inbound
    submissions and scheduled deadlines, read under the inbound lock through two new
    EventLoop accessors, inboundSubmissionCount() and inboundScheduledCount() --
    cancelPending already searched that queue, so the answer no longer depends on which thread
    submitted. Both counters lost noexcept: taking the lock can throw.

Changed

  • A PosixSocket keeps one backend registration for its life, not one per parked operation.
    Every read or write that parked attached a registration, armed it and detached it again -- on
    epoll an EPOLL_CTL_ADD and an EPOLL_CTL_DEL per request -- and fastcached's GET benchmark ran
    6.8% slower on EventLoop than on its own epoll reactor (geomean of 24 scenarios, -22% at the
    worst). The socket's parks now ask for RegistrationLifetime::UntilClosed: the loop registers the
    descriptor the first time it is parked on, a park takes a slot on that registration and changes
    what it is armed for only when it must, and close() ends it by announcing the close, as it
    already did. Readability stays armed after a read completes, so the steady state of a
    request/response connection costs no epoll_ctl at all; writability is dropped as soon as the
    write is taken, and a readiness report nobody is parked to take narrows the registration after
    that wait. On a loopback echo over epoll (WSL2, clang-22 Release, median of 5) the server's CPU
    per request fell from 9.3-10.5 us to 5.1 us, and its throughput rose from 96-106k to 193-195k
    requests a second at 16, 64 and 256 connections; a raw epoll echo, the floor, is 4.2 us. The
    turn is unchanged. SocketRegistration_test.cpp counts the backend calls, and fails with the
    per-park registration.
  • An operation parked on a socket allocates nothing in EventLoop, and a turn nobody handed
    work to takes no lock.
    Each park was a fresh allocation, filed in a std::unordered_map and,
    whatever its registration, in a second map by handle: three allocations and three frees per
    parked operation. Parks are now recycled and kept in an open-addressing table, and a park on a
    socket's lifetime registration is found through that registration rather than the handle map. A
    turn skips the inbound mutex when nothing was posted and the timer heap when no deadline is armed,
    stop() sets an atomic, ResultAwaitable registers no stop callback on a token that can never
    be stopped, a queued entry no longer moves an empty work item through a temporary, and
    EpollBackend no longer zeroes a 768-byte event array on every wait. On fastcached's GET at 16
    connections (WSL2, clang-22 Release, median of 5, the daemon's CPU per request), memcached text
    went from 24.1 to 23.4 us and RESP from 26.9 to 25.0 us, against 23.7 and 25.7 us on fastcached's
    own epoll reactor, and allocations per request from 10.1 to 7.1 and from 26.1 to 23.1.

Added

  • CORE_CPP_MSVC_STATIC_RUNTIME_VARIANTS (OFF): with an MSVC-ABI compiler, every compiled module
    but core::testing_main (whose Catch2 and dialog suppression are built /MD, and whose target
    is edited after it is declared) also gets a static-CRT twin, core::<name>_mt (target core-cpp-<name>-mt), built /MT or
    /MTd and linking the twins of the modules it links (header-only modules are shared as they
    are). The MSVC linker refuses to mix C runtimes (LNK2038 ... 'RuntimeLibrary', or lld-link's
    /failifmismatch), so one build of core-cpp could not serve both fastcached's /MD daemon and
    its /MT launcher, fastcache-cc; with the option on it can. The twins are declared by
    core_cpp_add_module from the module table, so they follow it; they join CORE_CPP_TARGETS,
    and are EXCLUDE_FROM_ALL, so a parent builds only those it links. Other compilers ignore the
    option with one status line. Two tests pin it on Windows: a /MT program linking core::net
    and core::log fails to link, naming RuntimeLibrary, and the same program linking
    core::net_mt and core::log_mt links and runs a loopback echo.
  • RegistrationLifetime (<core/net/detail/ParkTable.hpp>, through <core/net/EventLoop.hpp>),
    and a lifetime field of it on ParkEntry, which ParkEntry::onReadyCallback takes as a new
    trailing parameter with a default. PerPark, the default, is the registration every park had.
    UntilClosed shares one registration per handle among every park on it that asks, kept until
    EventLoop::notifyHandleClosing names the handle -- so a caller that asks for it promises to
    announce every close, which is what PosixSocket does and what it now asks for.
  • testing::ScriptedBackend::pushReadiness(HandlerId, Readiness): a scripted wait that reports
    several conditions at once, as one kernel answer does (EPOLLIN|EPOLLOUT), so a case can reach
    the choice a backend makes between two watched directions reported in the same wait.
  • adoptSocket(EventLoop&, platform::NativeHandle, std::string peerAddress), beside
    adoptFd and adoptListener in <core/net/Sockets.hpp>: a connected socket accepted or
    dialled outside core-cpp, driven by the loop the caller chooses. It is what a Windows server
    needs to spread connections over several loops -- one thread accepts and deals each handle out,
    since Windows has no SO_REUSEPORT and a completion-port association is one socket to one port
    -- and adoptFd answers Unsupported there. It builds the socket the loop's backend drives
    (IocpSocket where the loop lends a completion port, WindowsSocket under WFMO, PosixSocket
    on POSIX), takes ownership of the handle on every path and closes it when adoption fails --
    allocating the wrapper throwing included (the opposite of adoptListener, which leaves a refused
    handle with its caller), changes no socket
    option beyond what the transport needs to run (non-blocking mode on POSIX), and asserts it is
    called on the loop's thread. adoptFd is unchanged. Under WFMO, a readiness event that cannot
    be created or associated is an error value, through the new WindowsSocket::adopt; the
    WindowsSocket constructor, which connect and the WFMO listener still use, can only carry on
    with a socket that never becomes ready.
  • SocketBufferSizes (<core/net/SocketBuffers.hpp>), and a buffers field of it on both
    ListenOptions and DialOptions: the kernel send and receive buffers (SO_SNDBUF,
    SO_RCVBUF) of every socket a listener accepts, and of one dialled socket. Each size is a
    std::optional<std::size_t>, and an unset one leaves the kernel's value untouched, which is the
    default. fastcached sizes both to 1 MiB so that a large reply leaves in one sendmsg. The sizes
    are asked for before the connection exists -- of the listening socket before listen, which its
    accepted sockets inherit (an AcceptEx socket included), and of a dialled socket before
    connect -- because the TCP window scale is announced in the handshake and tcp(7) asks for them
    first. A size is a request: Linux reports twice what was set and caps an
    unprivileged request at net.core.wmem_max/rmem_max. PosixListener::bind takes two new
    trailing parameters with defaults, PortSharing sharing (below) and then the sizes;
    WindowsListener::bind and IocpListener::bind take the sizes; and PosixListener,
    WindowsListener and IocpListener gain native(), as the sockets have. The internal
    detail::DialStep, detail::dialReadiness and detail::dialCompletion take a
    detail::StreamSocketOptions where they took a KeepAlive.
  • ListenOptions::sharing, a PortSharing defaulting to PortSharing::Exclusive. With
    PortSharing::Shared, several listeners may bind one port; fastcached's daemon binds one per loop
    by default, and without it the second loop's bind failed with AddressInUse. Whether the
    connections are spread across them is the platform's: Linux spreads them (SO_REUSEPORT), and
    FreeBSD does with SO_REUSEPORT_LB, which listen() uses wherever the constant is defined. On
    macOS and the other BSDs the binds coexist and the newest listener gets every connection
    (SO_REUSEPORT), so one listener per loop leaves all but one loop idle there; accepting on one
    and handing sockets out with adoptSocket is what spreads them. On Windows a shared listener is refused with NetErrorCode::Unsupported rather
    than mapped to SO_REUSEADDR, which there lets a later socket take a held port over. It is the
    UDP sockets' existing enum, so <core/net/Sockets.hpp> now includes <core/net/UdpSocket.hpp>.
    tools/migrate/renames.json's core::net::ReusePort row points at it.
  • core-cpp.await-ready, a tree-level check with a self-test (scripts/check-await-ready.py,
    run by the style job), refusing an await_ready body under src/ or tests/ that calls a
    function or constructs an object, and an await_suspend returning a coroutine handle that
    returns the handle it was given, unless its row names the test that runs it on the ARM64 leg. It cannot see an overloaded operator; the fixed public awaiters
    also carry a static_assert(core::async::awaitReadyIsConstantFalse<A>()), new in
    <core/async/Awaitable.hpp>, which asks the question at compile time without constructing an
    awaiter (P2280), so a call there fails to compile on GCC 14, Clang 20 and MSVC 19.51 or newer;
    on older compilers it asserts nothing.
  • The cl-release-arm64 preset and the windows (cl-release-arm64) CI leg, on
    windows-11-arm: MSVC's ARM64 code generator is the only one that miscompiles the shape above,
    and no other leg can observe it. The preset expects an arm64 developer shell.

Assets. core-cpp-v0.2.0-vendor.tar.gz is the vendoring file set; SHA256SUMS covers it.