Releases: contour-terminal/core-cpp
Release list
core-cpp 0.5.0
Breaking
- The WFMO backend is removed: the completion port is Windows' only backend (core-cpp#6).
BackendKind::Wfmo,WfmoBackendand 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,adoptSocketand the dials refuse a loop whose backend lends no completion port
withNetErrorCode::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 answersCancelledon every
platform, whereWindowsSocketansweredBadHandle(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)withmakeDefaultBackend().BackendKind's enumerators after
Iocpshift 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 anISocketof its own.
- Migration: no consumer names any of these; a program that did replaces
core::platform::EnvironmentProviderisProcessEnvironment, acore::Environment; the
working directory has its own seam; Windows reads and writes the environment in UTF-8
(core-cpp#7). Two seams answered "readHOME", with two doubles a mixed test had to keep
agreeing.core::Environmentis now the one read seam, andProcessEnvironment-- what a shell
writes -- derives from it, so code that only reads is handed the same object and
testing::TestProcessEnvironmentis the double for both.set,unset,exportVariableand
setAndExportreturnstd::expected<void, PlatformError>, where they returnedvoidand 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).
changeDirectoryandcurrentDirectorymove tocore::platform::WorkingDirectory
(nativeWorkingDirectory(),testing::TestWorkingDirectory), andhomeDirectory,userName
andconfigHomeare the free functions of<core/platform/UserPaths.hpp>over a
core::Environment const&(userNameis new there). On Windows,core::LiveEnvironment,
core::setProcessEnvironmentVariableandunsetProcessEnvironmentVariablego 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), andProcessEnvironment::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>;
EnvironmentProviderisProcessEnvironment,nativeEnvironmentProvider()is
nativeProcessEnvironment(), andtesting::TestEnvironmentProvideris
testing::TestProcessEnvironment(<core/platform/testing/TestProcessEnvironment.hpp>), which no
longer takes an initial directory. Handle or discard thestd::expectedfrom everyset,
unset,exportVariableandsetAndExport(std::ignore = env.set(...)where a failure is
acceptable). Replaceenv.changeDirectory(p)andenv.currentDirectory()with a
WorkingDirectory&the object is given --nativeWorkingDirectory()in a composition root,
testing::TestWorkingDirectory(initial)withaddValidPathin a test. Its
currentDirectory()returns astd::filesystem::path, where the old member returned a UTF-8
std::string, so the answer round-trips throughchangeDirectory()on Windows whatever it
spells: a caller that wants the text takescore::platform::normalizePath(cwd.currentDirectory()),
and one that compared with a string literal compares itsgeneric_string(). Replace
env.homeDirectory(),env.userName()andenv.configHome()with
core::platform::homeDirectory(env),userName(env)andconfigHome(env). A function that
only reads can take acore::Environment const&and be handed either double. contour, which
uses onlycore::Environment, changes nothing.
- Migration:
core::cli::parse()returnsstd::expected<FlagStore, ParseError>and throws nothing
(core-cpp#13). It returnedstd::optional<FlagStore>--std::nulloptfor tokens left over --
and threwcore::cli::ParserErrorfor a value of the wrong type, a missing value or an explicit
empty one, andstd::invalid_argumentfor a missing required option. Every one of those is now a
ParseError: aParseErrorKind(NotEnoughArguments,InvalidValue,EmptyValue,
UnexpectedToken,MissingRequiredOption), the index of the token at fault and a message.
ParserErroris removed. Numbers are read whole, withstd::from_chars(floating point with
std::strtod):12abcis refused rather than read as 12,-1is no longer accepted -- and
wrapped -- as an unsigned, a leading+or leading whitespace (+5,5), whichstd::stoi
andstd::stoulaccepted, 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()andApp::reparseParameters()print the error's message; their signatures are
unchanged.- Migration: a call site that tested
has_value()or used*parsedandparsed->compiles
as it is when its variable isauto; one that names the type spells
std::expected<core::cli::FlagStore, core::cli::ParseError>, orauto. Replace atry/catch
aroundparse()with a test of the result, and reportparsed.error().message. tuidu's
parseCommandLine()(src/tuidu/Cli.cpp) is the one consumer call site: it declares
std::optional<core::cli::FlagStore> parsedand catchesstd::exceptionaround the call.
contour and endo usecore::cli::Apponly, and need no change.
- Migration: a call site that tested
Added
core::async::TaskKindandRunTask::kind(): an around-task hook can tell a coroutine
resumption from a posted callable (core-cpp#53).TaskKind::Callableis whatpostand
tryPostwere given;TaskKind::Resumptionis a coroutine arriving throughsubmitor
trySubmit, as a handle or asParkedWork, aResumeOnhop, or aKeyedStrandsreroute
through a retired key strand. A hook --StrandOptions::aroundTaskor aKeyedAroundTask--
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::closeLingeringandLingerBounds(<core/net/LingeringClose.hpp>, from
fastcached at0708dd54; 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::lingerbounds it forserve, by
default 250 ms, 64 KiB and four reads -- smaller than fastcached's two seconds, becauseserve
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 workentry leads with a core-cpp issue, and that issue is
open (core-cpp#12).scripts/check-open-work.pyreads 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'sstylejob 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-driftneeds the upstream checkouts beside core-cpp, so it skips on every CI
runner and its coverage there was zero. Theupstream-driftjob ofdownstream.ymlchecks 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-canaryproves 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::vectorout of range and passes only on the runtime's ownvector subscript out of range;
a build where_ITERATOR_DEBUG_LEVELfell 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-tidyjob's preset is Unix-only, so everywindows/source and every_WIN32arm 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 throughscripts/tidy-database.py, which refuses a result that
analysed fewerwindows/sources t...
core-cpp 0.4.3
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
PosixSocketfiles 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 incore-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
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::ScopedCapturetakes lines from several threads at once. Its sink appended to a
std::stringwithout 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 docontains(),
count(),lines()and the newsnapshot(). No signature changes.DetachedTaskno 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, anawait_suspendthat 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
Added
seal()onStrandandKeyedStrands: 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). Afterseal(),tryPostandtrySubmitreturn false and leave the work with
the caller, forKeyedStrandsfor every key, a key with no strand included.postandsubmit
are still admitted untilclose():submitis how a coroutine the strand already admitted
comes back --ResumeOn,resumeOn(key), anAsyncQueuepush, close or stop -- and dropping it
would free a detached chain without its finish. Queued work runs as usual, and no kept
KeyedStrandsstrand is handed out or kept once sealed.- What a drain can see:
idle()andwaitIdle()then mean nothing queued and nothing
running. A coroutine suspended off the strand -- on a socket, a timer, anAsyncQueue-- is
invisible to both and may still come back, so a consumer counts its own in-flight work. The teardown that loses nothing isseal(), then that
count andwaitIdle()both done (on the single-threaded WebAssembly build, the base run until
they are), thenclose().postis new work too: a producer that posts must stop, or offer
throughtryPost, beforewaitIdle(). - Idempotent;
close()is unchanged. A patch-compatible addition.
- What a drain can see:
Known issues
- MSVC cl 19.51
/O2leaks 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
lastco_awaitin a coroutine that may be destroyed while suspended.
Full Changelog: v0.4.0...v0.4.1
core-cpp 0.4.0
Breaking
- A consumer parked on
AsyncQueue::popresumes on the executor it was running on when it
parked, not on the executor the queue was constructed over -- on a push, aclose()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 aStrandback 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 }. AnIExecutorof your own that resumes coroutines
should state itself withcore::async::ExecutorScopearound the resumption (as
testing::ManualExecutordoes), or its coroutines keep the old behaviour. - Consumers: fastcached uses
AsyncQueuein five files (RaftPeerTransport.hppand.cpp,
and fastcache-cli'sLiveEventSource.cpp,LiveSourceRig.hppandScriptedStopSignal.hpp)
and is unaffected: every queue there is built over the reactor, and everypop()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.
- Migration: a consumer that runs on the queue's own executor -- the usual shape -- sees no
core::net::resumeSoonOniscore::net::detail::resumeSoonOn, and takes the chain's
unowned root instead of a work-item factory. It isResultAwaitable's out-of-line completion
hook and has no other caller.- Migration: none expected -- fastcached, endo, contour, tuidu,
dbtooland morph call it
nowhere. A transport completes an operation throughResultAwaitable::complete.
- Migration: none expected -- fastcached, endo, contour, tuidu,
Added
core::async::Strand(<core/async/Strand.hpp>): anIExecutorover anyIExecutorthat
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 mostStrandOptions::batch(32) tasks
per turn before it hands the base back. A task that throws out ofresume()propagates to
whoever resumed the pump and does not wedge the strand or lose the tasks behind it -- except
under MSVC'scl, where it ends the process with a message: an exception crossing the strand's
coroutine frames was measured corrupting the thread's executor scopes oncl-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
whosesubmitthrows when the strand hands it its pump makes thatsubmitthrow, and the rest
of the queue -- work other threads queued meanwhile included -- is dropped as destruction drops
it; aKeyedStrandskey'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 (orKeyedStrands) from its destructor is dropped as well, not handed to the
refusing base.~Strandwaits for a hand-off still inside the base'ssubmit. Destroying a strand drops what is
queued -- a chain rooted in aDetachedTaskis freed, aTask-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 (anAsyncQueuepush after the
strand died) is dropped the same way rather than reaching freed storage; inside a task,
currentExecutor()is that state, not theStrand's address -- askrunningHere(). The base
must outlive the strand and run what it queued; anEventLoopdestroyed 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(), andwaitIdle(), 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 ownStrandExecutorfound missing:post(fn)/post(key, fn)run a callable as one task, held by value in one allocation (the
census inStrandAllocation_test.cpp: one per post and none per submit, to a busy key and to
an idle one alike, in the steady state --KeyedStrandskeeps 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'sclas 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(anAroundTask) andKeyedStrands'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.Taskcarries
no context of its own, which would cost everyco_awaitfor every consumer.idle(): nothing queued or running. On the single-threaded WebAssembly build, where
waitIdle()does not exist, a host pumps its base untilidle(); 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>):ExecutorScopemarks the
calling thread as running a task of an executor, nests, and restores on every exit;
currentExecutor()answers the innermost;ResumeTargetis what an awaitable holds across a
suspension to resume there, withResumeTarget::currentOr(fallback).core::net::EventLoop
states itself once per turn (and around its teardown drains),ThreadPoolExecutoronce 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 istests/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 withco_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 afterDrainBound(2^20) resumptions by default and
throwsstd::length_errorif work is still queued, so work that requeues itself for ever fails
a case instead of hanging it. The first public test double ofcore::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::completehands its
waiter to the drain step (G2) throughEventLoop'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 adetail::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 inCompletionClaim_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...
core-cpp 0.3.0
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
withoutcancelRead()or awaiting the old one. Installcore::setFailHandlerto 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.
- Migration: audit every path that re-arms a read or write on a socket without the previous
- 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.
- Migration: code written against 0.2.1 that relied on a flow queued ahead of a readiness
IHostScheduler::callAftermust deliver every request it accepts exactly once, because
HostDrivenBackendnow 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.
- Migration: a host that dropped pending callbacks at shutdown leaks one small ticket per
testing::ManualHostScheduleris neither copyable nor movable, andclear()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.
- Migration: hold one per test by value or by reference, and destroy it after the backends it
Added
-
core::net::contract::SlotDirection,contract::secondOperationArmed()and
contract::describeHandle()in<core/net/SocketContract.hpp>, and an optional handle argument
(plus a defaultedstd::source_location) oncontract::claimReadSlotand
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)andtarget_link_libraries(app PRIVATE core::net), the same names as a source
build's aliases. Every module target is installed with itsHEADERSfile set (the generated
core/Config.hppincluded) in the install componentcore-cpp, with a
core-cppConfigVersion.cmakethat isSameMinorVersionwhile core-cpp is 0.x. The package
config callsfind_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, defaultPROJECT_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'sdetail/ReadyBatch.hppanddetail/ScopeGuard.hppjoined itsHEADERSfile
set:EventLoop.hppandtesting/ScriptedBackend.hppinclude them, so the installed headers
could not compile without them.core::tuilinks stb_image as$<BUILD_INTERFACE:...>, and
core::testing_mainnames its dialog object through the installedcore::testing_dialogs.core-cpp.installinstalls 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) withCORE_CPP_INSTALLon and off.
Fixed
-
core::testing::suppressWindowsDialogs()keeps abort()'s message and turns off Windows Error
Reporting's UI. It cleared_WRITE_ABORT_MSGwith_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;WerGetFlagsconfirms the flag is set), for an unhandled structured exception in a
process whose error mode was reset (found by morph).windows-dialog-canary.abortnow 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 toEventLoop::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'sAbandonIfPeerGone-- 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).resumeSoonfrom 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, andcancelPendingfinds 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::spawnunlinked a finished flow by the frame its ready entry
named, and a flow parked inside a sub-task (co_await leaf(), withleafon 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 inspawnedCount()-- until~EventLoop, on
normal completion and onrequestStopalike. A long-lived loop spawning one flow per connection
grew without bound (found by the contour migration, measured on 0.2.1).spawnnow 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 (afterco_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::claimWriteSlotand the watch slots of
EventLoop::registerParkenforced it withassertalone: underNDEBUGthe 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 throughcore::detail::fail
(so a program'score::setFailHandlerlogs it first), naming the direction and the socket's
native handle, in Debug and Release alike.claimReadSlotandclaimWriteSlottake the handle
as an optional second argument. Thesocket-contract-canaryslot 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.
- Migration: a caller that armed a second read or write over a parked one was already broken; in
-
A host-driven loop may be destroyed while a pump is out with the host.
HostDrivenBackend
handedIHostScheduler::callAfterits own address, andemscripten_async_callcannot be
retracted: aPlatformLoopdestroyed under Emscripten with a timer armed, or after any off-turn
addTimer,postor 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.IHostSchedulerstates 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::ManualHostSchedulerdelivers whatever is still pending when it is destroyed,
including whatclear()took out, which is no longernoexcept; it is no longer copyable or
movable, since a copy would deliver a ticket twice.
core-cpp 0.2.1
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()besidebuildSgrSequence(),
TerminalOutput::resetStyle(), andprotocols::SgrReset. They are the byteswriteTextalready
ended styled text with, which were private, so Lightweight's dbtool spelled"\033[0m"itself. core::platform::SignalHandler::nativeHandle(): the signal fd as theNativeHandle
TuiRuntimeOptions::signalFdtakes -- the signalfd on Linux while initialized,InvalidHandle
everywhere else -- so a caller no longer convertsinitialize()'sint, which on Windows is the
wrong type for a handle.initialize()keeps itsintfor 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 ofcore::tui_output's own. WithCORE_CPP_WITH_TUI
off and this on, core-cpp builds the styled-output leaf by itself and neither finds nor fetches
libunicode. It defaults toCORE_CPP_WITH_TUIon a first configure, is forced on by it
(core::tuilinks 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-outputand aconsumer-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, soPosixSocket::close()-- andcancelRead(),IocpSocket's,
WindowsSocket::cancelRead(), andCompletionWait::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 executingclose()'s
caller: contour crashed on it deterministically, inNativeClient::detach(_writer.close(); _connection->close();, where the first close resumedrunClient, 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 toEventLoop::resumeSoon; an awaiter whose frame is destroyed while its
waiter is queued takes it back withcancelPending. The listeners already resumed a closed
accept through the loop's closed-park list, and TLS'sSerialGateits waiters since Task B11.
CloseResumesThroughLoop_test.cppholds it overBackendMatrix, 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, ablockOn). 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::InMemorySocketand
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, aCancelled
one or bytes a read already took. A flow must not touch a socket it does not own after its
operation resumes (ISocket::closesays 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 withOperationCancelledwhere they
used to write into freed storage -- WFMO'sWindowsSocket(its_readWaiter), and the TLS
layer, whosefeedInwrote 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,UnixListenerand WFMO'sWindowsListener, a
defect older than this release. The accept now asks the listener's lifetime token first and
answersCancelled("the listener was destroyed").IocpListenerkeeps what an accept reads
in state it shares, and was not affected. - Teardown:
~EventLoopdrains 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 (aDetachedTask) 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 INSIDEclose(), so code that closes two sockets in a
row through an object the read flow owns -- contour'sNativeClient::detach-- was exposed to
it. 0.1.0 is released, so the note is recorded here rather than there.
- Migration: a caller that asserted a parked flow's outcome right after
- On Windows,
listenUnixandconnectUnixbelong to the loop's transport (found through
contour).listenUnixbuilt the WFMOWindowsListenerwhatever the loop was, andconnectUnixa
WindowsSocket, whilelistenandadoptListenerbranch 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, whoseAcceptExaccepts AF_UNIX connections (measured on Windows 11,
asserted in CI) and hands outIocpSocket, andconnectUnixadopts 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: withSIGHUPignored, 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 thanEAGAIN, an end of file on a pipe or a file, an end of file on a terminal
thatpoll(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()andnextActivity()throwcore::async::OperationCancelled
without parking onceTuiRuntime::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 (tuiduc20bcac) 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'sPrompt::readis the example -- it catches
OperationCancelledand returns an empty line, and its REPL reads again for as long as the
prompt is ready. tuidu'srunModal(tui/runtime/Modal.hpp) is the other: it returns
std::nullopton the cancellation, so a caller that shows the modal again onnulloptspins
the same way. TerminalInput::poll()records the end ininputClosed()too (a hangup with nothing to read on
POSIX, a failed wait on Windows), but a loop driven bypoll()itself must ask it; nothing ends
that loop for it.
- Migration: a consumer that treats a cancelled or empty read as "try again" must treat the
- Piped output no longer carries synchronized-output sequences -- restores
SyncGuard's
purpose, bracketing a frame for the TERMINAL that renders it.TerminalOutput::syncGuard(), and
aSyncGuardconstructed directly, wroteCSI ? 2026 h/lwhatever the destination was, so
every caller had to testisTerminal()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'sisTerminal()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.
- Migration: a capture that wants the sequences answers
tools/migrate/rewrite.pyrewrites 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.jsongains the
crispy::Overloaded->core::Overloadedrow the migration guide already listed (contour).- **A vendored copy configures wit...
core-cpp 0.2.0
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_readyof seven public awaiters answers aconstexprconstantfalse:
core::net::DelayAwaiter, the awaiterwhenAllandwhenAnyreturn,AsyncQueue<T>::PopAwaiter,
and the fourTuiRuntimeawaiters (which also becamenoexcept). It is the fix for
fastcached#1546 under Fixed: the decision it made isawait_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.) Aco_awaitbehaves as before; code that calledawait_ready()
directly to learn whether an await would park getsfalsewhere it gottrue-- for instance
sleepUntil(nullptr, t).await_ready()-- and core-cpp's ownSleepUntil_test.cppand
AsyncQueue_test.cppasserted exactly that. It stays aconstmember rather than astatic
one, which clang-tidy'sreadability-static-accessed-through-instancewould report at every
co_awaitin a caller's code.ResultAwaitable::await_readystill answers whether the operation
settled inline.Migration:
co_awaitthe awaiter, and never callawait_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'sSleepUntil_test.cpp
assertstrueon its own copy, and changes with the migration.
Fixed
-
EventLoop::runUntilIdleandtesting::TestLoop::drainno longer return while the waiters of
a closed handle are still queued.notifyHandleClosingrecords 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 throughwaitReadable/waitWritable-- woken and not yet run. A
caller that tore its objects down next left~EventLoopto 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 onerunUntilIdleto finish it. -
An
OperationCancelledthrown out of aco_awaitondelayorsleepUntil, and on twelve
other awaiters, is caught again under MSVC 19.44 on ARM64
(fastcached#1546). That compiler's
ARM64 code generator drops the enclosingtryof aco_awaiton a temporary awaiter whose
await_readymakes a call, so the exception passed a typedcatchandcatch (...)alike and
reached whatever awaited the flow. The newwindows (cl-release-arm64)leg then found the same
loss with no call at all: anawait_suspendthat returned the awaiting coroutine's own handle,
followed by anawait_resumethat threw --Task's awaiter over a task owning no frame, whose
std::logic_errorpassed the awaiting coroutine'scatch. SoTask's two awaiters decide in
their constructor, where the awaiter is made by theco_await, andawait_readyreads the
answer.ResultAwaitable, every socket operation's awaitable, transfers back into an
OperationCancelledfor a flow already stopped too; on the same leg it keeps its handler, which
CancelRead_test.cppnow holds.core::net::DelayAwaiter::await_readyread the clock through
the virtualIClock::now(). The 0.1.0 notes say this awaiter carried fastcached's fix when
TuiRuntimemoved onto it; it did not, and everydelayandsleepUntilhad the shape. Every
await_readyinsrc/corenow reads a member or answers a constant, and the decision it made is
the constructor's orawait_suspend's, asked before the flow's stop token is read, so what aco_awaitobserves is
unchanged (a direct call ofawait_ready()is not; see Breaking): an elapsed deadline, a null loop, a finished task, an emptywhenAll, 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>::AwaiterandTask<void>::Awaiter,
whenAll's andwhenAny's,AsyncQueue::PopAwaiter,ResultAwaitable, the threaded resolver's
and the TLS serial gate's, andTuiRuntime'sNextInputEventAwaiter,NextEventForAwaiter,
NextActivityAwaiterandNextAgentReadyAwaiter. -
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/accepton POSIX, the WFMO listener'sacceptand the IOCP
listener'sAcceptExhanded 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 aredetail::applySocketBufferSizes's, before the connection exists.WindowsSocket::native()joinsPosixSocket::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 usedSO_EXCLUSIVEADDRUSE. It uses that too
now, and fails the bind if the option is refused.BackendKind::Wfmois not the Windows
default, so only a caller that asked for it by name was exposed. -
testing::TestLoop::pendingSubmissions()andpendingTimers()count what was handed over
between turns. Asubmitorschedulefrom 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
EventLoopaccessors,inboundSubmissionCount()andinboundScheduledCount()--
cancelPendingalready searched that queue, so the answer no longer depends on which thread
submitted. Both counters lostnoexcept: taking the lock can throw.
Changed
- A
PosixSocketkeeps 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 anEPOLL_CTL_ADDand anEPOLL_CTL_DELper request -- and fastcached's GET benchmark ran
6.8% slower onEventLoopthan on its own epoll reactor (geomean of 24 scenarios, -22% at the
worst). The socket's parks now ask forRegistrationLifetime::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, andclose()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 noepoll_ctlat 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.cppcounts 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 astd::unordered_mapand,
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,ResultAwaitableregisters 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
EpollBackendno 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...
core-cpp 0.1.0
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.