Changelog¶
All notable changes to core-cpp are recorded here. The format follows
Keep a Changelog, and versions follow
Semantic Versioning. While the major version is 0, a minor
release may break the API; every break is listed under Breaking with a migration note. A
release tag vX.Y.Z equals the version in project(core-cpp VERSION X.Y.Z), and the release
workflow refuses one without a section here.
[Unreleased]¶
Added¶
-
core::tui::runtime::InputSource, the TUI runtime's one dependency-injection seam now that the waiting iscore::net::EventLoop's: it names the handles to watch and decodes what is ready behind them, and has nowait().TerminalInputSourceis the production implementation over aTerminal-- header-only, with no platform body, because everything it asks is already portable throughTerminalInput.core::tui::runtime::testing::ScriptedInputSourceis the test double, which scripts decoding alone and can be pointed at acore::platform::SystemPipeso the same case runs over every backend a platform builds.TuiRuntimeOptionscarries the interrupt wakeup, the POSIX signal fd and the escape-flush interval, andTuiRuntimegainsloop()andnotifyAgentReady(). -
core::tuihas no translation unit that chooses its platform with an#ifdef, and no platform directory underruntime/at all.runtime/PollEventSource.cppwas the last one, and the twoTerminalEventSourcebodies were the last of those directories; the multiplexing all three held is the event loop's, on every platform..agent/rules/platform.md's rule -- an OS difference is an injected implementation, never an#ifdefin logic -- holds here by construction rather than by review. -
core::async::asTask(awaitable)(<core/async/AsTask.hpp>) — wraps any awaiter in aTask, for the one caller shape an awaitable cannot serve: one that must store the operation, keep it across a suspension point, or hand it to a combinator. It costs a coroutine frame, so it is an explicit call rather than an implicit conversion. -
ctest -R socket-contract-canary— three processes (read-slot,write-slot,empty-read-buffer) that drive a REAL socket into each of the socket contract's Debug guards and must die. Each is judged on a marker naming its own mode, printed immediately before the guarded call, and fails on a marker printed after it: a process that died on its way to the call is then not read as a guard that fired. They skip (77) where assertions are compiled out. Writing them found a defect they now guard: an operation created and never awaited left the socket's slot naming freed storage, and the nextclose()dereferenced null — in Release, where the guard that catches the usual spelling is compiled out. -
A syntax-check for platform sources this configuration does not otherwise compile (
core_cpp_add_unbuilt_source_check()incmake/CoreCppHeaderSelfCheck.cmake).core::netpicks oneDefaultBackend.cppfrom five, and itselse()arm —posix/— is reached only on a POSIX platform that is not Linux, not a BSD, not Apple, not Windows and not Emscripten, so no CI leg and no local preset compiled that file at all: a rename or a dropped include would have broken it silently until somebody ported core-cpp. It is now parsed for diagnostics everywhere else, and it is the only such arm in the tree. What a green check means is deliberately narrow and is written in the function's own comment: the file still parses, against the host's headers rather than the target's, with no link step, so it catches bit-rot and not a wrong implementation. The platform that takes the branch remains untested. -
Every public header is self-contained, and the build now proves it. Each header in a module's
FILE_SET HEADERSis compiled as the first and only include of a translation unit of its own, so a header that needs a neighbour included first fails the build naming itself and what it needed..agent/rules/cpp-guidelines.mdhas always required this and nothing enforced it: all eighteen hygiene rules read text, none compiled anything, so such a header passed every check and broke only for whoever included it first — which in a library is a consumer (core-cpp#31). The list comes from the build rather than a glob, so a header added later is covered without anyone remembering, and one a platform excludes from its file set is excluded here too. Private headers (detail/,posix/,windows/and the other platform directories) are in no file set and stay out of scope: an implementation header may assume its includer. -
The CMake framework: a module table that enforces the layering between modules, a dependency table resolved from the parent project, then
find_package, then CPM, per-target toolchain tables (pedantic warnings,CORE_CPP_WERROR, sanitizers, coverage, clang-tidy), and no global state unless core-cpp is the top-level project. core::testing(Windows dialog suppression, usable without a test framework),core::testing_dialogsandcore::testing_main, a Catch2main()whose exit status is 0 when everything passed, 1 when anything failed or Catch2 reported an error, 77 when every test case skipped, and 2 when nothing ran.- Configure, build, test and workflow presets for clang, GCC, AppleClang, MSVC and clang-cl, the
sanitizers, clang-tidy, coverage and Tracy, and an
emscriptenpreset for single-threaded WebAssembly whose tests run under node. - Checks over the tree: the CMake and C++ hygiene rules with their self-test, the exit-code
contract, and
tests/cmake/check-release.cmake, which the release workflow runs on a tag. - The documentation site, the API reference, the rulebook in
.agent/, and the CI workflows. - The module table's
PLATFORMScolumn takesany,nativeorwasm-subset, and a module may listSOURCES_EMSCRIPTEN;SOURCES_POSIXis not compiled under Emscripten, which setsUNIX. core::base(namespacecore): contract checks (Require,Guarantee), an injectable process environment (core::Environment, withcore::testing::FakeEnvironmentincore::testing), escaping, FNV hashing, type-safeFlags,times(), the password-database entry, string and range utilities,Overloaded,Deferred, Base64 (core::base64), the Tracy profiling macros (CORE_ZONE_*) and thecore::ranges::Iota/FoldLeftseam. It owns the generatedcore/Config.hpp.core::log: categorised logging (Category,Sink,configure()), its sinks and formatters (ScopedOutput,ScopedCapture),fatal()andSoftRequire(), which report through it, andisStdOutTerminal()/isStdErrTerminal()— the one place the platform is asked whether a standard stream is a terminal, which is what decides colourisation.core::cli: the command-line parser (core::cli::parse, help and usage text) and the application scaffoldcore::cli::App.- The Tracy dependency, 0.14.1 as contour pins it, resolved when
CORE_CPP_WITH_TRACYis on:core::basethen linksTracy::TracyClientand theCORE_ZONE_*macros record zones. A fetched client is built withTRACY_ENABLEandTRACY_ONLY_LOCALHOST. CI builds and tests theclang-tracypreset. core::testing_mainapplies theLOGenvironment variable tocore::logbefore it runs the tests (LOG=netenables thenetcategory and writes it to standard output), and so linkscore::log.core::platform, the operating-system layer: one clock seam merged from endo's, contour's and fastcached's (IClockwithnow()and a virtual no-oprefresh(),SteadyClock,CachedClock,ManualClock,IWallClock,SystemWallClock,ManualWallClock,WallClockRef,defaultSteadyClock(),defaultSystemWallClock()),Types(NativeHandle,isTerminal(), ...),PlatformError,Wakeup,SignalHandler,SystemPipe,WinsockInit,MessageQueue,FileSystemandNativeFileSystem,FileInfoProvider,EnvironmentProvider,UserPaths,PathUtils,GlobMatch,FileUri,SystemInfoandStringUtils, with the test doublestesting::InMemoryFileSystem,testing::MockFileInfoProviderandtesting::TestEnvironmentProvider, andnativeEnvironmentProvider()andnativeFileInfoProvider(), which give a composition root the private native implementations: Windows' own, and one POSIX provider each for Linux, macOS, the BSDs and Emscripten (endo'sLinuxFileInfoProvider, which used nothing Linux-specific, isPosixFileInfoProvider). Under single-threaded Emscripten its row sayswasm-subset: Types, PlatformError, Clock, StringUtils, PathUtils, GlobMatch, FileUri and the POSIX providers build, and their tests run under node.core::async, header-only and including nothing but the standard library; it links Threads, which is what itsStopTokenfallback'sstd::mutex,std::condition_variableandstd::this_thread::get_id()need, and nothing at all under single-threaded Emscripten. fastcached's executors arrive with Task B1.core::Generator<T>incore::base:std::generatorwhere the standard library has it and is not libstdc++, otherwisecore::detail::GeneratorFallback<T>, which is tested on every platform. It needs only the standard library, so it lives in base rather thancore::async, wherecore::async::Generatorwould read as an asynchronous,co_await-able stream.core::async::StopToken,StopSource,StopCallback<F>and theconstexprtagNoStopState(<core/async/StopToken.hpp>):std::stop_token,std::stop_source,std::stop_callback<F>andstd::nostopstatewhere the standard library defines__cpp_lib_jthread, and otherwise core-cpp's implementation with the standard semantics, which keeps plain state under single-threaded WebAssembly. libc++ before 20 has<stop_token>only behind-fexperimental-library(emsdk 3.1.56's libc++ 17, FreeBSD 15's base Clang 19, AppleClang 17 (measured in CI)), and core-cpp adds no compile flag to its consumers, so the fallback runs there, with real threads everywhere but WebAssembly. The configure log of a build with tests says which branch the toolchain takes.CORE_ASYNC_FORCE_STOP_TOKEN_FALLBACKselects the fallback everywhere; the test binarycore-cpp-async-fallback-test(ctestcore-cpp.async-fallback) is built with it, so the fallback is tested on every platform, ThreadSanitizer included.core_cpp_add_test()takesNAME, for a module's second test binary, andDEFINITIONS, the compile definitions of that binary alone.- contour's coroutine vocabulary in
core::async:Task<T>, lazy and awaited once, whose promise carries theStopTokenit inherits from the awaiting coroutine;detail::UniqueCoroHandle;OperationCancelledandthisCoroStopToken()(Cancellation.hpp); theAwaiterandHasStopTokenconcepts (Awaitable.hpp);whenAll(), which joinsTask<void>s and rethrows the first failure once all have finished; andwhenAny(), which resolves to astd::optional<std::size_t>, the index of the first to complete, and cancels the others. Their tests also run over theStopTokenfallback, andcore-cpp.async-link-smokelinkscore::asyncalone over it, which is the link a consumer makes. ATask's symmetric transfer is a tail call with Clang and MSVC at every optimisation level, with GCC only when it optimises sibling calls, and not in WebAssembly without-mtail-call. So awaits that complete synchronously grow the stack: at GCC-O0both a nested chain and a loop of 100000 of them overflow an 8 MiB stack, at GCC-Og/-O1the nested chain does, and under emsdk 3.1.56 the nested chain exceeds node's call stack. The deep-chain test is skipped under Emscripten without-mtail-call, and on GCC unless the build's optimisation level is-O2or better (core-cpp#15). core::testing:ScopedTempDir,ScopedWorkingDirectoryandEnvHelper(setTestEnv(),unsetTestEnv(),ScopedEnv).core::setProcessEnvironmentVariable()andcore::unsetProcessEnvironmentVariable()incore::base: the one writer of the process environment, in place ofsetenv(). On POSIX they publish a newenvironblock underLiveEnvironment's lock and never free a published one, so a reader elsewhere never sees a block change or disappear under it.core::net, contour's event loop, sockets, TLS and HTTP server, as contour has them but for the namespaces andcore::platformin place of contour'snet/platform/:EventLoopover an injectedEventSource(poll everywhere, epoll on Linux, kqueue on macOS and the BSDs,makeDefaultEventSource()),ISocketandIListenerwithlisten(),connect(),listenUnix(),connectUnix()andadoptFd(), descriptor passing on POSIX,AsyncBufferedReader,WriteQueue,SplitSocket,withTimeout(), an HTTP/1.1 server, the diagnostic sink, and the test doublestesting::ScriptedEventSource,testing::makeSocketPair(),testing::AllBackendsandtesting/CoroTestSupport.hpp. Its error vocabulary,NetErrorandIoResult, is the header-onlycore::net_types, which builds under Emscripten too; the rest is native only until Phase B, which also replaces theEventSourceAPI withIoBackend.core::netlinksThreads::ThreadsPUBLIC, because its headers usestd::mutex.core::net_tls(<core/net/Tls.hpp>), withCORE_CPP_WITH_TLS: a TLSISocketover any other, behindITlsContext, in server, client (a pinned CA and a host name, or trust on first use) and self-signed form, andconstantTimeEquals(). It links OpenSSL PRIVATE, and no OpenSSL type appears in its header.- The OpenSSL dependency, taken from the system and never fetched, resolved when
CORE_CPP_WITH_TLSis on. - Every Linux, macOS and BSD preset turns
CORE_CPP_WITH_TLSon, and CI installs OpenSSL where it builds them, socore::net_tlsis built and tested on Linux, macOS and FreeBSD as well as incl-release-tlson Windows. Those presets now need OpenSSL's development files. core::net::EventLoopcalls its clock'srefresh()before it computes a wait's timeout and after the wait returns, ascore::platform::IClockasks of whoever owns a loop, so aCachedClockcan drive it. contour's loop did not, because contour'sIClockhad norefresh(); forSteadyClockandManualClockit does nothing.- A module may declare further targets in the module table, each with a row of its own
(
core_cpp_module_target()), where itsPLATFORMS,WHENor links differ from its module's: a native-only module is entered under Emscripten when one of its targets builds there, andcore_cpp_add_test(<module> NAME <target>)links that target and builds where it does. A row'sDEPSare all its target may link, another target of the module or a module its module's row lists, and a row withoutDEPSlinks no core-cpp target; the configure refuses a row or a link outside that by name (core::net_typeslinks nothing,core::net_tlsonlycore::net). core::tui_output(CORE_CPP_WITH_TUI, native only), the leaf of endo's terminal UI: styled output and cursor, screen, scroll-region, sixel, OSC 52 and OSC 8 control throughTerminalOutput, whosewriteToDestination()a subclass overrides to retarget the stream and whoseisTerminal()says what that stream is;SyncGuard(DEC mode 2026), which brackets the output it was made from;buildSgrSequence(); the protocol sequence constants and the DA1 reader incore::tui::protocols;CursorShape; and the module'sResult/VoidResult. It linkscore::baseand nothing else — no libunicode, no coroutines, not evencore::platform— so a program that only prints styled text takes nothing else with it, and its row in the module table is what refuses any other link.- The libunicode dependency (0.9.3,
unicode::unicode) whenCORE_CPP_WITH_TUIis on, and stb (stb_image,DOWNLOAD_ONLY, pinned to a commit because stb publishes no releases) whenCORE_CPP_WITH_IMAGESis on. Both are off under Emscripten. A fetched libunicode is built withBUILD_SHARED_LIBS OFF, as endo pins it: its target is linked PUBLIC fromcore::tui, so a consumer configured for shared libraries would otherwise get a shared libunicode behind a static core-cpp. A first configure withCORE_CPP_WITH_TUIon fetches libunicode from GitHub and libunicode's configure then downloadsUCD.zipfromwww.unicode.org— core-cpp's only fetch outside GitHub. core::tui, endo's terminal UI (f774a210), native only:TerminalInputandVtParserover the Kitty keyboard protocol, SGR mouse reporting, bracketed paste and focus tracking;Terminal, which pairs input with output and owns the bounded query round-trips on an injected clock;Buffer,Canvasand the diffingScreen(inline, full-screen and fixed viewports); the components (InputField,List,TreeTableView,Dialog,StatusBar,LogPanel,Spinner,ProgressBar,Tooltip,QuestionComponent, the completion, command-palette and fuzzy-picker popups);core::tui::completer;MarkdownRendererandGenericSyntaxHighlighter; sixel encoding, and withCORE_CPP_WITH_IMAGESthe stb-backed loader, scaler andFilesystemImageProvider; andcore::tui::runtime, whoseTuiRuntimedrove coroutines against anEventSource(TerminalEventSource,PollEventSource,runModal(),withTimeout()). Test doubles:MockTerminalOutput,runtime::testing::MockEventSourceandTestHelpers.hpp. The runtime was rewritten ontocore::net::EventLoopbefore release and those types are gone; see Breaking below.runtime/TuiRuntime.hppand its test came from fastcached's copy (5389e29a), which carried one fix endo has not taken back:DelayAwaiter::await_ready()is a constant and an elapsed deadline is decided inawait_suspend(), because MSVC 19.44's ARM64 code generator loses the enclosingtryof aco_awaiton an awaiter whoseawait_ready()reads the clock through a virtualnow(). That awaiter iscore::net's now, and carries it.- The global property
CORE_CPP_TARGETS: every compiled library core-cpp built, by its real target name, in the order the module table declares them. A parent project that instruments its build reads it and applies the same sanitizers or coverage to core-cpp's code, which is what keeps ThreadSanitizer from reporting races between instrumented and uninstrumented code. Header-only targets and test binaries are not in it. cmake/CoreCppVendor.cmake, the vendoring tool of the design spec's Part I §5:cmake -DMODE=sync -DREF=<tag> -DDEST=<dir> [-DREPO=<url or path>] ["-DMODULES=<a;b>"] -P ...copies the file set out of git's own blobs (-c core.autocrlf=false -c core.eol=lf), refusing a CR byte, a symbolic link and a submodule, and writes aMANIFESTof SHA-256 hashes with LF endings whatever the host, because the consumer commits that file;MODE=checkre-hashes a copy and refuses a hash mismatch, a missing file, an unlisted file and a manifest that is not one -- an unparsable line, a missing# repositoryor# refheader, a# committhat is not 40 lowercase hex digits, and a# filescount that is absent, is not a number, is zero or disagrees with the lines below it -- needing no git, because a consumer runs it in its own CI. A sync assembles the new copy inDEST.core-cpp-vendor-new, a sibling ofDEST, writes itsMANIFESTthere, and touchesDESTitself only once that copy is complete; every refusal deletes the sibling on its way out, so a copy a refused sync found still passes its own check with nothing new beside it. The replacement is then two directory renames throughDEST.core-cpp-vendor-old(cmake/CoreCppVendorReplace.cmake) rather than a file-by-file move into an emptiedDEST, so whichever of the two directories exists when a sync stops -- for any reason, including being killed -- is a whole copy that passes its own check, andDESTis never half of each nor unfinished. A rename that fails -- on Windows an open handle, a lock or a scanner can fail one -- puts the previous copy back and refuses; if that restore fails too the refusal names both directories, deletes neither, and says that either can be adopted by renaming it. ADEST.core-cpp-vendor-oldleft by a previous run holds the only copy of what was there, so a sync refuses rather than delete it to make room. It refuses aDESTthat is not one of ours -- a directory with files and no manifest, or a regular file where a directory belongs -- and it refuses what it cannot copy correctly: aREFthat is not a tag or a full 40-character SHA, a localREPOthat is not the root of its own repository, a ref whose tree is not core-cpp's, and aMODULESlist that omits a module the ref's own table builds unconditionally. The last three are one mistake seen from three sides -- running sync with a vendored copy's own script, whereREPOdefaults to the copy's directory and git reads the consumer's repository instead.tests/cmake/check-vendor-selftest.cmake(ctestcore-cpp.vendor-selftest, labelhygiene) proves every one of those judgements by name against repositories it builds for the purpose, and skips rather than fails where git is absent. The file set is the spec's, plus everything else directly insrc/core/-- that module'sCMakeLists.txtandConfig.hpp.in, without which the copy does not configure. File modes are outside the contract.docs/vendoring.mdis the contract.- Consumer smoke tests, one project per way core-cpp is consumed, and the
consumer-smokeCI job that runs all three (ci-okrequires it):tests/consumer-cpmadds core-cpp with CPM and asserts that doing so changed none of its own flags, launcher or include directories, thatCORE_CPP_TARGETSnames every compiled library and no test binary, that no core-cpp target -- the header-only ones included, which is where an interface-scoped usage requirement would show -- carries a PUBLIC or INTERFACE flag, and that no test of core-cpp's was built;tests/consumer-vendoredbuilds a vendored copy of the commit under test withCORE_CPP_FETCH_DEPS=OFF,CORE_CPP_WITH_TUI=OFFandCORE_CPP_WITH_TLS=ONinside a container with no network and no git, and registers the verbatim check as one of its own tests;tests/consumer-wasmbuilds the WebAssembly subset behind one INTERFACE library, as morph does, runs it under node with emsdk 3.1.56, and refuses a build in which any core-cpp target links threads -- read off those targets'LINK_LIBRARIES, becausefind_package(Threads)inside core-cpp creates a target the parent scope cannot see. The loopback echo and thecore::logline the CPM and vendored programs share aretests/consumer-shared/ConsumerSmoke.hpp; each program keeps only what is its own. -
cmake/portable/CompileCache.cmakere-synced verbatim from fastcached5a9dca0498f4c37c63a17270550ee51ca87ae0a3(cmake/portable/README.md), fixing the nightlydownstream.ymldrift check. Upstream addedFASTCACHE_AUTO_INSTALL_HOST_SYSTEMandFASTCACHE_AUTO_INSTALL_HOST_PROCESSOR: empty by default, so_fc_auto_install_select_row()still asksCMAKE_HOST_SYSTEM_NAME/_PROCESSOR, but a caller can state the host to fetchfastcache-ccfor instead, which letsscripts/check-compile-cache-autoinstall.cmakepin a published platform per row rather than stopping at whichever host actually runs the check.cmake/FetchTransferBound.cmakecompared identical at the same commit; no change there. -
core::platform::NativeFileSystemtakes its rename primitive at construction:RenameFunction,nativeRename()and a constructor defaulting to it, soinstance()and every existing caller are unchanged. It is the one filesystem call the class takes rather than makes, and it is injected becauserename()'s two-hop lettercase retry only runs on a volume that refuses a case-only rename outright -- which ext4, APFS, NTFS and UFS all do natively, so the retry was unreachable from any test. It moves a consumer's entry through a temporary name and can leave it there when both the second hop and the rollback fail, which is not behaviour that may ship untested (controller ruling R53). -
core::net::NetErrorCodeis the merged vocabulary of both lineages, so a caller of contour'snet::NetErrorCodeor of fastcached'sFastCache::NetErrorCodehas a code for every failure it used to distinguish. From fastcached it gainsAddressNotAvail(a bind whose address is not available locally),HostUnreachandPermissionDenied(a low-numbered port without privileges, a firewall'sEACCES) — three causes that were an unclassifiedOtherhere and that no caller could match on. Nothing in core-cpp returns those three yet: the errno and WSA tables that classify a socket failure gain their rows when fastcached's sockets and dialler are merged in (Tasks B6 to B8), so until then a migrated== HostUnreachbranch compiles and is dead code. The codes are here now because the vocabulary is settled before the backends are rewritten on top of it.core::net::isDeadlineExpiry(NetErrorCode)joins it, also from fastcached (IsDeadlineExpiry): a deadline armed withSO_RCVTIMEO/SO_SNDTIMEO, and a poll given a timeout, expire asEAGAIN/WouldBlockon POSIX and asWSAETIMEDOUT/Timeouton Winsock, so the question is asked through one predicate over both operands rather than open-coded (fastcached#824). A trailingNetErrorCode::Laststates how many codes there are, so a table or a test covers every one of them without restating the list; it is not a code,toString()gives it no description, and nothing constructs or returns it. A new code goes above it, never below — one appended afterLastwould satisfy both the switch and the count while every walk of[0, Last)missed it, so a test refuses that case by name.core::net_typesstill links nothing and still includes no<format>: it is whatfastcache-cclinks alone in Task C4. -
tools/migrate/, the tooling every consumer migration runs:renames.json, the rename table;rewrite.py --profile contour|endo|tuidu|fastcached, an idempotent codemod over its include, namespace, symbol, member and macro rows, anchored so thatnet::never matches insidestd::net::,endo::net::ormynet::and so that a string literal is left alone; andsemantic_rename.py, which renames a member through libclang only where the declaration it refers to is the one named, sosock.Read(moves wheresockis aFastCache::ISocketand another class'sReaddoes not.check-renames.py(ctestcore-cpp.migrate-renames, labelhygiene) holds the table to the tree: every core-cpp symbol a row names must exist in the delivered headers, and a row still waiting on a Phase B task must not exist yet, so a rename that forgets the table fails the build and names the row to update. The cases are stdlibunittest(ctestcore-cpp.migrate-codemods); thestyleCI job installs libclang's Python bindings and fails on a skip, so the semantic pass is tested for real. None of this is part of the library: no target links it and no consumer builds it. -
ruffis pinned like clang-format and clang-tidy, and the repository's Python issnake_case, formatted and linted with it:.ruff-versionstates the release,scripts/tool-versions.pyinstalls it and refuses a mismatch,scripts/python-style.py --checkruns both halves and reports both before failing, and thestyleCI job runs it beside clang-format's.ruff.tomlsets the line length to.clang-format'sColumnLimit, so a Python file and the C++ beside it wrap at the same column and one number governs both, and it states ruff's default rule set (E4,E7,E9,F— undefined names, unused imports, import and statement errors) rather than inheriting it, so a future ruff cannot widen or narrow the gate by changing its mind about the default. Nothing stylistic is selected: layout is the formatter's job, and the linter never rewrites. The wrapper refuses any ruff but the pin, because its output changes between releases: an unpinned ruff reformats a file that CI then reports as unformatted, and finds one more thing on a version nobody chose. The# noqacomments are gone with it — a diagnostic-muting comment is the Python spelling ofNOLINT. Nothing here enters a consumer's build, so there is no row incmake/CoreCppDependencies.cmake. -
core::asyncgains fastcached's executor and ownership vocabulary, merged onto contour'sTask(the design spec, Part I §2, item 6). New headers, all header-only and all in the WebAssembly subset but the last: <core/async/ParkedWork.hpp>:ParkedWork, the pair of the coroutine to resume and the chain root an executor may free if it never resumes it, withdetail::Parked, the container entry that owns the second for as long as it holds it, anddetail::unownedRootOf/detail::parkedWorkFor, which derive the answer from the parking coroutine's own promise.<core/async/DetachedTask.hpp>:DetachedTask, a coroutine started for its effects whose frame nobody owns -- the one shape an executor may free at teardown.<core/async/SyncRun.hpp>:syncRun(task), which drives a self-driving task to its end and throwsstd::logic_errorrather than reading a result a still-suspended task does not have, andsyncRunWith(task, retrieve), which takes the park back first so the refusal is the whole of the failure.<core/async/IExecutor.hpp>:IExecutor, withsubmit(std::coroutine_handle<>)(borrows) andsubmit(ParkedWork)(carries what may be freed). Every class deriving from it saysusing IExecutor::submit;, andParkedWork_test.cppasserts at compile time thatsubmit(ParkedWork {})reaches the owning overload (fastcached#1041).<core/async/ResumeOn.hpp>:co_await ResumeOn { executor }, which continues the awaiting coroutine wherever that executor runs things.<core/async/AsyncQueue.hpp>:AsyncQueue<T>, a queue one coroutine parks on and any thread pushes to, withAsyncQueueOptions(capacity and aDropOldest/DropNewestoverflow policy),AsyncQueuePush, and apop()that resolves tostd::optional<T>.push()is[[nodiscard]]: itsAsyncQueuePushis the only report of a drop, and a discarded one is the silent loss the type exists to prevent.push()andclose()never resume the consumer inline; they hand its handle to the executor.pop()is stop-aware: a cancel from the awaiting flow's own token throwscore::async::OperationCancelled, while an item already queued and aclose()both answer first.<core/async/ThreadPoolExecutor.hpp>: anIExecutorwhose "somewhere else" is a fixed set of threads, for work that blocks. It is the one header of the module a single-threaded WebAssembly build does not get -- it refuses to compile there by#error, and is in noFILE_SETand in no test binary of that build.core::async::Task<T>::release()anddetail::UniqueCoroHandle<Promise>::release()hand the owned coroutine frame to the caller.-
core::async::CarriesUnownedRoot(<core/async/Awaitable.hpp>) is the second concept a templatedawait_suspendreads the awaiting promise through, besideHasStopToken: a promise that carries the root of an await chain nobody owns.Task's promise carries it, and so do thewhenAllandwhenAnyrunners, so a coroutine parked underneath a combinator still states what an executor may free. -
renames.jsongains aremovedkind, which runs the drift gate backwards: the row names a symbol core-cpp deleted, carries notoand notarget, andcheck-renames.pyasserts the symbol stays absent from the delivered headers, so a re-introduction is refused. It exists because a removal that changes the shape of a call, rather than just its name, must stay a compile error at the consumer's call site instead of becoming a codemod that rewrites it into something that compiles and is wrong — while the row'snotestill carries the migration instruction beside every other rename the same pull request applies. The schema refuses such a row that carries ato, atargetor anyapplybutnone, so no rewrite tool can be handed one. The first two rows arecore::tui::LanguageId::Endoandcore::tui::registerEndoHighlighter(). -
A
removedrow'sfrommust be the fully qualified core-cpp name, and the schema now refuses anything else. It is the one kind whosefromis a core-cpp name — every other kind's is the consumer's spelling — sonet::FdToken, the form the neighbouring rows teach, was the natural mistake, and its failure was silence: the gate reads a bare name as a macro and a two-component name as a namespace nothing opens, finds neither, and reports the symbol absent. The row then passed for ever while naming a type sitting in the tree.core::tui::runtimestill declaresFdInterest,FdToken,WaitOutcome,FdRegistrationandFdRegistryuntil Task B12, so the guard has live work to do, and all eight rows were qualified by discipline rather than by construction. -
A
macrorow that names only atarget.headeris checked against it: the header must still name the macro, by defining it or by testing it. Two rows are of that shape (CORE_GENERATOR_FORCE_FALLBACK,CORE_RANGES_FORCE_FALLBACK) and they are deliberate — the macro is one a consumer defines and core-cpp only asks about — so the assertion is "consults", not "defines", and a mention in a comment does not count. -
core::net::IoBackend(<core/net/IoBackend.hpp>), the readiness seam the event loop drives, withmakeDefaultBackend(),makeBackend(BackendKind)andpreferredBackendKind(). A backend DISPATCHES:wait()invokes the callbacks on theReadinessHandlers registered with it, and those callbacks only enqueue — every coroutine is resumed by the loop, on the loop's thread, after the wait has returned.selectReadinessCallback(handler, readiness)is the pure rule that picks one callback per registration per wait and routes a hangup or error toonError, or, for a handler that has none, to whichever direction it watches; it is a free function so it is tested without a kernel.setInterest()answersstd::expected<void, NetError>, so a kernel that refuses a registration is reported instead of leaving the caller parked on one it never made (fastcached#1054, fastcached#1057), anddetach()withdraws the handler from the ready batch a wait in flight is walking, which a kernel's own deregistration cannot do (fastcached#475).wake()is on the interface and is its one thread-safe member, so the wakeup channel belongs to the backend rather than to the loop. The backends arePollBackend(POSIX),EpollBackend(Linux),KqueueBackend(macOS and the BSDs) andWfmoBackend(Windows,WSAEventSelect+WaitForMultipleObjects); each header is private, and a program reaches one through the factories.BackendParity_testruns one scenario against every backend this platform builds. core::net::testing::NullBackend, which accepts registrations, reports nothing and never blocks — a loop driven entirely bypost,spawnand timers, and what Task B4'sTestLoopwill be built on.-
core::net::EventLoop::parkedWaiterCount(), besidependingTimerCount(): the same leak assertion for a readiness park. A flow that resumed or unwound without unregistering leaves its handler attached to the backend, and a count that never returns to zero is how that shows. -
.agent/guides/consumer-migration.mdcarries the byte-identity proof a consumer pull request runs to show a mechanical pass was mechanical: for every file the commit modified, re-derive the post-image from the pre-image by applying the substitutions the author asserts by hand — never the codemod's own report, and never the codemod again, or a tool that is wrong about a row is wrong identically on both sides and proves itself correct — and compare with whitespace stripped. It catches an unintended rewrite and a hand edit mixed into a codemod commit; it does not catch a correct rewrite to a wrong target, which is what the drift gate is for. -
core::net::HostDrivenBackend(<core/net/HostDrivenBackend.hpp>) and theIHostSchedulerseam behind it: the backend for an event loop that does not own its thread. It does not block — there is nothing to block on inside a browser, and under single-threaded WebAssembly nothing to block with — so the loop is PUMPED instead.attachandsetInterestanswerNetErrorCode::Unsupported,wait()returns at once, andwake()andarmWakeAt(deadline)ask the host for a pump throughIHostScheduler::callAfter(delay, fn, state), coalescing several requests into one and clamping a deadline already past to a zero delay. It is portable and is tested on every platform overcore::net::testing::ManualHostScheduler, because a behaviour observable only in a node run is one nobody reads;core::net::EmscriptenHostScheduler(emscripten_async_call, which is the browser'ssetTimeout) is whatmakeDefaultBackend()uses there. Filing work from outside a turn is safe on such a loop, which is the position a DOM event handler, a frame callback or a TUI input path is in:EventLoop::registerParkasks the host for the turn that will reach the park — and soaddTimer,schedule,co_await loop->delay()andinterruptibleSleepUntil()all do, including from an eagerly-startedcore::async::DetachedTask— as doresumeSoonandrequestStop. Without it the work is filed, correct, and never run: the host is armed at the END of a turn, so a quiescent host-driven loop has nothing coming that would arm it. Backends that are not host-driven are unaffected. -
core::nethas a WebAssembly subset: its module row iswasm-subset, and under single-threaded Emscripten it builds theIoBackendcontract,HostDrivenBackend, the pure logic behind them and the test doubles — and links noThreads::Threads, which would force-pthreadand SharedArrayBuffer onto every consumer. The event loop, its timers and the sockets join in Tasks B4 and B5.core-cpp.net_backendis the test binary that runs everywhere, Emscripten included;core-cpp.netkeeps the cases that need a loop, a socket or a descriptor. -
Callback timers on
core::net::EventLoop, and nothing in core-cpp polls for a deadline any more.addTimer(deadline, callback, state) -> TimerIdandcancelTimer(TimerId) -> boolarm and retire a deadline with no coroutine frame behind it. A callback timer is a park in the SAME table as aco_await delay()— one heap, one sequence counter, one never-reused id space — so the loop has a single answer to "when is the next deadline" and a single firing order across the two kinds. Step 5 of the turn queues both; step 2 runs both, which keeps the one place that resumes a coroutine also the one place that calls out to a timer callback.cancelTimeransweringtruemeans this call prevented the callback, which includes the window between a deadline firing and its callback running: an owner destroyed in that window would otherwise have its callback run against storage that is gone.RunOnceResult::drained(and sotesting::TestLoop::tick()) counts what step 2 took off the ready queue — coroutines resumed plus timer callbacks run — because a turn that ran a callback and resumed nothing is not an idle turn, andrunUntilIdle()would otherwise stop on one. It is named for what it counts rather than for what happens to only half of that: a timer callback is called, not resumed.TimerCallback,ParkEntry::onCallbackand the diagnosticEventLoop::pendingTimerSlotCount()— the size of the deadline heap including the stale slots lazy pruning is carrying — are public with it. -
core::net::DeadlineTimer(<core/net/DeadlineTimer.hpp>): a deadline as an object, disarmed bydisarm()or by destruction, and destroyable from inside its own callback. For a timeout that has to tear an operation down rather than merely stop waiting for it — a dial that only stopped waiting leaves the connect attempt in flight for the kernel's own retry schedule. Ported from fastcached, without its coroutine frame, itsshared_ptrstate or its 50ms poll interval: those existed because a scheduled resumption could not be taken back, and here it can. -
core::net::interruptibleSleepUntil(loop, token, deadline)andcore::net::WakeReason(<core/net/InterruptibleSleep.hpp>): sleep to a deadline or until a stop token is stopped, whichever comes first. It parks once and the stop callback wakes it, where upstream slept in steps ofwakeBoundand re-read the token at each one. The supplied token is reported asWakeReason::Cancelled; the awaiting flow's OWN token throwscore::async::OperationCancelled, as every loop awaitable does — and where they are the same token, the reported answer wins. -
core::net::sleepUntil(EventLoop*, deadline)andcore::net::nextWakeStep()(<core/net/SleepUntil.hpp>). The freesleepUntiltakes a nullable loop, for a caller with no deadline mechanism behind it (an in-memory transport): a null loop or a deadline already gone resolves inline, without suspending.core::net::DelayAwaitergains a constructor takingEventLoop*for it; the existingEventLoop&one is unchanged. -
tests/wasm/HostDrivenTimer_smoke.cpp, run under node in theemscriptenjob on both emsdk versions: aPlatformLoopon a real host, with a coroutinedelayand aDeadlineTimerparked on it, advanced by nothing butemscripten_sleepyielding to the host.tests/consumer-wasmruns the same scenario as a consumer and linkscore::net. Both are judged by their OUTPUT rather than their exit status, because a WebAssembly program that leaves a pendingemscripten_async_callbehind — which an armed deadline always does — exits 0 whatevermainreturned; the reasoning, and the two fixes that do not work, are intests/wasm/CMakeLists.txt. -
core::net::IocpBackend, the Windows completion-port backend, and the readiness bridge that lets one wait serve a server's sockets and a TUI's console input. Reachable asmakeBackend(BackendKind::Iocp); available by name, not yet the default —WfmoBackendstays whatpreferredBackendKind()answers until the sockets that issue overlapped operations on a port arrive, because moving every Windows consumer onto a completion port that nothing completes on buys nothing. The header is private, like every other backend's.
A completion port reports completions and has no notion of "this handle is readable", so
readiness is synthesised, and each kind of handle needs its own source: a waitable HANDLE
(console input, an event, platform::SystemPipe's wakeup) gets a thread-pool wait whose
callback does nothing but PostQueuedCompletionStatus; socket readability is a zero-byte
WSARecv, the Winsock idiom for "complete when data is pending, consuming nothing"; socket
writability goes through WSAEventSelect for FD_WRITE and then through the first bridge.
Where the kernel exports NtAssociateWaitCompletionPacket — a GetProcAddress probe at
startup, never a link against ntdll, and its absence is an ordinary answer — the first bridge
needs no helper thread at all. BackendParity_test runs the whole shared matrix against it, and
gains a Windows case that registers CONIN$ on every backend: that one wait serving both a
console handle and a socket is the thing neither upstream had, and it is why fastcached kept a
second coroutine runtime.
-
ReadinessHandler::slot(core::net::detail::ReadinessSlotRef,<core/net/detail/ReadinessSlot.hpp>), and the ownership rule written beside it. A completion-based backend hands the kernel a pointer and gets it back on a later turn — an operation the caller has since cancelled still completes — solpOverlappedpoints at a backend-owned, refcounted slot and never into the handler, which by then may be freed. The handler holds one share for the length of its registration; each in-flight operation holds another; a packet arriving afterdetachfinds the slot retired and drops without reading anything of the handler's. A readiness backend leaves the field empty and nothing notices; an owner never reads it. The design spec declared it and Task B3 left it out, because a public field with no reader has no defined meaning — it arrives with its first writer rather than ahead of one. -
core::net::ICompletionPort(<core/net/ICompletionPort.hpp>), what a completion-based backend lends the sockets that sit on it, and the one place a handle is associated with a port — which is what makes guarantee G4 (a SOCKET is associated with exactly one port) assertable.CreateIoCompletionPortrefuses a second association withERROR_INVALID_PARAMETER, which is also what it answers for a closed handle and half a dozen ordinary mistakes, so a caller reading that back would condemn a working connection; the port keeps the record itself and refuses by name. An owner that CLOSES a handle must callforget(), or the next socket handed that value looks already associated and is then associated with nothing.IoBackendgainscompletionPort(), defaulted tonullptr; it is declared on every platform rather than behind#if defined(_WIN32), because a public header that changes shape per platform is one a consumer's build can disagree with this one about. -
Guarantees G1 and G4 are asserted on the Windows port, and each has a canary.
core-cpp.iocp-canary.g1callswait()from a second thread while another is dequeuing;core-cpp.iocp-canary.g4associates one handle with one port twice. Each is a separate program, because an assertion aborts the process and so cannot be provoked from inside a test case; each is judged by a marker it prints tostderrimmediately before the forbidden call, and both SKIP where assertions are compiled out. They exist because neither violation fails on its own: IOCP is designed to be drained by many threads, and a lost association is a socket awaiting completions that are delivered elsewhere — a hang with nothing in any log. -
Three CI legs that never existed, and the gate that makes their absence fatal. Every visible configure preset must now be named by a workflow or allowlisted with a written reason;
core-cpp.preset-coverage(labeltree-level, with a 15-case self-test) refuses a preset no job runs, an allowlist entry for a preset a job now runs, an entry for a preset that no longer exists, and a workflow naming a presetCMakePresets.jsondoes not define.
It was written because three presets were run by nothing, and all three were Debug:
gcc-debug, clangcl-debug, and appleclang-debug — which was the only Debug configuration
macOS had. So NDEBUG was defined in every macOS job and all 30 runtime assertions in
src/core were compiled out of the whole platform — 19 of them in the shared event-loop code,
among them the twelve teardownIsSerialisedWithDispatch() thread-affinity checks in
EventLoop.cpp and ReadyBatch's re-entrancy trap — and both canaries abstain with 77 under
NDEBUG. kqueue is macOS-exclusive, so those shared checks had never once been evaluated with
kqueue underneath them — on the platform Ruling R101 exists because of. The count is of runtime
assert() only: the 122 static_asserts fire at compile time and Require()/Guarantee() are
not NDEBUG-gated, so neither family was ever dark.
All three legs are added: gcc-debug to linux, appleclang-debug to macos, clangcl-debug
to windows. The LLVM-version floor on the Windows job now covers every clang-cl leg rather than
the release one alone, or the new leg would have built with the runner's bundled clang-cl,
silently below the project's floor of 22.
A configuration absent from CI does not fail there — it is simply not present, and an absent gate
reads exactly like a passing one. .agent/rules/build-and-toolchain.md states it as a property
rather than a preset list, because a list there would decay the way four earlier enumerations in
this module did.
Deprecated¶
core::net::interruptibleSleepUntil(loop, token, deadline, wakeBound), the four-argument form, is kept for one release so a fastcached caller compiles unchanged, and ignoreswakeBound. It named the longest uninterruptible step of a poll, and there is no poll left to bound. Drop the argument. It carries no[[deprecated]]attribute deliberately: the overload exists so a fastcached caller compiles unchanged, and the attribute under that consumer's own-Werroris exactly what would stop it doing so. What reports a migration in this project istools/migrate/renames.jsonand the codemods, not the compiler, and the row is already there.
Breaking¶
core::tui::runtime::TuiRuntimeis composed oncore::net::EventLoop, and the project no longer carries a second scheduler. The runtime had its own ready queue, timer min-heap, park slots,pumpOnceand blockingEventSource; all of it is the loop's now, and every scheduling member ofTuiRuntimeforwards there. What is genuinely the TUI's and stays is input semantics: the decoded-event buffer,nextEvent()/nextEventFor()/nextActivity()/nextAgentReady(), and the interrupt policy.core::tuilinkscore::netas a result, and the module table'stuirow carries it.
Gone with the second scheduler: runtime::EventSource, runtime::PollEventSource,
runtime::TerminalEventSource, runtime::WaitFdAwaiter, runtime::withTimeout,
runtime::testing::MockEventSource, and the readiness vocabulary they shared --
FdInterest, hasInterest, FdToken, WaitOutcome, FdRegistration, FdRegistry. The
headers runtime/EventSource.hpp, runtime/PollEventSource.{hpp,cpp},
runtime/WithTimeout.hpp, runtime/posix/PollHelpers.hpp,
runtime/testing/MockEventSource.hpp and all three TerminalEventSource files are deleted;
tools/migrate/renames.json carries a row for each, with what to write instead.
Migrations, in the order a caller meets them:
- Construction.
TuiRuntime(EventSource&, IClock&)becomesTuiRuntime(core::net::EventLoop&, Terminal&, TuiRuntimeOptions = {}), orTuiRuntime(EventLoop&, InputSource&, …)where the input is injected. The clock is the loop's, so aManualClockis given to the loop rather than to the runtime. Destroy the runtime before its loop, on the loop's thread: its source flows are parked on the loop and name it, and the destructor is what takes them back. - The agent wakeup has no handle. Where a
core::platform::Wakeupwas passed toTerminalEventSourceand waited on, a worker now callsloop.post([&]{ runtime.notifyAgentReady(); })-- the loop's own cross-thread surface. ThenextAgentReady()andnextActivity()vocabulary is unchanged. - The interrupt wakeup is
TuiRuntimeOptions::interruptWakeup, and the POSIX signal fd isTuiRuntimeOptions::signalFd.core::platform::SignalHandlerrecords the signal and signals the wakeup exactly as before; what changed is that a flow parked on that handle runs the policy, rather than a branch inside a hand-built wait set. runtime::withTimeout(&runtime, …)iscore::net::withTimeout(&runtime.loop(), …). The two were the same construction over two schedulers.waitReadable/waitWritablereturncore::net::WaitHandleAwaiterand take an optionalcore::net::HandleKind. They throwcore::net::FdRegistrationFailedwhere the backend refuses a handle, which the old awaitable flattened intoOperationCancelled-- so a caller that catches onlyOperationCancellednow lets a plumbing failure escape, which is the point: the two were indistinguishable and are different facts.delay/sleepUntilreturncore::net::DelayAwaiter, androotStopSource(),clock(),spawn()andblockOn()are the loop's. Preferloop.requestStop()overrootStopSource().request_stop(): it also unparks what it cancels.- A headless runtime replaces
PollEventSource. AnInputSourcethat reportsplatform::InvalidHandlefor its input handle starts no input flow, and the socket work that used to need a headless event source belongs tocore::net::EventLoopdirectly. -
Tests.
runtime::testing::MockEventSourceis replaced by two doubles that are each one thing:core::net::testing::ScriptedBackendscripts readiness (or a realcore::platform::SystemPipeprovides it), andcore::tui::runtime::testing::ScriptedInputSourcescripts decoding. -
core::net::ISocket's operations are frame-free, stop-aware awaitables rather thanasync::Tasks.read,write,writeVectored,waitReadableandreadWithFdreturncore::net::ResultAwaitable<R>(IoAwaitableis the byte-count spelling), andhandshakeIfNeededreturnsResultAwaitable<void>. Awaiting one allocates no coroutine frame, which is what lets a server hold a parked read per connection without paying a frame per idle connection. New on the interface, arriving from fastcached:writeVectored,waitReadable,cancelRead,shutdownWrite,setReceiveDeadlineandhandshakeIfNeeded. Also new:core::net::SocketResult, andcore::net::contract::{requireReadBuffer, claimReadSlot, claimWriteSlot, assertTeardownIsSerialisedWithDispatch}— the socket contract's guards, public because a transport outside this library is under the same rules.
Migration, for a caller that only awaits. Nothing changes: co_await sock.read(buffer) works
against an awaitable exactly as it did against a task.
Migration, for a caller that STORES the operation. An awaitable is a one-shot temporary bound
to its co_await expression — it cannot be held across a suspension point, put in a container or
handed to whenAny, because nothing but the awaiting frame keeps it alive. Wrap it:
loop.blockOn(sock.read(buffer)) becomes
loop.blockOn(core::async::asTask(sock.read(buffer))), and likewise for
whenAny(sock.read(buffer), …). core::async::asTask (new, <core/async/AsTask.hpp>) costs
exactly the frame the awaitables avoid, which is why it is a call at the site that needs one
rather than an implicit conversion every site gets.
Migration, for a caller that IMPLEMENTS ISocket. A transport whose operations are genuinely
coroutines — a TLS record pump, a scripted test double — keeps its coroutine and wraps it:
IoAwaitable read(std::span<std::byte> b) override { return IoAwaitable { readTask(b) }; }, where
readTask is the old Task<IoResult> body unchanged. The awaiting flow's stop token still
reaches it through Task's own awaiter. A transport that wants the frame-free path passes an
arm hook, a retire hook and an owner pointer instead. writeVectored and readWithFd have
working defaults, so an existing implementation need not grow them.
Migration, for a caller of readWithFd. Unchanged in behaviour: the base default still reads
through read and reports fd = -1.
- Cancellation of a socket operation now distinguishes the flow from the resource. A stop on
the awaiting flow's own token throws
core::async::OperationCancelled; a cancel from the socket —close(),cancelRead()— resolves withNetErrorCode::Cancelledas a value.
What changed, precisely. A read that was already parked when close() arrived used to
resume, re-check the socket and report BadHandle; it now reports Cancelled. A read issued
after the socket was closed still reports BadHandle, unchanged — the two were the same code
before and are now distinguishable, which is the point. So a caller that branched on BadHandle
to mean "somebody closed this socket under me" must add Cancelled; a caller that used it to mean
"this socket is not usable" needs no change.
And the flow side. A stop on the awaiting flow's own token used to surface as whatever the
loop's waitReadable awaiter threw, from inside the read's retry loop; it is now
OperationCancelled out of the socket operation itself. A caller that never caught it and only
inspected the error value now has to catch it around a read it can cancel. Design spec §2 item 5.
core::net::ParkEntrygains a frameless readiness park, and with itcore::net::ReadyCallbackandcore::net::ParkWake.ParkEntry::onReadyCallback(callback, state, handle, kind, interest)files a park that calls back rather than resuming a coroutine, and — unlike a timer park — SURVIVES its own dispatch, so its owner can run a retry loop across many wakes and retire it withunregisterPark. It is what makes a socket operation frame-free, and it is a park in the same table as every other kind, so it inheritsnotifyHandleClosing,requestCancel's generation check,registerPark's host-wake arming and the turn's decision to enter the backend wait. Nothing existing changes shape: aParkEntrybuilt throughonDeadline,onCallbackoronReadinessbehaves exactly as before.
It does NOT inherit ~EventLoop's teardown, and an earlier version of this entry said it
did. A callback park has no coroutine to resume, so teardown step 2 skips it and
unparkEverything excludes it by its !entry->parked test — both deliberately, because calling
it would reach an owner that is being destroyed. The consequence for a caller: a socket
operation still parked when its loop is destroyed is neither completed nor abandoned, and the
awaiting coroutine is never resumed and never unwinds. So destroy sockets before the loop they
were created on, which is already the documented ordering for every loop-owned object.
core::net::EventLoopis the merged reactor contract: a five-step turn, a six-step teardown, a park table and the thread-affinity guarantees, all asserted. It implementscore::async::IExecutor, so anything that takes an executor --ResumeOn,AsyncQueue, aTaskchain -- takes a loop. What arrives with it:run(),runOnce(),runUntilIdle(),stop(),submit()andschedule()in both the borrowing and the owning form,cancelPending(),resumeSoon(),registerPark(),unregisterPark(),requestCancel(),running(),isOnWorkerThread(),teardownIsSerialisedWithDispatch(),IdlePolicy,EventLoopOptions,RunOnceResult,ParkEntry,core::net::PlatformLoop(which ownsmakeDefaultBackend()) andcore::net::testing::TestLoop(the real loop overNullBackend, driven by hand).ParkIdwidens from an fd wait to every kind of parked work, and it is the generation check: ids are never reused, so a cancel request for a park that has gone resolves to nothing.
Migrations, in the order a caller meets them:
WaitFdAwaiterisWaitHandleAwaiter, andwaitReadable/waitWritabletake a second, defaultedHandleKind. Call sites that wroteautoorco_awaitchange nothing; one that named the type changes the name.delay()takes acore::platform::SteadyDurationrather thanstd::chrono::milliseconds. A5msargument converts; a caller that stored the parameter type changes it.blockOn()drives its task to completion, blocking the calling thread while the loop is idle, and returns only when the task finishes. An idle turn waits on the backend rather than polling, so a flow thatco_await ResumeOn { pool }s and comes back completes here — and a flow nothing will ever advance waits rather than burning a core (core-cpp#17, which Task B12 had been carrying for the TUI runtime). On anIdlePolicy::Returnloop, which is one somebody else drives a turn at a time, the wait cannot block and such a flow still spins;testing::TestLoopforces that policy, so drive it withrunOnce()orrunUntilIdle().- A coroutine resumed by readiness or by a deadline resumes one turn later, in the next
turn's step 2, because there is exactly one place a loop resumes and that is what makes
guarantee G2 stateable. A test that counted waits, or that used
blockOn(trivialTask())as "pump once", counts differently now;runOnce()is what drives one turn. ~EventLoopfrees what the loop owns and resumes what it borrows. ADetachedTaskparked on a loop that is destroyed is FREED, not run on: it carries no stop token, so resuming it would not cancel it, it would run the rest of its body on a loop that is going away (fastcached#1025). A flow whose frame aTaskowns is resumed, observes the stop and unwinds, as before.EventLoop::run()andblockOn()are precondition violations on a host-driven loop and assert. They are compiled under WebAssembly rather than removed, so the mistake is an abort with a message rather than a link error in a consumer's build.IoBackendgainssetPump(HostCallback, void*), defaulted to a no-op besideisHostDriven()andarmWakeAt(). A backend outside this repository need not implement it; a host-driven one that wants a loop to pump must.- Six loop-thread-only members now assert their thread affinity —
spawn,resumeSoon,requestStop,registerPark,unregisterPark,wakeReasonOf, pluscancelPendingandnotifyHandleClosing. They mutate the loop's own containers with no lock and no inbound queue to hand to, so a call from a second thread while another drives tears astd::listor rehashes a map underneath a turn.spawnis the one to check first when migrating fromsubmit: it looks like it and is not.core-cpp.hostdriven-canary.spawnOffThreadproves the family fires.resumeSoon's documentation previously named thread-pool callbacks among its callers, which the assert aborts — usesubmit(async::ParkedWork), the same operation with the cross-thread hand-off. FdRegistrationFailedgains aNetError reasonmember carrying what the backend refused with.attach()andsetInterest()both return the kernel's reason so it is never swallowed, and the loop was flattening both tobool: a consumer debugging descriptor exhaustion could not tell it from a filter the kernel would not arm. Nothing constructs the type with arguments, so no call site changes; acatchthat wants the reason reads.reason.~EventLoopDROPS borrowed work still waiting in the inbound queue, and this is now stated rather than left to be discovered. What the loop owns is freed and what it borrows is resumed — in the ready queue and the park table, which hold work a turn has accepted. A submission the inbound queue still holds is an offer no turn took up, and the loop cannot tell what a borrowedstd::coroutine_handle<>names: a suspended flow that would unwind and a never-started lazyTaskthat would RUN are the same type, andResumeOn::await_resume()is noexcept, so resuming one runs its body against a loop that is being destroyed rather than unwinding it. The cost is real and worth planning around: a cross-threadResumeOn { loop }whose loop dies before the next turn leaves its awaiting flow suspended forever. Run one more turn before destroying a loop other threads have been handing work to. Posts are dropped for the same reason, and owned chains are still freed rather than resumed.- A readiness park keeps its handle registration until the park itself is taken. A park
whose waiter had been queued but not yet resumed was invisible to
notifyHandleClosing()for the turn in between, so its kernel registration was detached after the close rather than before it — against a descriptor number the kernel may already have reassigned.parkedWaiterCount()counts those parks again, which is what its documentation always claimed.
Consumer impact: contour, endo and tuidu all construct an EventLoop. The rename table
(tools/migrate/renames.json) carries net::WaitFdAwaiter, and fastcached's IReactor,
PlatformReactor, TestReactor and their members are marked delivered.
core::platform::testing::InMemoryFileSystemmodels a file's lifetime the way POSIX does, where it used to hand each stream a private copy. A stream now survivesremove()of its file and follows it acrossrename(),openRead()sees writes that land after it was opened, andcopyFile()onto an open destination overwrites in place rather than detaching the stream. Migration: a test that relied on a read stream holding a snapshot of the file it opened must read the file before the write, or re-open it after. The divergences that remain between this fake andNativeFileSystemare listed in core-cpp#27.core::tui's completion types move tocore::tui::completer, the namespace their directory names, assrc/core/tui/runtime/already givescore::tui::runtime. endo's TUI is one flatnamespace tuiand the import kept that, which core-cpp's namespace-equals-directory rule does not allow; it was invisible until Task A11 fixed the hygiene rule that only looked at the first directory segment. Migration, for each ofCompleter,CompletionConfig,CompletionProvider,CompletionItem,FuzzyMatch,FuzzyConfig,FuzzyMatchResult,SmartCaseMatchandSmartCaseConfig:core::tui::Completerbecomescore::tui::completer::Completer, and so on. The include paths do not change. Recorded here rather than under Changed because this file's preamble puts every API break under Breaking with a migration note (core-cpp#30).core::async::whenAny()resolves tostd::optional<std::size_t>rather than to astd::size_tthat wascore::async::detail::WhenAnyNoWinner(SIZE_MAX) when nothing won. The sentinel was part of the documented public result but lived indetail::, so handling the empty case meant reaching intodetail::, and a caller who forgot the check indexed a container atSIZE_MAX. Migration:auto const i = co_await whenAny(...);becomesauto const i = co_await whenAny(...); if (i) use(*i);, and any== detail::WhenAnyNoWinnerbecomes!i.has_value(). Nothing outside this repository reads the result yet.core::async::whenAll()andwhenAny()'s variadic overloads take their tasks by rvalue. The constraint was written overstd::remove_cvref_t, so an lvalueTask<void>satisfied it and then failed insidestd::vector::push_backonTask's deleted copy constructor. An lvalue or aconstrvalue is now "no matching overload" at the call. Migration:whenAll(std::move(task)), which is what every call already had to do to compile.core::platform::FileSystem::openWrite()takes acore::platform::WriteModeandcopyFile()acore::platform::OverwritePolicy, in place of thebooleach took before. Aboolin an API is an anonymous enum whose two values are named after their representation rather than their meaning (.agent/rules/design-principles.md), andFileSystem.hppis public API for every consumer, so this costs nothing now and would be a break once one of them passestrue. Migration:openWrite(p, true)becomesopenWrite(p, WriteMode::Append)andopenWrite(p, false)becomesopenWrite(p, WriteMode::Truncate);copyFile(a, b, true)becomescopyFile(a, b, OverwritePolicy::Replace)andcopyFile(a, b, false)becomescopyFile(a, b, OverwritePolicy::Refuse). The defaults are unchanged, so a call that took the default needs no edit; an implementation of the interface outside core-cpp mirrors the two signatures.-
core::platform::testing::TestEnvironmentProvideropens the namespace its directory names, alongside its neighboursInMemoryFileSystemandMockFileInfoProvider; it used to opencore::platform. Migration: spell itcore::platform::testing::TestEnvironmentProvider. -
core::Flags::operator&=intersects instead of clearing, which silently reverses what it answers. It calleddisable(), sof &= X::Akept everything exceptAwhilef = f & Flags { X::A }kept onlyA: the compound operator computed the complement of its binary form. Nothing in contour, endo, tuidu or morph uses it, so nothing has to change today, but the reversal is invisible at the call site — it compiles either way. Migration: a caller that wanted the old meaning spells itf.disable(X::A). An overload taking aFlagswas added too, so the pair is symmetric withoperator|andoperator|=. core::FNV's byte-wise overload takes only a type with unique object representations, which narrows what compiles. It accepted any trivially copyable type and walked its object representation, padding included, so two objects with equal members hashed differently depending on what their padding held. It now rejects any type with padding bits — a struct with interior padding, andfloatanddouble, whose representations have padding bit patterns. Migration: hash the members one at a time, or passstd::bit_cast<std::array<unsigned char, sizeof(T)>>(value), which is what the overload does for the types it still accepts. No consumer instantiates it with such a type: contour's and endo'sFNVuses all go through thechar,uint8_torstring_viewoverloads.core::base64::decodeLength()answers a different number for the same input: the size of the base64 prefix, where it used to size from the whole input including padding and any trailing junk. Migration: none for a caller that used it to reserve a buffer fordecode(), which is what it is for — the answer is still an upper bound, just a tight one. A caller that relied on the old over-estimate for something else wants its own arithmetic. endo sizes an image buffer with it (GeminiProvider.cpp) and was over-allocating.core::readFileAsString()answers a different string for the same file: exactly the bytes on disk. It sized fromfile_size()and read in text mode, so on Windows CRLF translation delivered fewer bytes than it had reserved and the shortfall stayed behind as trailing NULs; and it narrowed the path throughpath::string(), which cannot represent every name a filesystem accepts and throws on Windows for the ones it cannot. A missing file now answers empty rather than throwing, as its documentation said all along. Migration: a caller that trimmed trailing NULs off the result can stop; one that caughtstd::filesystem::filesystem_errorfor a missing path checks for an empty string instead. contour reads a CA certificate and a forced-DPI file through it.-
Every function in
<core/Escape.hpp>—escape()in all three of its spellings,escapeMarkdown()in both of its, andunescape()— pluscore::readFileAsString(),core::detail::Times::operator[]andcore::detail::Times2D::operator[]are[[nodiscard]]. Discarding any of them is a bug: none has an effect other than its return value. A consumer that does so and builds with-Werrorstops building. Migration: use the result, or cast it tovoidat the one call site that means to throw it away. The whole header rather than the one overload that changed behaviour, because the surprise would be the inconsistency —escape(text)is the spelling most likely to be called for its return value alone. -
core::net::ISocket::isClosed()answers what its documentation has always said: true onceclose()was called or a read observed the peer's EOF. NeitherPosixSocketnorWindowsSocketlatched the second half, so a consumer polling a connection whose peer had hung up was told for ever that it was still open, andSplitSocket::isClosed()("closed once either half is") inherited that. The contract is latched rather than narrowed, because the latched version is the one callers need.TlsSocketlatches its own EOF too -- aclose_notifyends the session whether or not the inner transport is still open. The latch is a flag of its own, so a peer that shut only its write side leavesread()andwrite()working exactly as before. Migration: a caller that usedisClosed()as "did I close this myself" asks its own bookkeeping instead; one that polled it to drop dead connections now gets the answer it wanted. core::net::FdInterest::Nonemutes a registration on everyEventSourcebackend, as its documentation says ("mute the fd without detaching it"). The Windows wait and kqueue already reported nothing for such a registration; poll(2) and epoll reportedPOLLHUP/POLLERR(EPOLLHUP/EPOLLERR) for it whatever interest was asked for, so a muted descriptor still woke its flow -- and on epoll it did so on every wait, since those bits are level-triggered, spinning the pump. A muted registration still counts as attached and is still found bydetach(). Migration: a caller that attached withNoneand relied on being woken when the descriptor died attaches withRead, which reports HUP/ERR as read-readiness by design.core::net::WriteQueue's constructor throwsstd::invalid_argumentfor a null socket rather than accepting it.ITlsContext::wrap()is documented to return null when it cannot allocate, and a queue built on that null constructed cleanly and crashed later inclose()-- which isnoexcept, so the failure landed at teardown, far from the call that caused it, and could not be reported at all. A constructed object is usable (.agent/rules/design-principles.md). Migration: checkwrap()'s result and drop the connection instead of queueing onto nothing.core::detail::Times2D::operator[]answers the same type itsvalue_typedeclares: astd::tupleof both coordinates, in the order iteration yields them (the inner range advances fastest). It answered the inner coordinate alone, so subscripting and iterating disagreed on what an element of aTimes2Deven is;operator[]changed rather thanvalue_type, because the tuple is what*italready yielded and what the existing case asserts. Migration: a caller that wanted the inner coordinate alone takes it out of the tuple —std::get<1>(grid[i]), orauto const [outer, inner] = grid[i];. Nothing in core-cpp or in contour, endo, tuidu or morph subscripts aTimes2D. While there:Times::size()andTimes::operator[], which nothing had ever instantiated, spell out the conversions their arithmetic implies instead of letting the compiler narrow silently.core::joinHumanReadableQuoted()'s separator is astd::string_viewrather than a deduced template parameter, so the= ", "default it declares can be taken:joinHumanReadableQuoted(xs)did not compile before. Migration: a caller that passed something other than a string formats it itself — the old signature rendered the separator withstd::format, so anintor acharwas accepted and now is not. Nothing calls it yet, in core-cpp or in any consumer.core::net::NetErrorCode::OtherisSystemError, andNetErrorCode::BadFileHandleisBadHandle. The merged enumeration takes one spelling per meaning, and these are the two the spec's rename map names: fastcached'sSystemErrorsays what the code is (an OS error nothing classified further — readNetError::systemCode) where contour'sOthersaid only what it is not, and contour'sBadHandlecovers the WindowsHANDLEand the waitable handle that fastcached'sBadFileHandledid not name.NetError's default code isSystemError, as it wasOther. Migration, for a contour or Lightweight caller:sed -i 's/NetErrorCode::Other/NetErrorCode::SystemError/g'; for a fastcached caller:sed -i 's/NetErrorCode::BadFileHandle/NetErrorCode::BadHandle/g'. Both rows are intools/migrate/renames.json. 53 call sites moved inside core-cpp, nearly all of themmakeNetError(Other, errno, …).toString(core::net::NetErrorCode::SystemError)is"system error", where contour'stoString(Other)was"network error". The description follows the code's name, and both change in the same release. Migration: a log filter or a test matching the exact textnetwork errormatchessystem errorinstead; nothing else in the rendering changed.-
core::net::NetError::toString()renders contour's shape for both lineages —connection reset (recv) [errno 104]— where fastcached'sNetError::ToString()renderedNetError(code=9 system=104 context=recv). A log line's shape is API for anyone grepping their logs, and fastcached's is the one that loses: itscode=is a position in an enumeration this release renumbered, so an old line and a new one that read alike would mean different codes, and a reader needs the header open to decode either. Droppingstd::formatalso keeps<format>out ofcore::net_types, which links nothing and whichfastcache-ccwill link alone. Migration for a fastcached caller:ToString()istoString();ToStringView(code), which gave the enumerator's name ("Eof"), iscore::net::toString(code), which gives the description ("end of stream") — a caller that wanted the identifier must map it itself. Anything parsingNetError(code=…)out of a log reads the words instead, and the OS number is still[errno <n>]. -
core::tuino longer ships one consumer's language.LanguageId::Endo,registerEndoHighlighter(), the.endorow ofExtensionLanguageTableand theendorow ofFenceTagLanguageTableare gone; an application teachescore::tuiits own language throughcore::tui::SyntaxHighlighterRegistryinstead — a registry it constructs, fills and passes to whatever renders the text, rather than a process-wide callback core-cpp holds on its behalf (core-cpp#24). Nothing about the built-in languages changed and a registry answers for them too, so a consumer that uses only those recompiles unchanged: every new parameter is trailing and defaults to "the built-ins alone". Two exceptions to that, both narrow: code that takes the address ofhighlightLine,detectLanguageFromExtension,detectLanguageFromFenceTagordetectLanguageFromPathsees a changed function type, because a default argument is not part of one; and a consumer that registers an extension or fence tag core-cpp later adds as a built-in will findregisterLanguage()refusing it withTokenInUseafter that upgrade — registering a token core-cpp might one day ship is a forward-compatibility risk the refusal makes loud rather than silent.LanguageIdgained a trailingLast— not a language, but how many there are, which anchors the newBuiltinLanguageTable— and the ids a registry issues begin atcore::tui::FirstRegisteredLanguageId(128).FilenameLanguageTable, the well-known-file-name tabledetectLanguageFromPath()consults first, moves from an anonymous namespace in the.cppinto the header beside the other two, so that all three of the module's built-in tables are public and pinned by the same golden test; it is the table that carried a consumer's-formatdotfile, and it was the one nothing guarded. Migration, for the one consumer that registered a language:
// was: a process-wide callback, and a closed enumerator naming one application's language.
core::tui::registerEndoHighlighter(highlightEndoLine);
auto const language = core::tui::LanguageId::Endo;
// is: one registry the application owns, filled once at startup and injected. Hold exactly one
// per program unless you keep each id with the registry that issued it -- see below.
auto highlighters = core::tui::SyntaxHighlighterRegistry {};
auto const registered = highlighters.registerLanguage({
.name = "endo",
.extensions = { ".endo" },
.fenceTags = { "endo" },
.highlight = highlightEndoLine,
});
// std::expected<LanguageId, LanguageRegistrationFailure>; *registered replaces LanguageId::Endo.
// and each entry point takes the registry, as a trailing argument defaulting to nullptr:
auto renderer = core::tui::MarkdownRenderer { output, theme, &highlighters };
auto const styled = core::tui::StyledText::fromMarkdown(text, width, &theme, &highlighters);
auto const detected = core::tui::detectLanguageFromPath(path, &highlighters);
auto const [map, next] = core::tui::highlightLine(line, detected, state, &highlighters);
registerLanguage() refuses a name, extension or fence tag another language already claims —
built-in or registered, including a well-known file name that would shadow the extension — and
refuses a token that could never match at all: an extension without its leading dot, or an
empty extension or fence tag (LanguageRegistrationError::MalformedToken). It refuses rather
than shadows, because replacing would repoint an id already issued and its holder would then
get a wrong answer that looks right. A refused definition leaves the registry exactly as it
was, and LanguageRegistrationFailure names the token at fault.
A registered LanguageId belongs to the registry that issued it. Ids are dense from
FirstRegisteredLanguageId in registration order and carry nothing that identifies their
registry, so passing one to a different registry is a precondition violation — the contract a
std::vector::iterator has with its container. If that registry issued an id in the same
position, the line is highlighted as its language, silently and wrongly; only an id past the
end of it gives plain text. A program that holds one registry, which is the shape this is
designed for, cannot hit it. Built-in ids are not issued by anybody and are portable: they
mean the same language in any registry and in none.
tools/migrate/renames.json carries both removals as kind: "removed" rows, which assert the
symbols stay absent rather than rewriting anything: the call shape changes, so a mechanical
rewrite would produce code that compiles into the wrong thing, and a compile error at
LanguageId::Endo is the better signal.
- core::async::Task<T>'s awaiter OWNS the task it awaits. operator co_await is rvalue-qualified
and now moves the frame out of the Task value into the awaiter, which holds it across the
suspension and destroys it at the end of the co_await expression; the Task that produced it is
empty afterwards. Until now the awaiter borrowed, and the Task value freed the frame on scope
exit. Migration: co_await someTask() is unchanged, since a temporary was already freed at the
end of that expression. Code that awaited a named local with co_await std::move(task) and then
read task.handle(), task.done() or task.result() must stop: the frame is gone and the name
holds nothing. The change is what lets an executor free an abandoned chain from its root, because
ownership in a Task chain now runs strictly downward.
- core::async::Task<T>::result() and its awaiter's await_resume() throw std::logic_error for a
task that owns no coroutine frame, where they used to answer with a default-constructed T;
Task<void>'s equivalents throw there too, where they used to return silently. done() is true
for a default-constructed, moved-from or released task as well as for a completed one, so
if (task.done()) task.result(); reaches this, and a default-constructed value is one the
coroutine never produced. With the T {} gone, T no longer has to be default-constructible.
Migration: a driver that asks for a result checks that it still owns a frame (task.handle()),
not only that done() is true.
core::net::IoBackendreplacescore::net::EventSource, and the shape changes with the name: a backend invokes the callbacks on aReadinessHandlerthe caller registers, where an event source returned two vectors ofFdTokens for the caller to look up.EventLooptakes anIoBackend&, and the post self-pipe it used to own is gone —post()callsIoBackend::wake(), which every backend provides, so the loop's constructor no longer throws and a backend's does when its wakeup channel cannot be created.
Migration, for contour, endo and tuidu, which all have callers. Every row is in
tools/migrate/renames.json:
| Was | Is |
|---|---|
EventSource |
IoBackend |
<core/net/EventSource.hpp>, <core/net/DefaultEventSource.hpp>, <core/net/PollEventSource.hpp> |
<core/net/IoBackend.hpp> |
FdInterest, FdInterest::None |
Interest, Interest::None (still "mute the handle without detaching it", and now that on every backend) |
makeDefaultEventSource(), makeEventSource(EventSourceKind), preferredEventSourceKind() |
makeDefaultBackend(), makeBackend(BackendKind), preferredBackendKind() |
EventSourceKind |
BackendKind, which gains Iocp, Wfmo, HostDriven, Scripted, Null and a Last sentinel |
PollEventSource, EpollEventSource, KqueueEventSource |
PollBackend and WfmoBackend (contour's one file, split along its #ifdef), EpollBackend, KqueueBackend — all private; reach one through the factories |
testing::ScriptedEventSource |
testing::ScriptedBackend, scripting readiness against a HandlerId handed out in attach order |
testing::AllBackends, testing::Backend |
testing::BackendMatrix, testing::BackendUnderTest (<core/net/testing/BackendMatrix.hpp>) |
source.attach(fd, interest) → FdToken |
backend.attach(handler) then backend.setInterest(handler, interest), each std::expected<void, NetError> |
source.detach(token) |
backend.detach(handler) |
source.wait(timeoutMs) → WaitOutcome |
backend.wait(std::optional<SteadyDuration>) → WaitResult, having already dispatched |
FdToken, WaitOutcome, FdRegistry and FdRegistration are gone with no replacement: a
handler's ADDRESS is its registration's identity. EventLoop keeps an id of its own for its
parks, core::net::ParkId, which registerFdWaiter() and unregisterFdWaiter() now take;
Task B4 widens it over every kind of parked work.
Two behavioural differences a caller can see. attach() no longer carries an interest, because
kqueue has no "register with no filters" operation and so cannot say whether the kernel accepted
the descriptor — only setInterest() can, and that is where a refusal is reported. And a
registration is serviced by at most ONE callback per wait, because a callback may leave the
object its handler is embedded in ready to be freed; level triggering reports whatever was
skipped on the next wait.
Changed¶
check-cmake-hygienereports how many files it checked, and refuses a count it cannot reconcile. The gate printed the number of files it found, which is not the number any rule ran over: the kind dispatch skips a file silently, so a defect there shrinks the checked set without moving the number and the gate goes on reporting success over less and less. It now holds the dispatch's own tally against an independent recount taken with a different CMake primitive, and fails naming both when they disagree. On this tree the message changes from447 file(s) under <root> are cleantochecked 445 of 447 file(s); the two it does not check are aREADME.mdand a.clang-tidy, which no rule reaches. A guard against zero would not have caught the failure this answers —scripts/check-upstream-drift.pyonce reported 350 rows where there were 351, because a substring match swallowed one and its total was printed rather than checked.core::net::selectReadinessCallbackroutes a failure to the watched direction, andReadiness::Failedis documented best-effort. The function returns exactly one callback, and it used to returnonErrorwhenever the kernel reported a failure — so a peer hangup on a socket with unread bytes still in it (POLLIN|POLLHUPon poll and epoll) would have returnedonErroralone the moment a handler set that field, the reader would never have been woken, and those bytes would never have been read. kqueue andWfmoBackendreport the same hangup as readable and were always right.onErrornow takes a failure only when no watched direction accompanies it, which is thePOLLERR-only failed connect it exists for. Nothing shipped with the old order andEventLoopsetsonError = nullptr, so no consumer can observe the change; Task B6 is the first code that would have. The portable guarantee, now pinned byBackendParity_teston every backend, is that a peer hangup wakes the direction the handler watches — a caller is woken, callsread()orwrite(), and learns what happened from that.Readiness::Faileditself is a hint and must not be branched on for correctness: the backends disagree and are each right to, sinceEV_EOFon a kqueue read filter is an ordinaryshutdown(WR)rather than an error.cmake/portable/CompileCache.cmakeis re-synced from fastcachedf6ec49f3446b8bc121eba82c64cde2de759e774a, andcmake/FetchTransferBound.cmake's pin moves to the same commit, where its content is unchanged. The whole delta is one diagnostic: withFASTCACHE_AUTO_START=ON, a daemon that exits immediately now has its first line of output printed beside the exit status, so(127)reads as the missing shared library it is rather than as "not found" for a binary this module has just staged and knows the path of (fastcached#1538). Launcher selection is untouched: a fresh configure on Windows (clang-cl) and in WSL (clang) still resolves to fastcache-cc, read offbuild.ninjarather thanCMakeCache.txt.-
core::async::whenAllandwhenAnyare one runner, one join state and one awaiter, parameterised by a policy (<core/async/Join.hpp>, all of itcore::async::detail). The two combinators had ~200 lines of near-identical coroutine, latch and start-phase code, differing in one step: what a child finishing does to the shared state. That step, the token each child observes and what the awaiting coroutine resumes with are whatWhenAll.hppandWhenAny.hppstill hold. No public name changes, and no behaviour does:whenAllstill surfaces the first escape from any child and cancels nobody,whenAnystill latches the first child to complete and unwinds the rest. Two things the collapse settled by making them one source: what escaped a child's task is recorded once, in the runner promise, wherewhenAll's wrapper used to catch it a second time in its own body; andwhenAll's join state is reference-counted likewhenAny's, so the lifetime rule that keeps a stop state alive across its ownrequest_stop()has one spelling rather than two. -
core::async::whenAny()reports a child that completed even when the awaiting flow's own token is stopped afterwards. It threwOperationCancelledwhenever that token was stopped, sowhenAny(readSocket(), timeout())whose read had completed and consumed bytes lost them if the cancellation landed before the last loser unwound;.agent/rules/async-and-net.mdsays the opposite, that a receive which already completed with bytes wins. It now throws only where no child completed at all. A child that swallows itsOperationCancelledand returns counts as one that completed, because nothing can tell the two apart: a loser must let the cancellation out, which is whatwhenAny's contract already asked of it. core::asynclinksThreads::Threads(interface), on the same conditioncore::baseuses, so a consumer writingtarget_link_libraries(app PRIVATE core::async)links what<core/async/StopToken.hpp>'s fallback needs. It linked nothing, which failed wherever pthread is a library of its own and the fallback branch is taken — libc++ before 20 without-fexperimental-library, so FreeBSD 15 and AppleClang 17. A single-threaded Emscripten build still links nothing..clang-tidy'sreadability-identifier-namingno longer exemptsrequest_stop,stop_requested,stop_possibleandget_tokenfrom the function naming rule: they are members ofstd::stop_tokenand friends, whichcore::async's fallback spells as the standard does, and a free function of one of those names is not a standard-library hook. TheClassMethodstyle is gone with its duplicate of that ~800-character regex; with noClassMethodstyle configured, a static member function falls through to theMethodstyle, which says the same thing.
Fixed¶
- The TUI runtime's four deferred defects, all closed by composing it on
core::net::EventLoop(Task B12). They were found reviewing Task A7's import from endo and deferred here because this task deletes or rewrites the code they live in; nothing shipped with them. - core-cpp#16: a cancelled
delayleft its entry in the runtime's timer heap. Itsawait_resumereset the stop-callback and unregistered nothing, so awhenAnyloser's heap entry outlived the frame it named and the next pass over the heap calleddone()on freed storage. Reproduced as a SIGSEGV in a plain debug build, not merely under a sanitizer. The loop's own awaitable unregisters its park on every resume, ready or cancelled, and the regression case assertspendingTimerCount()andpendingTimerSlotCount()are both zero afterwards. - core-cpp#17:
blockOn()spun at full CPU when nothing the runtime knew about was parked.pumpOnce()returned without waiting andblockOnlooped on it unconditionally.EventLoop::blockOnwaits on the backend instead. An outcome test cannot separate a loop that slept from one that burned a core, so the case asserts the ARGUMENT: every wait of an idle turn is indefinite, never zero. - core-cpp#18: a cancelled input
awaiter stranded the runtime's single waiter slot.
NextInputEventAwaiter,NextEventForAwaiterandNextActivityAwaiterarmed no stop-callback, so a cancelled waiter stayed parked until input happened to arrive -- after EOF on stdin, never -- and the next flow to ask for input found the slot taken. All four input awaiters now arm one, and a cancelled waiter releases its slot and its deadline. The case asserts the argument again: awhenAnywhose loser is parked on input must resolve with no wait at all. -
core-cpp#19: the Windows TUI event source failed hard past 64 wait handles. It called
WaitForMultipleObjectswith an unchecked count and mapped the refusal ontointerrupted, so a consumer watching ~60 descriptors saw the whole TUI unwind indistinguishably from Ctrl+C;PollEventSourcehad the same limit and span instead. Both files are deleted, andcore::net's Windows backend sweeps its set in chunks (detail/WaitChunking.hpp). Held by a case that parks 70 concurrent handle waits and requires every one to resolve. -
~TuiRuntimehandled two states of a source flow and there were three. Acore::async::Taskis lazy, so between the constructor and the first turn every source flow sits at its initial suspend point with its body not yet entered — and the destructor resumed it, which ran that body from the top and parked it on the loop, after which the frame was destroyed underneath the park.~EventLoopthen resumed freed storage. Each flow's loop now guards on the teardown flag before its firstco_await, so a flow started during teardown returns without parking, and the destructor's comment enumerates all three states rather than branching on two.
Reachable by constructing a runtime and destroying it with no turn in between — an error-return path, or construction and destruction inside one turn. No case in the suite reached it, because every one drives the loop first, which is why seven configurations and both sanitizers were green over it. A case that constructs and destroys with no turn now holds it.
-
A timed input wait could silently lose its timeout.
releaseInputWaiterretired_inputDeadlineunconditionally, but a waiter's deadline is retired where it leaves the slot, so by the time a queued waiter'sawait_resumeran the slot could already hold a different flow and its timer.nextEventFor/nextActivitythen waited forever on a deadline they had asked for and never got. The deadline is now retired only in the branch where the slot still holds the resuming waiter. -
A focus change no longer closes an open modal. The runtime woke its input waiter for any non-input activity, including a dispatched focus report -- but a
nextEvent()awaiter can only yield an event or throw, so it threwOperationCancelled, andrunModalcatches that and returnsstd::nullopt. Which waiters may be resumed with nothing is now stated ascore::tui::runtime::InputWakerather than inferred: only a waiter that can say nothing happened (nextEventFor,nextActivity) is told so. -
A lone Escape keypress is delivered. Disambiguating a bare
ESCfrom the start of an arrow key needs a clock, and the old runtime only called the parser's timeout hook when its multiplexed wait timed out -- which, for a flow parked onnextEvent()with no timer pending, meant an indefinite wait and a hook that was never called at all. The runtime now armsTuiRuntimeOptions::escapeFlush(50ms by default) after any read that decoded to nothing, and flushes when it elapses. -
scripts/clang-format.pyno longer destroys a file it was handed. The extension filter applied only to what--alldiscovered, never to a path the caller named, soclang-format.py x.cmakehanded a CMake file to clang-format, which parses its input as C++ whatever the name is, rewrote it, and reported1 file(s) formatted.--checkwas worse than blind: it failed the pristine file and passed the mangled one, so it pointed a caller at the damage and then certified it. Both scripts now refuse a path that is not theirs, by name and before either tool is looked up — refused rather than skipped, because a silent skip leaves a caller believing a file they named was formatted when nothing touched it.scripts/python-style.pyhad the same shape with a milder effect (ruff honours the extension and leaves the file alone, but reported it as formatted), and is closed the same way. Both are now held bycore-cpp.format-scripts-selftest, which proves each refuses every foreign shape by name and accepts its own — the absence of exactly that test is why this survived. -
core::async: a use-after-free when a chain parks again while an earlier park is being released.detail::claimOn()armed the chain's abandon state and then took its claim as two separate atomic stores, so a concurrentAbandonClaimrelease could observe a state that never existed as a whole — armed by the new park, count zero because that park had not been counted yet — conclude the chain was abandoned, and destroy a coroutine frame that was live. It fires wherever several children of one detached flow park on an executor at once, which is exactly whatwhenAllandwhenAnyover aThreadPoolExecutordo: measured at 36 crashes in 1920 runs of the async suite at 32-way concurrency, and at 15 and 14 for the two combinators individually. The park count and the armed flag are now one atomic word, with claim-and-arm and decrement-and-claim-the-destroy each a single compare-exchange, so armed is never observable without the claim that accompanies it. No API changed. -
A module-table row whose
WHENnames an undeclared variable is refused where it is declared, instead of silently removing its target.core_cpp_row_builds()evaluatesif(when AND NOT ${when}), soWHEN CORE_CPP_WITH_TSLexpanded toNOTan undefined variable — true — and the row stopped building: the target went, every testcore_cpp_add_test()registers against it went with it, and nothing said so. It configured clean, built clean, and a module was simply not there. The check isDEFINEDrather than "is anoption()", becauseCORE_CPP_USE_THREADSis a plainset()and is already used as aWHEN, so anoption()-only rule would refuse a condition this build uses today; a typo is undefined by construction, which is exactly what is now refused. Five scenarios incore-cpp.layeringcover it, including the two that must still configure — a declared option and a plain variable — because a check that refused everyWHENwould also "catch" the misspelling. -
tests/cmake/check-layering.cmakeincludesCoreCppOptions.cmakebefore the module table, in the order the real build uses. Its scenarios read a table whose rows carryWHENconditions naming those options while never declaring them; that was invisible until the check above started refusing an undefinedWHEN, at which point every scenario failed on the realnet_tlsrow. -
core::platform::testing::InMemoryFileSystemkeeps a name the platform's narrow encoding cannot spell in both directions. The keys were made UTF-8 earlier in this release, but twelve sites turned a key back into a path throughstd::filesystem::path's narrow constructor -- the ANSI code page on Windows -- solistDirectory(),walkDirectoryRecursive(),weaklyCanonical(), the walk's sort key and the parent-key derivations handed back a path that no longer named the entry it came from, and a symlink's target was narrowed on the way in as well. One helper now spells the way out, asnormalizePath()spells the way in. core::platform::testing::InMemoryFileSystem's streams no longer point into the file map. AwriteFile()through the filesystem reallocated the string under an open stream, andremove()orrename()took the entry away from under it -- a use-after-free in each case, on the fake every consumer's tests are written against. The content is shared now, and the read-write stream caches no pointer into it, so a file that changes behind a stream is read from where it lives.core::platform::testing::InMemoryFileSystem's streams supportunget()andputback(), which setbadbitwhile the buffer kept no get area forstd::streambufto satisfy a put-back from.unget()and aputback()of the character just read now answer asstd::ifstreamandstd::fstreamdo. Aputback()of a character the file does not hold is a case the standard leaves open -- only one put-back is guaranteed at all, and a different character is expressly permitted to fail ([streambuf.virt.pback]). libstdc++ and MSVC accept it; libc++ refuses, so macOS and FreeBSD differ from Linux and Windows. The fake accepts it, since a memory buffer with an exact position can always satisfy one, and keeps the character in a slot of its own rather than writing it to the file. Where it is deliberately more permissive than a real stream is listed in core-cpp#27.core::async::whenAny()no longer runs the rest of arequest_stop()on freed memory. Its parent→child cancel bridge requested stop on aStopSourcethat the awaiter held as a member; a child awaitable that resumes its coroutine from inside its own stop callback — how every runtime awaitable delivers cancellation — makes the losers unwind there and then, the last of them transfer to the awaiting coroutine, and that frame unwind, destroying the awaiter and with it the source whoserequest_stop()is still on the stack. The race state is now held byshared_ptrand every call into it that can run foreign code holds a reference for that call. This was a use-after-free whereverStopTokenisstd::stop_token, whose state a raw pointer reaches; core-cpp's fallback survived it only because itsrequest_stop()happens to hold ashared_ptrcopy of the state.tests/cmake/check-cmake-hygiene.cmake's namespace gate had two holes. It checked only the first namespace a file declares, although its rule is that every segment of every namespace is lowercase, sonamespace core::async { namespace Detail { ... } }passed clean. And it derived the expected namespace from the first directory segment undersrc/core/alone, so a file insrc/core/platform/testing/declaringcore::platformpassed although the rule is namespace = directory. The expected namespace now follows the whole path, with the platform and private-detail directories (posix/,windows/,linux/,bsd/,darwin/,emscripten/,detail/) skipped as layout — exactly the onescore_cpp_add_module()holds private headers in, and nothing else. The one thing in the tree the deeper rule found,src/core/tui/completer/declaringcore::tui, is fixed rather than exempted: see Breaking (core-cpp#30).Task_test.cpp's deep-chain case skips on GCC unless the build's optimisation level is known to make symmetric transfer a tail call. It keyed on__OPTIMIZE__, which GCC defines at-Ogand-O1as well, where the 100000-frame chain overflows the stack and kills the process, taking every other case in the binary with it — so a build outside core-cpp's presets lost the binary rather than getting a red.src/core/async/CMakeLists.txtnow reads the level off the build's own flags and says in the configure log which it decided.<core/async/WhenAll.hpp>includes<type_traits>, which it names;<core/async/Awaitable.hpp>no longer includes<utility>, which it does not;Task_test.cppincludes<stdexcept>rather than relying on Catch2 for it, and not<string>, which it does not use.core::cli's--helpno longer reads past the text it is laying out.wordWrapped()computed the room left on the line asmargin - cursor + 1in unsigned arithmetic, with a<= 0guard below it that is dead for an unsigned type;printOptions()sets the cursor to the option column, so an option column wider than the terminal — an 80-column terminal and an option whose rendered text exceeds 65 characters, a narrower terminal, or a pty reportingws_col == 0— wrapped it to about 4294967295 and indexed the help text far past its end. It also readtext[SIZE_MAX]for a help text beginning with a line feed, and returned an empty chunk for a word longer than the line, which made the caller loop forever. The options column now accounts for the verbatim placeholder as well, so a placeholder longer than the longest option no longer underflows its padding into a string of about four billion spaces (theassertabove it is compiled out under NDEBUG), and the hyperlink scan'sisalpha()widens throughunsigned char, which is what it is defined for. The wrapper also advances its index by what a chunk consumed rather than by what it emitted: the two differ whenever a chunk is trimmed, and a space before a line feed left the trimmed space in front of the index for a skip loop that skips line feeds and not spaces, so the same empty chunk came back for ever and--helpnever returned. Two rendering changes come with this, both visible to anyone diffing--helpoutput. A line whose text reaches exactly to the margin is no longer broken onto a second line. And trailing spaces before a line feed no longer produce a wrapped line each: the old renderer consumed them one per turn and emitted a line break plus a continuation indent for every one of them, so a help text readingFirst line.+ line feed +Second line.rendered as three lines where its author wrote two, andabc+ three spaces + line feed +xyzas five. They are consumed together now.core::cli::Appkeeps the contracts it documents.installLogging()assigned the replacement over the member holding the previous output, so the previousScopedOutputwas destroyed after the new one had installed itself: its destructor restores every category to the sink it snapshotted, so a second call silently sent every later log line back to the console and left each category holding a reference into a destroyed sink. It releases the previous output first now — which means a destination that then fails to open leaves logging on the console rather than on whatever was installed before; the caller is told, and has nothing to fall back to either way.reparseParameters()andparseParametersForTesting(), both documented "false on failure", catch whatcli::parse()throws rather than letting it escape a function whose contract is a bool (cli::parse()'s declaration now states what it throws; core-cpp#13 converts this API tostd::expectedat the end of the plan).screenWidth()rejects a reported width of 0.listDebugTags()sorts a copy rather than the process-wide category registry, whose order is its construction order.core::logasks the platform whether a standard stream is a terminal, on Windows too.ScopedOutput's privateisStdErrTty()returnedtrueunconditionally there, so a redirected standard error received SGR escapes — against the header's own contract — andcore::cli::App'shelpStyle()andcustomizeLogStoreOutput()each carried a second copy of the same branch for standard output. All three now callcore::log::isStdOutTerminal()orisStdErrTerminal(), whichcore::logimplements once per platform insrc/core/log/posix/TerminalQuery.cppandsrc/core/log/windows/TerminalQuery.cpp— an operating-system difference is an implementation, never an#ifdefinside the decision (.agent/rules/platform.md). The module's other one, the process id the[PID]field prints, went the same way (posix/ProcessId.cpp,windows/ProcessId.cpp, declared in the privatedetail/ProcessId.hpp), socore::loghas no#ifdefin its logic left.core::escape()andcore::unescape()round-trip again. 0x7E was outside the printable range, so~came out as a numeric escape;escape()writes a quote as\"andunescape()re-emitted it as\"; and an octal escape is three digits of which only those below\100begin with a zero, but the reader keyed the sequence on'0', so\101and everything above it came back as literal text. The reader now opens an octal sequence on any octal digit and consumes exactly three, which reads the\0ddform it used to accept identically (a leading zero is octal-neutral). One reading did change:\1through\7used to come back as the two literal characters and now open a three-digit octal run. That is correct for anythingescape()produced, which is whatunescape()is for; hand-written or third-party escaped text that meant a literal backslash before a digit has to spell the backslash\\.core::FNV's byte-wise overload reads the bytes withstd::bit_castrather than areinterpret_castthrough the object representation, which no constant evaluation may do — so theconstexpron the overload can now be taken up. (What it accepts narrowed too; see Breaking.)core::base64::decode()'s index lambda captures its 256-byte table by reference; by value it copied the whole table on every call.core::Utilsstays inside the bounds it is given.splitKeyValuePairs()rebuilt its last segment with the length-lessstd::string_view(char const*)constructor, which callsstrlen(): it read past the view (AddressSanitizer reports a heap-buffer-overflow) and returned whatever followed as part of the value.toLower()/toUpper()passed a plainchartotolower()/toupper(), undefined for any byte with the high bit set — every continuation byte of a UTF-8 sequence, andcli::about::registerProjects()sorts project titles through them; a character wider than a byte goes totowlower()/towupper()rather than being truncated into the narrow functions' domain. (readFileAsString()is fixed too; because it answers differently, its entry is under Breaking.)eachElement()'s end iterator wasmax + 1computed inintand cast back, which for a type narrower thanintwraps ontobegin()— so the range was empty — and for one as wide asintoverflows. Windows'threadName()resized bylen - 1withlen == 0on a failed conversion, which threwlength_errorbefore theLocalFree()below it ran.core::tuicarries no consumer's name in the code it runs. Beyond the OSC 8 hyperlink id below,detectLanguageFromPath()'s well-known-filename table no longer has a row for endo's.endo-format, so that name now answersLanguageId::None; the table keeps only names that are well known beyond one project, and a consumer that wants its own configuration file highlighted passes the language tohighlightLine()itself. The default theme's path-gradient colours and the fuzzy matcher's worked example no longer describe themselves in terms of one application either. (LanguageId::Endo,registerEndoHighlighter()and the.endoandendotoken rows were the same finding, left then for a design decision; they are removed under Breaking above, together with the registration seam that replaces them.)core::tui's assembly highlighter no longer overruns a stack buffer. Its three scanners lowercased an identifier, a%registeror a.directiveinto a 64-character array through a helper that took a barechar*and wrotesrc.size()bytes; the four other call sites bounded the copy themselves and these did not, so a token longer than 64 characters in any rendered```asmfence smashed the caller's frame. The helper now takes astd::span<char>and returns an oversized identifier unchanged, so the bound is in one place.core::tui's three dialogs draw their frame where their text is. Each built itsRectas{ .x = startRow, .y = startCol }, butRect::xis the left column andRect::ythe top row, whileputString()takes(row, col); the border and the interior fill therefore landed at the transposed position and the contents outside them. The two coincide only on a canvas where the dialog is centred at the same offset in both axes, which is why nothing caught it.core::tui::InputDialog::render()no longer throws on a terminal narrower than its own border.dialogWidth = min(config.width, termCols - 4)andinputWidth = dialogWidth - 4had no floor, and the negative width reachedsubstr()as a hugestd::size_t, throwingstd::out_of_rangeout of arender()no caller expects to throw. All three dialogs clamp both to zero.core::tui::Buffer::addHyperlink()mints the OSC 8id=as the bare hash of the URI. endo's copy prefixed itendo-, so every consumer's hyperlinks carried another project's name on the wire. A behaviour change for anything that reads the id back: it is now1f2ewhere it wasendo-1f2e.core::tui::completer::Completer::addProvider()sorts stably, so providers of equal priority -- which is every provider that does not set one -- keep the order they were registered in.gatherCompletions()drops a later duplicate by text, so an unstable sort let the standard library decide which provider's item a user saw.core::tui::VtParser's three sequence buffers are bounded. A bracketed paste, a CSI parameter string and a DCS payload each grew for as long as bytes kept arriving without the terminator that ends the sequence, andtimeout()resolves only a bare Escape, so aESC[200~whoseESC[201~never came grew the process without limit from untrusted bytes on stdin. The caps are the new publicVtParser::MaxPasteLength(4 MiB),MaxCsiParamLength(256) andMaxDcsLength(64 KiB); past one, the parser returns to Ground, emitting the collected text for a paste and dropping the other two, which are malformed at that length. Each cap bounds what the sequence CARRIES: the terminator's own bytes (ESC[201~,ESC \) pass through the same buffer on their way in, and their room is reserved above the cap, so a paste of exactlyMaxPasteLengthbytes and a DCS payload of exactlyMaxDcsLengthbytes still end at their own terminator instead of being cut a few bytes into it.MaxCsiParamLengthneeds no such reservation: a CSI's final byte is dispatched, never collected.core::tui's POSIX SIGWINCH handler saves and restoreserrno, reaches itsTerminalInputthrough a lock-freestd::atomicrather than a plain pointer, and cannot block. The write end of the resize self-pipe was left blocking (only the read end was made non-blocking), so a pipe nobody had drained stalled::write()inside a signal context; and a resize arriving between a failed syscall and the mainline'serrnocheck overwrote the value that check was about to read.core::tui::SyncGuardflushes at both ends of the region, whichever way the guard was made. Anything composed inside the region and still buffered was emitted afterCSI ?2026land so applied outside it --Screen::flush()'sapplyCursorShape()is the live case -- and move-assignment, which ends a region the same way, flushes too. The flush on the way in is the constructor's rather thanTerminalOutput::syncGuard()'s, so the natural RAII spellingauto guard = SyncGuard { output };no longer emits previously buffered bytes inside the region it is opening.core::tui::SyncGuardwrites its begin and end sequences (DEC mode 2026) through theTerminalOutputit brackets, so they follow that output'swriteToDestination()wherever its bytes go. endo's guard wrote them to the process's standard output whatever the output was (src/tui/platform/TerminalOutput.cpp:355-359atf774a210), which put the frame's begin and end on a stream that never saw the frame's contents, and left a retargeted output's own stream unsynchronised. The guard therefore carries no native handle, and<core/tui/TerminalOutput.hpp>no longer declares avoid*handle alias under_WIN32.TerminalOutput::isTerminal()is new beside it: whether the destination is a terminal, answered by the operating system for the default one and by the subclass for a retargeted one.core::nextPowerOfTwo()rounds a 16-, 32- or 64-bit value up to a power of two. crispy's, which it was imported from, compared the type's width in bytes against bit counts and so smeared only the eight bits below the highest set one: 257 became 511, and 0x10001 became 0x1fe01.core::LiveEnvironmenton Windows reads a variable set to the empty string as set, as it does on POSIX; it read as unset.core::Generatoris the same type in every translation unit. endo's, which it was imported from, tested__cpp_lib_generatorbefore including anything, so whether it wasstd::generatordepended on what the including file had included first, and a virtual function returning one (FileSystem::walkDirectoryRecursive) could have two return types in one program.core::platform::SystemPipenever blocks: both POSIX ends are non-blocking and close-on-exec, a write into a full channel reports done, andsend()usesMSG_NOSIGNAL. endo's copy blocked; contour's, which an event loop'spost()uses, already did this.-
core::platform::SystemPipe::read()returns aChannelResult, which tells the bytes read, an empty channel and the end of the stream apart; only a failed read is aPlatformError. endo's and contour's copies returned a count, 0 for the end of the stream, and failed a read of an empty non-blocking channel with the same error as a broken one. -
core::platform::testing::InMemoryFileSystem's read-write stream stops handing out stale pointers. It cached the get-area pointers into thestd::stringthat holds the file and then appended to that same string on every write, so a write that grew it past its capacity left every one of those pointers naming freed memory -- a heap-use-after-free on the next read. The same append also ignored where the stream stood, soopenReadWrite()could never overwrite in place the way thestd::fstreambehindNativeFileSystemdoes; it now carries one position for reading and writing, asstd::filebufhas, and implementsseekoff()/seekpos(). core::platform::MessageQueueguards its wakeup pointer like every other member.setWakeup()wrote it with no lock whilepush()andshutdown()read and dereferenced it from another thread, so a teardown that cleared the pointer could be missed and leavepush()signalling a destroyedWakeup. Registration takes the queue's mutex now, and the signalling happens under it, so oncesetWakeup(nullptr)returns nothing is still insidesignal().core::platform::SignalHandler::restore()deregisters the interrupt wakeup as well as the callback. It leftinterruptWakeuppointing at theWakeupthe caller was about to destroy, and Linux'sprocessSignalFd(), the SIGINT handler elsewhere and the Windows console control handler all reach it through that pointer.core::platform's WindowsEnvironmentProviderreads the environment throughcore::LiveEnvironment, and writes it throughcore::setProcessEnvironmentVariable()andcore::unsetProcessEnvironmentVariable(), as the POSIX one already did. Its ownGetEnvironmentVariableA()call could not tell an empty value from a missing name, so it reported a variable set to""as unset while the other reader of the same Win32 block reported""; it also ignored the buffer-too-small return and allocated 32 KiB per lookup.core::testing::setTestEnv()sets an empty value instead of removing the variable. On Windows_putenv_s(name, "")removes it, sosetTestEnv(name, "")andunsetTestEnv(name)were the same call andScopedEnvcould not put back a variable whose previous value was empty -- and the environment is process-global, so the loss crossed into every later test.core::platform::SystemPipe's never-stall guarantee holds on Windows too. Only the read socket was made non-blocking, so a producer that outran the loop parked insend()indefinitely; the write socket is non-blocking now andwrite()answersWSAEWOULDBLOCKas done, the way the POSIX branch answersEAGAIN.write()also clamps the byte count toINT_MAX, asread()already did, so a count past it can no longer go negative or wrap into a short write reported as a full one. AndmakeLoopbackPair()compares the two ends' addresses and retries:accept()returns whoever connected, and between thelisten()and theaccept()any local process can take the ephemeral port, leaving a "pair" whose ends are not each other's.core::platform::FileSystem::isExecutableFile()classifies a symlink by what it points at. On POSIX it accepted any symlink and then read the followed target's permissions, so a symlink to a directory was reported as executable on the directory's own search bit -- against the declaration's "Directories always return false". A PATH lookup that trusted it ran the directory and failed withEACCESinstead of trying the next entry.core::platform::globMatchFilename()reaches its bracket arm for the character it exists to match. The literal arm was tested first, soglobMatchFilename("[", "[[]")-- POSIX's own way to spell a literal bracket -- answered false. A[that no]closes stays the literal[thatfnmatch(3)reads it as.core::platform::stripTrailingSeparator()andisCaseOnlyRename()keep a spelling the platform's native narrow encoding cannot hold. Both went throughpath::generic_string(), which on Windows narrows to the ANSI code page: MSVC throws on a path it cannot spell, and where it does not throw it substitutes, so two distinct paths come back as one.InMemoryFileSystemkeys its whole file map on the first of them, so one file answered for another.NativeFileSystem::createTempFile()had the same problem twice, and iswchar_tend to end on Windows now.-
core::platform::NativeFileSystem::createDirectory()names an existing directory rather than reporting "No such file or directory", the diagnosis for the other way it fails; andrename()reports the two-hop recase's own error instead of the first attempt's, and says where a failed rollback left the entry. -
core::net's HTTP head parser ends the head at the first blank line, whichever terminator produced it. An empty line inside the header block was skipped with acontinue, so"GET / HTTP/1.1\n\nHost: evil\r\nContent-Length: 0"parsed as ONE request carrying those headers while a front-end that honours a bare LF as a line terminator -- which RFC 9112 2.2 permits, and which this parser itself does for every other line -- read it as two. That is the request-smuggling desync of RFC 9112 11.2, in the parser that already refusesTransfer-Encodingand a conflictingContent-Lengthfor the same reason. The message is refused rather than re-framed: the bytes behind the blank line were already consumed as part of the head block, so aContent-Lengthread before it would index into the wrong place. core::net's Windows listener no longer stops accepting for good.accept()calledWSAResetEventon the shared readiness event before parking; a client connecting between the::accept()that returnedWSAEWOULDBLOCKand that reset leavesFD_ACCEPTrecorded and the event signalled, and the reset then cleared the event while the record stood -- Winsock raises a recorded indication only once, so the coroutine parked for ever and the listener went silent, for that connection and every one after it. The indication is consumed withWSAEnumNetworkEventsinstead, which clears both in one step and says what it took, so a connection from that window is accepted rather than lost.WindowsSocket::latchNetworkEventsalready did this for the two directions that share a connected socket's event; both now go through onecore::net::consumeNetworkEvents.core::net's TLS wrapper checks bothBIO_newresults before handing them toSSL_set_bio. A failed allocation produced a non-null socket whose first read or write dereferenced null -- breakingITlsContext::wrap()'s own documented "null on allocation failure", one line below the checkedSSL_new. The failure path also releases theSSLit had already created.core::net's TLSflushOut()reports a failed flush instead of success. ItsBIO_read <= 0branch is reachable only afterBIO_ctrl_pendingsaid bytes WERE queued, so it meant a failed write BIO, and calling that "nothing to flush" dropped ciphertext silently: the handshake then waited for a peer response to a flight that was never written, and both ends hung until an outer timeout.core::net::PosixSocket::write()handles a zero-length return instead of reading a staleerrno. Only a positive return was consumed, so a zero fell through to anerrnono call in the loop had set -- and depending on that leftover value the loop spun on an already-writable socket, retried for ever, or reported a failure that never happened.errnois now captured immediately after the syscall, asread()already handled its own zero (a clean EOF) first.core::net::AsyncBufferedReader::readUntil()rescans the buffer when the delimiter changes. The scan offset was reset only when the scanner KIND changed, but "no match can begin before here" is a statement about the bytes that scan was looking for: after areadUntil("\r\n\r\n")returned early, a followingreadUntil("X")resumed near the buffer's end and reported EOF for anXthe reader was already holding.core::net's POSIX listeners create their socket close-on-exec atomically, through themakeStreamSocket()helperconnect()andconnectUnix()already use, instead of a bare::socket()with the flags applied afterlisten(). A fork and exec from another thread in that window inherited the listening descriptor and kept the port -- or the AF_UNIX socket file -- claimed after the daemon exited.core::net::testing::ScriptedEventSource::detach()is idempotent, asEventSourcedocuments and every real backend behaves. It counted detach CALLS rather than live registrations, and the loop genuinely detaches twice on normal paths (notifyHandleClosingthenunregisterFdWaiter;requeueForCancellationandwakeAllWaitersbeforeawait_resume), so a second detach of one token cancelled out another token's registration andattachedCount()under-reported -- a future leak assertion against this source would have passed on a registration that never went away.src/core/net/EventLoop.cppincludes<stdexcept>for thestd::runtime_errorit throws, which compiled only through a transitive include.-
core::net's own tests: a failingREQUIREin awhenAllarm fails the case instead of hanging it (whenAllcancels no sibling, and the sibling was parked inaccept()with nobody left to close the listener --.agent/rules/testing.md); the descriptor-exhaustion case restores the process-wideRLIMIT_NOFILEthrough a scope guard, so a throw in between can no longer leave every later case in the binary running squeezed; and the TLS cases checkmakeSocketPair()before dereferencing it, so a loopback failure is a test failure rather than undefined behaviour. -
core::net's HTTP head parser rejects whitespace between a field name and its colon, which RFC 9112 5.1 makes a MUST for a server, instead of trimming it away. It is the same class as the bare-LF blank line above: a front-end that trims "Host :" back to "Host" and a server that rejects it (or the reverse) do not agree on what the message says, and the lenient half of that disagreement is the one that lets a header through under a name the other end never saw. A field name is a token, so whitespace anywhere in it -- and an empty name -- is refused; whitespace AFTER the colon is still padding a recipient removes, soHost: \texample \tis unchanged. core::net's own tests bound the waits that can hang rather than fail. The sequential-accept guard for the Windows listener would itself have parked for ever on the defect it guards -- so ctest reported "Timeout" after 1500 seconds and named nothing -- and now fails inside its budget with the count it waited for. Every testcore_cpp_add_testregisters is now bounded -- 300 seconds unless aTIMEOUTsays otherwise, which the two net binaries tighten to 120 and the cli binary to 60 -- so a wait somebody forgets to bound is named in five minutes instead of ctest's 1500-second silence. And the sibling half of thewhenAllsweep is closed: an arm that gave up early without stopping the sibling parked inaccept()turned a red into a hang just as an assertion there would, at five sites (two loopback client flows, the AF_UNIX probe, and the two TLS cases whose server runs on another thread, where the hang landed onstd::thread::join).
Imported¶
Each file was read as a git blob at the commit named, and none contains a CR byte.
| From | Commit | What |
|---|---|---|
| fastcached | f6ec49f3446b8bc121eba82c64cde2de759e774a |
cmake/portable/CompileCache.cmake and cmake/FetchTransferBound.cmake, verbatim |
| fastcached | eb9c9c68da8fadfd43b0b36366919cb462689f48 |
the bounded bootstrap download in cmake/CPM.cmake; the Windows error-popup suppression, merged into SuppressWindowsDialogs; the hook-name IgnoredRegexp of .clang-tidy |
| contour | 6777ff05014f8ff163b071e8b0e942830119db80 |
.clang-format and .clang-tidy, adapted; two copies of SuppressWindowsDialogs, merged; LICENSE |
| endo | f774a210ce989e5947b8f61d715068b1dc96088c |
SuppressWindowsDialogsAtStartup.cpp, WindowsDialogCanary.cpp, the CPM 0.40.8 pin; a copy of SuppressWindowsDialogs, merged; .github/clang-tidy-matcher.json |
| contour | 6777ff05014f8ff163b071e8b0e942830119db80 |
crispy's generic half, src/crispy/{Assert,Base64,Deferred,Defines,Environment,Escape,FNV,Flags,Overloaded,Times,UserInfo,Utils} as core (core::base), {LogStore,LogSink} as core::log, {CLI,App} as core::cli, and testing/Environment.hpp as core::testing, with their tests (Base64, CLI, Environment, LogSink, Times, Utils); fatal() and SoftRequire() moved from Assert.hpp to core/log/Assert.hpp; gsl::not_null replaced by a reference |
| fastcached | ee71f868547712892b7d9a2ebff60d49c496e25c |
src/FastCache/Core/{Profiling,Ranges}.hpp as core/{Profiling,Ranges}.hpp (FC_* as CORE_*, FastCache::Ranges as core::ranges), with Profiling_test.cpp and Ranges_test.cpp |
| endo | f774a210ce989e5947b8f61d715068b1dc96088c |
src/testing/{ScopedTempDir,ScopedWorkingDirectory,EnvHelper}.hpp and ScopedTempDir_test.cpp as core::testing; EnvHelper writes through core::setProcessEnvironmentVariable() and reads through core::LiveEnvironment on POSIX, not setenv()/getenv() |
| endo | f774a210ce989e5947b8f61d715068b1dc96088c |
the generic half of src/platform as core::platform (Types, PlatformError, Clock, Wakeup, SignalHandler, SystemPipe, WinsockInit, MessageQueue, FileSystem, NativeFileSystem, FileInfoProvider, EnvironmentProvider, UserPaths, PathUtils, GlobMatch, FileUri, SystemInfo, StringUtils, their posix/, linux/ and windows/ implementations and testing/ doubles), with their tests (WindowsPlatform_test.cpp split into PathUtils_test, Types_test and UserPaths_test); Generator.hpp as core::base (core::Generator; Task A5b moved it out of core::async, which it needs nothing of). Process, Pipe, WaitResult, ProcessProvider, ProjectFileTree, InstallPaths and InterruptThrottle stay in endo; the namespace endo compatibility aliases were not imported |
| contour | 6777ff05014f8ff163b071e8b0e942830119db80 |
src/net/platform/Clock.hpp, merged into core/platform/Clock.hpp; src/net/platform/SystemPipe.{hpp,cpp}, whose non-blocking behaviour is merged into core/platform/SystemPipe; src/net/platform/WinsockInit.{hpp,cpp}, identical to endo's |
| contour | 6777ff05014f8ff163b071e8b0e942830119db80 |
src/coro/{Awaitable,Cancellation,Task,UniqueCoroHandle,WhenAll,WhenAny}.hpp and {Task,WhenAll,WhenAny}_test.cpp as core::async, coro:: renamed core::async::; the std::stop_token aliases of Cancellation.hpp moved to StopToken.hpp, whose fallback replaces their #error; no NOLINT; two locals renamed for -Wshadow; two WhenAny_test.cpp helpers compiled only where the case using them is. test_main.cpp was not imported (core::testing_main replaces it), and testing/SuppressWindowsDialogs.hpp had been merged into core::testing already |
| contour | 6777ff05014f8ff163b071e8b0e942830119db80 |
src/net as core::net, core::net_types and core::net_tls, net:: renamed core::net:: and coro:: core::async::, with its tests but test_main.cpp; net/platform/{Clock,NativeHandle,SystemPipe,WinsockInit} replaced by core::platform, whose SystemPipe::read() returns a ChannelResult; platform/PeerAddress.hpp moved to detail/ and platform/WindowsLoopback.* to windows/, so that no core::net::platform namespace hides core::platform; platform code in platform subdirectories (epoll in linux/, kqueue in bsd/, PollEventSource.cpp split into posix/ and windows/, WaitChunking.hpp in detail/); NetError split out of IoResult.hpp into NetError.hpp; testing/TempDir.hpp not imported (core::testing::ScopedTempDir); no NOLINT; the C-style for loops written as range-fors and whiles; one lambda parameter renamed for GCC's -Wshadow, and a CMSG_FIRSTHDR() result checked for GCC's -Wnull-dereference; the TLS test makes its client context before its server thread starts |
| fastcached | b461e8b6d367ed22e4bf2935717fa59360a64b7d |
src/FastCache/Core/Clock.hpp, merged into core/platform/Clock.hpp in camelBack (Now/Refresh as now/refresh, TimePoint/Duration as SteadyTimePoint/SteadyDuration); Clock_test.cpp and WallClockRef_test.cpp, merged into core/platform/Clock_test.cpp |
| fastcached | 0708dd54dc7ee72622c8c0783c2bd4a06f0e9b21 |
src/FastCache/Async/{ParkedWork,IExecutor,ResumeOn,ThreadPoolExecutor,AsyncQueue}.{hpp,cpp} and their tests as core::async, FastCache:: renamed core::async:: and Detail:: detail::; DetachedTask and SyncRun/SyncRunWith out of Task.hpp into DetachedTask.hpp and SyncRun.hpp (Ruling R66), and the rest of that file merged into contour's Task.hpp; ThreadPoolExecutor.cpp inlined into its header (Ruling R65), over std::thread rather than std::jthread; IReactor replaced by IExecutor in AsyncQueue and ParkedWork_test.cpp, whose reactor-driven cases belong to Task B4 |
The rulebook and CI configuration adapt text from fastcached at
b5ded89c5ae6ba5b45337335ce774c5ae6986d65, contour and endo at the commits above, Lightweight at
f57dc2e0704d885a3c642a63675873919fc2d128 and tuidu at
30107fbab72310fde5db89e7882eab288f6b541e; NOTICE lists the files.