Skip to content

Releases: contour-terminal/core-cpp

core-cpp 0.5.0

Choose a tag to compare

@github-actions github-actions released this 26 Sep 09:24

Breaking

  • The WFMO backend is removed: the completion port is Windows' only backend (core-cpp#6).
    BackendKind::Wfmo, WfmoBackend and the readiness socket transport it drove
    (WindowsSocket, WindowsListener) are gone, after the release in which IOCP was the default
    and WFMO a fallback. makeDefaultBackend() on Windows no longer falls back: a completion port
    the kernel refuses to create is handle exhaustion, and it propagates. listen, listenUnix,
    adoptListener, adoptSocket and the dials refuse a loop whose backend lends no completion port
    with NetErrorCode::Unsupported, where they used to hand out a readiness socket; a dial refuses
    before it creates a socket, so nothing reaches the peer. Two defects of
    the removed transport go with it: a closed socket's parked read now answers Cancelled on every
    platform, where WindowsSocket answered BadHandle (core-cpp#46), and the WFMO-only
    destruction gap of core-cpp#50 has nothing left to apply to. connectUnix's socket on Windows is
    now made uninheritable, as every other one is (core-cpp#28).
    • Migration: no consumer names any of these; a program that did replaces
      makeBackend(BackendKind::Wfmo) with makeDefaultBackend(). BackendKind's enumerators after
      Iocp shift down by one, so a value stored or sent as an integer is re-read by name
      (toString). A Windows test double that stood in for the loop's backend and expected a
      socket from the factories needs a completion port, or drives an ISocket of its own.
  • core::platform::EnvironmentProvider is ProcessEnvironment, a core::Environment; the
    working directory has its own seam; Windows reads and writes the environment in UTF-8

    (core-cpp#7). Two seams answered "read HOME", with two doubles a mixed test had to keep
    agreeing. core::Environment is now the one read seam, and ProcessEnvironment -- what a shell
    writes -- derives from it, so code that only reads is handed the same object and
    testing::TestProcessEnvironment is the double for both. set, unset, exportVariable and
    setAndExport return std::expected<void, PlatformError>, where they returned void and dropped
    the error; a name that is empty or holds = or NUL, or a value that holds NUL, is
    PlatformError::InvalidArgument (a new enumerator, last, so no other value moves).
    changeDirectory and currentDirectory move to core::platform::WorkingDirectory
    (nativeWorkingDirectory(), testing::TestWorkingDirectory), and homeDirectory, userName
    and configHome are the free functions of <core/platform/UserPaths.hpp> over a
    core::Environment const& (userName is new there). On Windows, core::LiveEnvironment,
    core::setProcessEnvironmentVariable and unsetProcessEnvironmentVariable go through
    GetEnvironmentVariableW/SetEnvironmentVariableW, converting to and from UTF-8, where the
    code-page API mangled a value such as a user profile path outside the ANSI code page; a name or
    value that is not UTF-8 is refused (std::errc::invalid_argument), and ProcessEnvironment::keys()
    converts UTF-16 names rather than narrowing them a code unit at a time. The read seam's
    interface is unchanged.
    • Migration: <core/platform/EnvironmentProvider.hpp> is <core/platform/ProcessEnvironment.hpp>;
      EnvironmentProvider is ProcessEnvironment, nativeEnvironmentProvider() is
      nativeProcessEnvironment(), and testing::TestEnvironmentProvider is
      testing::TestProcessEnvironment (<core/platform/testing/TestProcessEnvironment.hpp>), which no
      longer takes an initial directory. Handle or discard the std::expected from every set,
      unset, exportVariable and setAndExport (std::ignore = env.set(...) where a failure is
      acceptable). Replace env.changeDirectory(p) and env.currentDirectory() with a
      WorkingDirectory& the object is given -- nativeWorkingDirectory() in a composition root,
      testing::TestWorkingDirectory(initial) with addValidPath in a test. Its
      currentDirectory() returns a std::filesystem::path, where the old member returned a UTF-8
      std::string, so the answer round-trips through changeDirectory() on Windows whatever it
      spells: a caller that wants the text takes core::platform::normalizePath(cwd.currentDirectory()),
      and one that compared with a string literal compares its generic_string(). Replace
      env.homeDirectory(), env.userName() and env.configHome() with
      core::platform::homeDirectory(env), userName(env) and configHome(env). A function that
      only reads can take a core::Environment const& and be handed either double. contour, which
      uses only core::Environment, changes nothing.
  • core::cli::parse() returns std::expected<FlagStore, ParseError> and throws nothing
    (core-cpp#13). It returned std::optional<FlagStore> -- std::nullopt for tokens left over --
    and threw core::cli::ParserError for a value of the wrong type, a missing value or an explicit
    empty one, and std::invalid_argument for a missing required option. Every one of those is now a
    ParseError: a ParseErrorKind (NotEnoughArguments, InvalidValue, EmptyValue,
    UnexpectedToken, MissingRequiredOption), the index of the token at fault and a message.
    ParserError is removed. Numbers are read whole, with std::from_chars (floating point with
    std::strtod): 12abc is refused rather than read as 12, -1 is no longer accepted -- and
    wrapped -- as an unsigned, a leading + or leading whitespace (+5, 5), which std::stoi
    and std::stoul accepted, is refused, and a value out of the type's range is refused rather
    than truncated. parse() is [[nodiscard]]: a call that discards the result no longer compiles
    under -Werror. App::run() and App::reparseParameters() print the error's message; their signatures are
    unchanged.
    • Migration: a call site that tested has_value() or used *parsed and parsed-> compiles
      as it is when its variable is auto; one that names the type spells
      std::expected<core::cli::FlagStore, core::cli::ParseError>, or auto. Replace a try/catch
      around parse() with a test of the result, and report parsed.error().message. tuidu's
      parseCommandLine() (src/tuidu/Cli.cpp) is the one consumer call site: it declares
      std::optional<core::cli::FlagStore> parsed and catches std::exception around the call.
      contour and endo use core::cli::App only, and need no change.

Added

  • core::async::TaskKind and RunTask::kind(): an around-task hook can tell a coroutine
    resumption from a posted callable
    (core-cpp#53). TaskKind::Callable is what post and
    tryPost were given; TaskKind::Resumption is a coroutine arriving through submit or
    trySubmit, as a handle or as ParkedWork, a ResumeOn hop, or a KeyedStrands reroute
    through a retired key strand. A hook -- StrandOptions::aroundTask or a KeyedAroundTask --
    can now scope per-resumption context to coroutine resumptions only, rather than installing it
    around every callable posted to the same strand or key too. Read from what the task already
    holds, so it adds nothing to a task and costs one load where a hook asks. Additive; no signature
    changes.
  • core::net::closeLingering and LingerBounds (<core/net/LingeringClose.hpp>, from
    fastcached at 0708dd54; core-cpp#35): half-close, discard what the peer is still sending until
    it closes or a bound runs out -- the whole drain's time, the bytes discarded, the reads made --
    then close, so a reply written over a request left unread is followed by a FIN rather than
    destroyed by the reset a bare close sends. HttpLimits::linger bounds it for serve, by
    default 250 ms, 64 KiB and four reads -- smaller than fastcached's two seconds, because serve
    handles one connection at a time and a refused request holds its accept loop for up to that
    long. Additive.
  • core-cpp.open-work: every ## Open work entry leads with a core-cpp issue, and that issue is
    open
    (core-cpp#12). scripts/check-open-work.py reads every such section under .agent/ and
    docs/ and the top-level documents, and refuses an entry that does not lead with a core-cpp issue
    link, a link whose text and URL disagree, and a heading with no entries. The ctest runs that
    offline; CI's style job also runs it --online, which refuses an entry whose issue has closed,
    and one whose issue does not exist (404 or 410); it exits 77 (skipped, a warning in CI) rather
    than failing when it could not ask GitHub. It has a self-test.
  • A nightly job answers whether a provenance row's upstream has moved (core-cpp#33).
    core-cpp.upstream-drift needs the upstream checkouts beside core-cpp, so it skips on every CI
    runner and its coverage there was zero. The upstream-drift job of downstream.yml checks out
    contour, endo and fastcached with full history as siblings and runs the checker: drift is listed
    in the job summary, and a row the checkouts prove malformed, or an upstream the checker could not
    read, fails the job.
  • core-cpp.iterator-debug-canary proves the MSVC Debug runtime's iterator checks are live
    (core-cpp#11). In every MSVC-driver Debug build (cl-debug, clangcl-debug) it indexes a
    std::vector out of range and passes only on the runtime's own vector subscript out of range;
    a build where _ITERATOR_DEBUG_LEVEL fell below 2 reads the element instead, and fails.
  • A windows (clang-tidy) CI job analyses the Windows sources (core-cpp#38, core-cpp#44). The
    clang-tidy job's preset is Unix-only, so every windows/ source and every _WIN32 arm of a
    shared header went unanalysed while it was green. The new job runs the pinned clang-tidy over the
    clang-cl tree's compile database through scripts/tidy-database.py, which refuses a result that
    analysed fewer windows/ sources t...
Read more

core-cpp 0.4.3

Choose a tag to compare

@github-actions github-actions released this 26 Sep 02:52

Changed

  • A socket operation that has to wait no longer files and takes a park in the loop's id map
    (core-cpp#52). A frameless one-direction park on a handle's kept registration -- what
    PosixSocket files for every read or write that waits -- is now filed in storage the handle's
    watch keeps for that direction, and gets an id made of that storage's slot and a generation that
    moves on per operation. The operation no longer costs an id-map insert and erase or a park made
    and recycled. The [park] bench in core-cpp-net-test (gcc-release, epoll, median of 5 runs,
    three interleaved rounds) goes from 33.1-33.8 to 17.8-18.6 ns per park filed and taken with 256
    parks live, and from 21.3-22.4 to 17.6-19.2 ns with one. The socket ping-pong bench's user time
    is within its own noise either way. The timer path's drain-step completion bench is about 1 ns
    (1-2%) slower at the median, for the park lookup's two extra branches. No signature changes:
    ids are still never handed out twice, so a late cancel or a ready entry for a retired operation
    still finds nothing, and an idle socket's park is still uncounted, silent and narrowed as before.
    A closed socket's slot hands its park back to the loop's spare list (capped at 64), so a burst
    of connections leaves no burst's worth of parks behind. Measured in fastcached (memcached and
    redis at 1, 16 and 64 connections, quiet host): the park path falls from about 1.5% to 0.7-0.9%
    of samples, roughly 0.2 µs per request, with syscalls and allocations unchanged.

Full Changelog: v0.4.2...v0.4.3

core-cpp 0.4.2

Choose a tag to compare

@github-actions github-actions released this 26 Sep 00:17

Added

  • core::log::ScopedCapture::snapshot(): a copy of what the capture holds, taken under its
    lock, for reading while other threads still log into it. text() is unchanged -- a reference to
    the buffer itself -- and is valid only while no thread logs into the capture, once the writers
    are joined.

Fixed

  • core::log::ScopedCapture takes lines from several threads at once. Its sink appended to a
    std::string without a lock, so a test whose code logged from two threads corrupted the heap
    (contour's Windows test crash). Every append now takes a mutex, and so do contains(),
    count(), lines() and the new snapshot(). No signature changes.
  • DetachedTask no longer reads a freed frame under clang-cl at -O0 (core-cpp#51). A
    trivial empty return object is returned in a register, and clang-cl without optimisation kept the
    starting call's copy of it in the coroutine frame and reloaded it from there after the first
    suspension. When that suspension handed the coroutine to something that ran it to its end first
    -- a pool thread, an event loop on another thread, an await_suspend that resumes inline -- the
    frame was already freed. fastcached's shutdown flows, which hop onto a loop from another thread
    and finish there, have this shape. DetachedTask's destructor is now defaulted out of line,
    which makes it user-provided, so every ABI returns it through a pointer the caller owns. The type
    is no longer trivially destructible, trivially copyable or an aggregate; DetachedTask{} still
    works through its default constructor, and its members and behaviour are unchanged.

Full Changelog: v0.4.1...v0.4.2

core-cpp 0.4.1

Choose a tag to compare

@github-actions github-actions released this 25 Sep 20:33

Added

  • seal() on Strand and KeyedStrands: close the offer door, keep running what is queued
    and what comes back.
    close() drops the queue, so a teardown of "stop the handlers, drain,
    close" dropped whatever arrived between the drain and the close -- a task's finish, a
    coroutine's resumption -- and leaked its frame and whatever it was to settle (found by morph's
    switch review). After seal(), tryPost and trySubmit return false and leave the work with
    the caller, for KeyedStrands for every key, a key with no strand included. post and submit
    are still admitted until close(): submit is how a coroutine the strand already admitted
    comes back -- ResumeOn, resumeOn(key), an AsyncQueue push, close or stop -- and dropping it
    would free a detached chain without its finish. Queued work runs as usual, and no kept
    KeyedStrands strand is handed out or kept once sealed.
    • What a drain can see: idle() and waitIdle() then mean nothing queued and nothing
      running. A coroutine suspended off the strand -- on a socket, a timer, an AsyncQueue -- is
      invisible to both and may still come back, so a consumer counts its own in-flight work. The teardown that loses nothing is seal(), then that
      count and waitIdle() both done (on the single-threaded WebAssembly build, the base run until
      they are), then close(). post is new work too: a producer that posts must stop, or offer
      through tryPost, before waitIdle().
    • Idempotent; close() is unchanged. A patch-compatible addition.

Known issues

  • MSVC cl 19.51 /O2 leaks a coroutine's by-value parameters when its frame is destroyed at
    a suspension point with no statement after it
    (#54). Keep a statement after the
    last co_await in a coroutine that may be destroyed while suspended.

Full Changelog: v0.4.0...v0.4.1

core-cpp 0.4.0

Choose a tag to compare

@github-actions github-actions released this 25 Sep 15:43

Breaking

  • A consumer parked on AsyncQueue::pop resumes on the executor it was running on when it
    parked
    , not on the executor the queue was constructed over -- on a push, a close() and a stop
    request alike (see Added, the current-executor context). The queue's executor is now the
    fallback, used only for a consumer that parked while no executor was current. The old behaviour
    sent a coroutine that parked on a Strand back off it, onto the queue's executor, where it raced
    the state the strand serialises (found by morph, PR #806).
    • Migration: a consumer that runs on the queue's own executor -- the usual shape -- sees no
      change. One that parks while running on a different executor now comes back there; if it
      relied on arriving on the queue's executor, hop explicitly after the pop with
      co_await ResumeOn { queueExecutor }. An IExecutor of your own that resumes coroutines
      should state itself with core::async::ExecutorScope around the resumption (as
      testing::ManualExecutor does), or its coroutines keep the old behaviour.
    • Consumers: fastcached uses AsyncQueue in five files (RaftPeerTransport.hpp and .cpp,
      and fastcache-cli's LiveEventSource.cpp, LiveSourceRig.hpp and ScriptedStopSignal.hpp)
      and is unaffected: every queue there is built over the reactor, and every pop() parks either
      on that reactor or outside any executor, so the executor it comes back to is the one it came back
      to before. morph's handlers, which parked on a strand, come back to it now, which is the fix.
  • core::net::resumeSoonOn is core::net::detail::resumeSoonOn, and takes the chain's
    unowned root instead of a work-item factory. It is ResultAwaitable's out-of-line completion
    hook and has no other caller.
    • Migration: none expected -- fastcached, endo, contour, tuidu, dbtool and morph call it
      nowhere. A transport completes an operation through ResultAwaitable::complete.

Added

  • core::async::Strand (<core/async/Strand.hpp>): an IExecutor over any IExecutor that
    runs what it is given one task at a time, FIFO -- a task being one resumption, from the submit to
    the next suspension. co_await ResumeOn { strand } hops onto it; runningHere() asks whether
    the calling thread is inside one of its tasks. One coroutine pump per strand, queued on the base
    once however many tasks arrive while it is busy, runs at most StrandOptions::batch (32) tasks
    per turn before it hands the base back. A task that throws out of resume() propagates to
    whoever resumed the pump and does not wedge the strand or lose the tasks behind it -- except
    under MSVC's cl, where it ends the process with a message
    : an exception crossing the strand's
    coroutine frames was measured corrupting the thread's executor scopes on cl-release
    (core-cpp.strand-throw-canary). An allocation that fails leaves the strand as it was: submit
    throws with nothing queued, and a key's strand whose replacement pump cannot be made is retired
    if nothing is queued on it. A refused hand-off abandons the strand's queued work: a base
    whose submit throws when the strand hands it its pump makes that submit throw, and the rest
    of the queue -- work other threads queued meanwhile included -- is dropped as destruction drops
    it; a KeyedStrands key's strand is retired with it. A key whose first submit cannot allocate
    no longer leaves its new strand registered with nothing to retire it. What a frame freed by that drop submits to
    the same strand (or KeyedStrands) from its destructor is dropped as well, not handed to the
    refusing base. ~Strand waits for a hand-off still inside the base's submit. Destroying a strand drops what is
    queued -- a chain rooted in a DetachedTask is freed, a Task-owned coroutine is left to its
    owner -- and waits for a task running on another thread, but not for the task it is called
    from: a task may release the last reference to the strand's owner. The strand's state outlives
    it, so a coroutine that parked on it and is handed back later (an AsyncQueue push after the
    strand died) is dropped the same way rather than reaching freed storage; inside a task,
    currentExecutor() is that state, not the Strand's address -- ask runningHere(). The base
    must outlive the strand and run what it queued; an EventLoop destroyed with the strand's pump
    still in its inbound queue drops it, leaking the strand's state. Written after morph's
    StrandExecutor, which consumers should replace with it.
  • core::async::KeyedStrands<Key, Hash, KeyEqual> (<core/async/KeyedStrands.hpp>): one
    strand per key over a shared base, made when a key gets work and reclaimed when it runs out.
    submit(key, ...), co_await strands.resumeOn(key), runningHere(key), runningAnyHere(),
    size(), and waitIdle(), which blocks until no key has work, asserts when called from one of
    its own tasks, and is declared only where threads exist. Destroying it from one of its own tasks
    does not wait for that task. A coroutine that parked while its key's
    strand was reclaimed comes back to the key, never to a second strand beside it.
  • Callables, a closed strand's answer, an around-task hook and idle() on both strands -- what
    morph's switch from its own StrandExecutor found missing:
    • post(fn) / post(key, fn) run a callable as one task, held by value in one allocation (the
      census in StrandAllocation_test.cpp: one per post and none per submit, to a busy key and to
      an idle one alike, in the steady state -- KeyedStrands keeps up to 32 retired strands, with
      their pump frames, queue room and map nodes, and gives them to the next key that needs one;
      without that a post to an idle key cost five). What it throws takes
      a task's way out, and ends the process under MSVC's cl as a task's throw does.
    • tryPost(fn), trySubmit(work) and their keyed forms return false once the strand is closed
      and leave the work with the caller, which can then run it itself. close() is public on both,
      idempotent, and what the destructors call.
    • StrandOptions::aroundTask (an AroundTask) and KeyedStrands' KeyedAroundTask<Key>, which
      is given the key: a hook called around every task, a coroutine that came back through the
      strand from another executor included, to install per-task ambient context such as a
      session. A reference set at construction; unset, it costs one branch per task. Task carries
      no context of its own, which would cost every co_await for every consumer.
    • idle(): nothing queued or running. On the single-threaded WebAssembly build, where
      waitIdle() does not exist, a host pumps its base until idle(); destroying or closing a
      strand there drops what is queued without waiting, since nothing else can be running.
  • The current-executor context (<core/async/ExecutorContext.hpp>): ExecutorScope marks the
    calling thread as running a task of an executor, nests, and restores on every exit;
    currentExecutor() answers the innermost; ResumeTarget is what an awaitable holds across a
    suspension to resume there, with ResumeTarget::currentOr(fallback). core::net::EventLoop
    states itself once per turn (and around its teardown drains), ThreadPoolExecutor once per
    worker thread, a strand once per batch. The scope is two thread-local stores and no allocation;
    on the loop it is paid per turn, not per resumption, because G2 puts every resumption inside one
    turn's step 2. Measured on the drain path against the same tree without it (gcc-release, one
    pinned core, 4M resumptions, median of five interleaved runs): 35.9 to 36.3 ns per resumption
    with one resumption per turn -- the two stores, paid once per turn -- and 19.6 to 19.7 with a
    turn of 64. The program is tests/bench/ExecutorContextBench.cpp (core-cpp-bench-executor-context).
    • core::net's socket operations and timers do not read it and keep resuming on their
      EventLoop (G2); a strand-bound coroutine hops back with co_await ResumeOn { strand }.
  • core::async::testing::ManualExecutor (<core/async/testing/ManualExecutor.hpp>): an
    executor a test drains by hand (runOne, drain, pending) that states itself as the current
    executor while it does. drain(bound) stops after DrainBound (2^20) resumptions by default and
    throws std::length_error if work is still queued, so work that requeues itself for ever fails
    a case instead of hanging it. The first public test double of core::async.
  • A design note, Strands and the resume context (docs/design/strands.md).

Changed

  • A socket completion costs less on the loop's thread. ResultAwaitable::complete hands its
    waiter to the drain step (G2) through EventLoop's ready queue, and for a chain rooted in a
    DetachedTask -- every connection a server spawns -- the queue entry was a refcounted work item
    whose claim cost five atomic operations per completion; it is now a detail::CountedClaim
    costing two, with the same count, arm and teardown behaviour. The waiter a readiness callback
    queues reaches the callback position by a swap of two vectors instead of a range insert and an
    erase, and the drain resumes an entry in place rather than moving it out first. Measured with the
    new [bench] cases (core-cpp-net_backend-test "[bench]", gcc-release, median of five on an
    idle host): a completion through a drain-step callback went from 120.4 to 83.4 ns for a
    detached chain, and from 122.6 to 79.3 ns for a chain a caller owns. The ordering (a waiter
    resumes in its callback's position), teardown (a detached chain is freed, a borrowed one resumed)
    and take-back paths are the same, and each is a case in CompletionClaim_test.cpp. Two things a
    caller can observe did change:
    • a frameless park holding both of its handle's watch slots gets one callback per report...
Read more

core-cpp 0.3.0

Choose a tag to compare

@github-actions github-actions released this 25 Sep 05:04

Breaking

Each of these is a defect fixed or a contract made explicit, and each changes what a caller can
observe; the migration is under each.

  • A second read or write armed over a parked one ends the process in every build (see
    Fixed). A Release process that used to hang there now aborts at the violation.
    • Migration: audit every path that re-arms a read or write on a socket without the previous
      operation having resolved -- in particular a retry or timeout path that starts a new read
      without cancelRead() or awaiting the old one. Install core::setFailHandler to route the
      message ("Socket contract violated: ...", naming the direction and the handle) to your logger,
      and to take a stack trace there before the abort.
  • A waiter completed by a drain-step callback resumes in the callback's position, not at the
    back of the ready queue as in 0.2.1 (see Fixed); 0.2.0's order, without resuming inline.
    • Migration: code written against 0.2.1 that relied on a flow queued ahead of a readiness
      callback running again -- after a yield -- BEFORE that callback's waiter now sees the waiter
      run first. Code written against 0.2.0 needs nothing.
  • IHostScheduler::callAfter must deliver every request it accepts exactly once, because
    HostDrivenBackend now hands it a ticket only the callback frees.
    • Migration: a host that dropped pending callbacks at shutdown leaks one small ticket per
      request dropped; deliver them (a late pump finds its backend gone and runs nothing) or accept
      the leak. A host that delivered one twice must stop.
  • testing::ManualHostScheduler is neither copyable nor movable, and clear() is no longer
    noexcept.
    Its destructor delivers what is still pending, cleared requests included.
    • Migration: hold one per test by value or by reference, and destroy it after the backends it
      serves -- declare it first.

Added

  • core::net::contract::SlotDirection, contract::secondOperationArmed() and
    contract::describeHandle()
    in <core/net/SocketContract.hpp>, and an optional handle argument
    (plus a defaulted std::source_location) on contract::claimReadSlot and
    contract::claimWriteSlot, which name it when they end the process. A transport outside
    core-cpp passes its own handle to get it in the message.

  • EventLoop::inboundFinishedRootCount(): how many spawned flows ended off the loop's thread
    and wait for the next turn to release them.

  • core-cpp installs as the CMake package core-cpp (core-cpp#5): find_package(core-cpp 0.3 CONFIG REQUIRED) and target_link_libraries(app PRIVATE core::net), the same names as a source
    build's aliases. Every module target is installed with its HEADERS file set (the generated
    core/Config.hpp included) in the install component core-cpp, with a
    core-cppConfigVersion.cmake that is SameMinorVersion while core-cpp is 0.x. The package
    config calls find_dependency() for exactly the dependency-table rows its installed targets
    link. A target that links a dependency the build fetched rather than found (libunicode, Catch2 or
    Tracy through CPM) cannot be re-found by an installed package and is left out, with a status line
    saying so. See docs/getting-started/install.md.

    • CORE_CPP_INSTALL, default PROJECT_IS_TOP_LEVEL: a vendoring or CPM consumer installs
      nothing of core-cpp's unless it asks. A parent that exports a target of its own linking
      core-cpp's turns it on, or CMake refuses the export as "not in any export set" (found by morph).
    • core::net's detail/ReadyBatch.hpp and detail/ScopeGuard.hpp joined its HEADERS file
      set: EventLoop.hpp and testing/ScriptedBackend.hpp include them, so the installed headers
      could not compile without them. core::tui links stb_image as $<BUILD_INTERFACE:...>, and
      core::testing_main names its dialog object through the installed core::testing_dialogs.
    • core-cpp.install installs the build under test into an empty prefix, builds and runs a
      consumer of the package (tests/consumer-install), checks that every core-cpp header an
      installed header includes was installed, and configures a parent exporting a target that links
      core-cpp's (tests/consumer-install-nested) with CORE_CPP_INSTALL on and off.

Fixed

  • core::testing::suppressWindowsDialogs() keeps abort()'s message and turns off Windows Error
    Reporting's UI.
    It cleared _WRITE_ABORT_MSG with _CALL_REPORTFAULT, so an aborting test
    printed nothing; only the fault report is off now, and the message goes to stderr, not a dialog.
    It also asks Windows Error Reporting for no UI, WerSetFlags(WER_FAULT_REPORTING_NO_UI) (in
    kernel32; WerGetFlags confirms the flag is set), for an unhandled structured exception in a
    process whose error mode was reset (found by morph). windows-dialog-canary.abort now requires the message in a Debug build;
    a Release UCRT writes none.

  • A waiter completed by a readiness callback resumes in the callback's position again. 0.2.1
    made a completion from a callback (ResultAwaitable::complete(), and anything a drain-step
    callback hands to EventLoop::resumeSoon) join the BACK of the ready queue, where 0.2.0 had
    resumed it inline. A flow queued ahead of the callback that yields once to let already-reported
    readiness run -- fastcached's AbandonIfPeerGone -- then resumed before the waiter and read stale
    state. The drain now puts what a callback queued at the front once the callback returns: the
    waiter resumes before anything queued after the callback, still in the drain step and never
    inside the callback (G2). resumeSoon from outside a drain-step callback stays FIFO. The
    callback's queue is a member reused across callbacks, so a readiness completion allocates
    nothing for it, and cancelPending finds a waiter a callback has queued.

  • A spawned flow that completes inside a sub-task is released, instead of leaking until the
    loop is destroyed.
    EventLoop::spawn unlinked a finished flow by the frame its ready entry
    named, and a flow parked inside a sub-task (co_await leaf(), with leaf on a socket read or a
    delay) is resumed through the sub-task's frame: it ran to its end inside that resume, by
    symmetric transfer, and stayed in the loop -- and in spawnedCount() -- until ~EventLoop, on
    normal completion and on requestStop alike. A long-lived loop spawning one flow per connection
    grew without bound (found by the contour migration, measured on 0.2.1). spawn now runs the
    flow inside a root coroutine owned by the loop -- one more frame allocation per spawn -- whose
    final suspension files it for release; the drain destroys it after the resume that finished it
    returns, in O(1) and on the loop's thread, whichever frame the resume named. A flow that ends on
    another thread (after co_await ResumeOn { pool }) hands itself over through the inbound queue
    and is released in the next turn's first step. A flow's exception ends it without being
    rethrown into the root.

  • A second operation armed over a parked one ends the process in every build, instead of hanging
    in Release.
    One read and one write operation per socket is the contract, and
    contract::claimReadSlot, contract::claimWriteSlot and the watch slots of
    EventLoop::registerPark enforced it with assert alone: under NDEBUG the second operation
    displaced the parked one, which was then never resumed -- a silent hang, and the leading suspect
    in a Release-only fastcached stall on 0.2.1. All three now terminate through core::detail::fail
    (so a program's core::setFailHandler logs it first), naming the direction and the socket's
    native handle, in Debug and Release alike. claimReadSlot and claimWriteSlot take the handle
    as an optional second argument. The socket-contract-canary slot modes, and two new ones for the
    loop's own slots (watch-read-slot, watch-write-slot), now run on the Release legs as well.

    • Migration: a caller that armed a second read or write over a parked one was already broken; in
      a Release build it now fails loudly at the violation instead of hanging later. IocpSocket's
      Release-only handling of an orphaned write, and the two tests that drove displacement under
      NDEBUG, are gone with the displacement.
  • A host-driven loop may be destroyed while a pump is out with the host. HostDrivenBackend
    handed IHostScheduler::callAfter its own address, and emscripten_async_call cannot be
    retracted: a PlatformLoop destroyed under Emscripten with a timer armed, or after any off-turn
    addTimer, post or wake, freed the backend it owns, and the browser's timer then wrote into
    it -- a heap-use-after-free (found by morph's timeout scheduler). Each pump now carries a small
    ticket holding a weak reference to the backend; a late pump finds it expired, runs nothing and
    frees the ticket. Coalescing is unchanged.

    • IHostScheduler states its contract: every request accepted is delivered exactly once, since
      its state may own storage only the callback frees. A host that drops a request leaks a ticket.
    • testing::ManualHostScheduler delivers whatever is still pending when it is destroyed,
      including what clear() took out, which is no longer noexcept; it is no longer copyable or
      movable, since a copy would deliver a ticket twice.

core-cpp 0.2.1

Choose a tag to compare

@github-actions github-actions released this 24 Sep 16:01

Every behaviour change in this section is a defect fixed, and each entry says which guarantee it
restores and what a caller that depended on the defect changes. None of them is a break of a
documented promise, which is why they are under Fixed in a patch release.

Added

  • A public SGR reset in core::tui_output: buildSgrReset() beside buildSgrSequence(),
    TerminalOutput::resetStyle(), and protocols::SgrReset. They are the bytes writeText already
    ended styled text with, which were private, so Lightweight's dbtool spelled "\033[0m" itself.
  • core::platform::SignalHandler::nativeHandle(): the signal fd as the NativeHandle
    TuiRuntimeOptions::signalFd takes -- the signalfd on Linux while initialized, InvalidHandle
    everywhere else -- so a caller no longer converts initialize()'s int, which on Windows is the
    wrong type for a handle. initialize() keeps its int for compatibility (found by the endo
    migration).
  • The end of a terminal's input is reported. TerminalInput::inputClosed(), the virtual
    runtime::InputSource::inputClosed() (false by default, so an existing source still compiles),
    TerminalInputSource's forward of it, TuiRuntime::inputClosed(), and
    runtime::testing::ScriptedInputSource::closeInput() to script it. See Fixed.
  • CORE_CPP_WITH_TUI_OUTPUT, an option of core::tui_output's own. With CORE_CPP_WITH_TUI
    off and this on, core-cpp builds the styled-output leaf by itself and neither finds nor fetches
    libunicode. It defaults to CORE_CPP_WITH_TUI on a first configure, is forced on by it
    (core::tui links the leaf), and is forced off under Emscripten. A module's own row switching
    off no longer takes a target with a row of its own down with it: core_cpp_add_modules() enters
    the directory for that target alone, as the module table always said a row of its own would.
    tests/consumer-tui-output and a consumer-smoke (tui-output) CI leg assert the configuration.

Fixed

  • A parked flow is never resumed inside the call that settled it -- restores guarantee G2,
    every resumption happens in the loop's drain step (.agent/rules/async-and-net.md, "a resource
    never resumes its consumer inline"). ResultAwaitable::complete() resumed the waiter on the
    spot, so PosixSocket::close() -- and cancelRead(), IocpSocket's,
    WindowsSocket::cancelRead(), and CompletionWait::close() under an IOCP listener -- ran the
    closed read's flow before returning. That flow could run to its end and destroy the object still executing close()'s
    caller: contour crashed on it deterministically, in NativeClient::detach (_writer.close(); _connection->close();, where the first close resumed runClient, which destroyed the client).
    Each now settles the operation at once, with the same value (Cancelled, or the data that won),
    and hands the waiter to EventLoop::resumeSoon; an awaiter whose frame is destroyed while its
    waiter is queued takes it back with cancelPending. The listeners already resumed a closed
    accept through the loop's closed-park list, and TLS's SerialGate its waiters since Task B11.
    CloseResumesThroughLoop_test.cpp holds it over BackendMatrix, contour's crash included.
    • Migration: a caller that asserted a parked flow's outcome right after close() or
      cancelRead() runs one loop turn first (runOnce, runUntilIdle, a blockOn). Two
      cancelRead() calls in a row no longer retire the read the first victim arms when it runs: the
      second finds the slot empty (fastcached#1233's shape). testing::InMemorySocket and
      testing::ParkingReadableSocket, which have no loop, still resume inline.
    • The socket may be gone when the flow runs, whatever the result: the waiter resumes later
      in the drain, so an owner that destroys the socket first -- conn->close(); connections.erase(id); -- has destroyed it before the flow sees its result, a Cancelled
      one or bytes a read already took. A flow must not touch a socket it does not own after its
      operation resumes (ISocket::close says so). The transports touch nothing of it: the
      frame-free ones settled a value that does not refer to the socket, and the coroutine-shaped
      ones ask a lifetime token on every way back and unwind with OperationCancelled where they
      used to write into freed storage -- WFMO's WindowsSocket (its _readWaiter), and the TLS
      layer, whose feedIn wrote the ciphertext of a read that settled with data into the freed
      session's BIO, on every backend. Both were a heap-use-after-free under AddressSanitizer.
    • A listener closed and destroyed in one turn -- listener->close(); listener.reset(); --
      woke its parked accept through the loop, and the accept then read the freed listener's
      closed flag and descriptor: PosixListener, UnixListener and WFMO's WindowsListener, a
      defect older than this release. The accept now asks the listener's lifetime token first and
      answers Cancelled ("the listener was destroyed"). IocpListener keeps what an accept reads
      in state it shares, and was not affected.
    • Teardown: ~EventLoop drains what destroying the spawned roots queued -- a borrowed flow
      whose socket or listener a root owned -- so no flow is left suspended with an operation naming
      a destroyed loop; and a chain nobody owns (a DetachedTask) that a socket queued is freed at
      teardown as the loop's own, where it used to be resumed and run on.
    • For contour, missing from 0.1.0's per-consumer summary: from 0.1.0 until this release,
      core-cpp's sockets resumed a parked read INSIDE close(), so code that closes two sockets in a
      row through an object the read flow owns -- contour's NativeClient::detach -- was exposed to
      it. 0.1.0 is released, so the note is recorded here rather than there.
  • On Windows, listenUnix and connectUnix belong to the loop's transport (found through
    contour). listenUnix built the WFMO WindowsListener whatever the loop was, and connectUnix a
    WindowsSocket, while listen and adoptListener branch to IOCP -- so an IOCP loop, the Windows
    default, served AF_UNIX through readiness and handed out readiness sockets. It now gets the new
    IocpListener::bindUnix, whose AcceptEx accepts AF_UNIX connections (measured on Windows 11,
    asserted in CI) and hands out IocpSocket, and connectUnix adopts its socket onto the loop's
    transport. A WFMO loop is unchanged. The socket-path claim both listeners make moved into a shared
    windows/UnixSocketPath.cpp.
  • A hung-up terminal no longer spins the TUI at 100% CPU
    (core-cpp#49, found by the tuidu
    migration) -- restores the input wait's promise that it waits: with SIGHUP ignored, a terminal
    that hangs up leaves its input readable for ever, each read answering EIO or an end of file; the
    runtime's input flow read nothing, re-parked, and was resumed at once, every turn, and the
    process never exited. TerminalInput::readReadyInput() now tells the end from "nothing yet" -- a
    read error other than EAGAIN, an end of file on a pipe or a file, an end of file on a terminal
    that poll(2) reports hung up, a Windows console input handle that can no longer be read -- and
    the runtime then stops watching the handle, delivers what was already read, and ends its input:
    nextEvent(), nextEventFor() and nextActivity() throw core::async::OperationCancelled
    without parking once TuiRuntime::inputClosed() is true. The same applies when the loop refuses
    the input handle (FdRegistrationFailed), where the input flow used to return and leave a
    nextEvent() waiting for ever. tuidu fixed the POSIX half in its own copy (tuidu c20bcac) and
    it never reached endo, so core-cpp did not have it.
    • Migration: a consumer that treats a cancelled or empty read as "try again" must treat the
      end of input as final
      , or the spin moves from the runtime into its own loop: it asks
      inputClosed() and exits. endo's Prompt::read is the example -- it catches
      OperationCancelled and returns an empty line, and its REPL reads again for as long as the
      prompt is ready. tuidu's runModal (tui/runtime/Modal.hpp) is the other: it returns
      std::nullopt on the cancellation, so a caller that shows the modal again on nullopt spins
      the same way.
    • TerminalInput::poll() records the end in inputClosed() too (a hangup with nothing to read on
      POSIX, a failed wait on Windows), but a loop driven by poll() itself must ask it; nothing ends
      that loop for it.
  • Piped output no longer carries synchronized-output sequences -- restores SyncGuard's
    purpose, bracketing a frame for the TERMINAL that renders it. TerminalOutput::syncGuard(), and
    a SyncGuard constructed directly, wrote CSI ? 2026 h / l whatever the destination was, so
    every caller had to test isTerminal() and choose between a guard and none, and one that did not
    wrote escape sequences into a pipe or a file. The guard now asks the output's isTerminal() once
    and writes the sequences only when it answers true; it still flushes at both ends.
    • Migration: a capture that wants the sequences answers isTerminal() true from its subclass,
      as a terminal-emulating capture already should.
  • tools/migrate/rewrite.py rewrites the code after a character literal holding a " (found by
    the contour migration). Its scanner read '"' as opening a string, so in
    os << '"' << crispy::escape(s) << '"' the symbol was masked as data and left unrewritten. A
    character literal is now one character or one escape sequence, which also keeps a digit
    separator's quotes (1'000'000) from being read as one. renames.json gains the
    crispy::Overloaded -> core::Overloaded row the migration guide already listed (contour).
  • **A vendored copy configures wit...
Read more

core-cpp 0.2.0

Choose a tag to compare

@github-actions github-actions released this 24 Sep 03:19

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...
Read more

core-cpp 0.1.0

Choose a tag to compare

@github-actions github-actions released this 23 Sep 06:34

The first release: the shared C++23 foundation of the Contour Terminal projects, in namespace
core, replacing the copies of the same code that contour, endo, fastcached and tuidu each carry.

What it is made of. Imported, and recorded file by file in
.agent/reference/provenance.md (see Imported below for each
import and what changed on the way in): contour at 6777ff05014f8ff163b071e8b0e942830119db80
(crispy's generic half, src/coro, src/net; 110 files), endo at
f774a210ce989e5947b8f61d715068b1dc96088c (the generic half of src/platform and src/tui; 212
files), and fastcached at 0708dd54dc7ee72622c8c0783c2bd4a06f0e9b21 (its async and networking
layer; 103 files, plus 8 at three earlier commits). 97 files were written here.

The merged design. contour's coroutine and networking layer and fastcached's are one design
now, not two side by side: one EventLoop with a five-step turn and a six-step teardown over an
injected IoBackend (poll, epoll, kqueue, an I/O completion port as the Windows default, WFMO kept
one release, and a host-driven backend for WebAssembly); one park table and one deadline mechanism;
one frame-free, stop-aware socket contract with one read and one write operation per socket; one
TLS layer; one socket-error table per platform; and fastcached's ownership rules for parked work,
teardown and cancellation. The rules and the fastcached issue behind each are
Coroutines and lifetimes; the threading guarantees are
Threading.

Breaking, for the projects that carried a copy. contour, endo, fastcached and tuidu each meet the breaks of the copies they carry. Every break, with its migration, is under Breaking in the 0.1.0 section of CHANGELOG.md. tools/migrate/renames.json and the codemods beside it apply the mechanical renames.

Assets. core-cpp-v0.1.0-vendor.tar.gz is the vendoring file set exported by cmake/CoreCppVendor.cmake, for consumers that vendor without git (see Vendoring). SHA256SUMS covers it.