Move morph onto core-cpp v0.2.1, and add coroutines on core::async - #806
Draft
christianparpart wants to merge 8 commits into
Draft
christianparpart wants to merge 8 commits into
christianparpart wants to merge 8 commits into
Conversation
christianparpart
force-pushed
the
build/core-cpp
branch
9 times, most recently
from
September 25, 2026 08:00
ee2a397 to
09b2ff3
Compare
glaze, Catch2, doxygen-awesome-css and both Lightweight sites now come through CPMAddPackage, loaded from cmake/CPM.cmake: core-cpp's pinned bootstrap (CPM 0.40.8 and its SHA-256), byte for byte below a header that says so. cmake/DepCache.cmake goes. CPM's own source cache replaces it. CPM_SOURCE_CACHE defaults to .cache/cpm inside the checkout, which is already ignored. The environment variable or an explicit -D still wins. A second configure of the same checkout clones nothing, and a cache miss still fetches. ci.yml, wasm-ladder.yml and wasm-demo.yml restore and save .cache/cpm with actions/cache. The key hashes cmake/CPM.cmake and every CMakeLists.txt, so a changed pin misses the cache. This keeps the burst of anonymous clones that github.com answers with a 401 off the shared egress address, as DepCache did. glaze and Catch2 keep their explicit find_package first, and CPM only fetches when it finds nothing. This is deliberately not CPM_USE_LOCAL_PACKAGES. That option is global, so it would also re-route Lightweight's own CPM dependencies. It would also ask find_package for the fetched pin's version, which rejects the distribution's Catch2 3.4.0 that the clang-tidy leg is pinned to. Catch2's extras/ is added to CMAKE_MODULE_PATH after a fetch, because under CPM its PARENT_SCOPE is the CPMAddPackage function rather than the root directory. The Lightweight fetch stays inside the CMAKE_SKIP_INSTALL_RULES bracket, because CPMAddPackage is now where its CMakeLists.txt runs. morph_demote_lightweight_odbc_includes() is still called right after it. check_coverage_roots.sh now admits $CPM_SOURCE_CACHE where it admitted DepCache's directory. Refs #805 Signed-off-by: Christian Parpart <christian@parpart.family>
…op timers morph now links core-cpp v0.3.0, fetched through CPM with its tests, examples, TUI, TLS and dependency fetching off. That builds core::base, core::async, core::net and, natively, core::platform. morph stays a header-only INTERFACE target, but a project that links it now builds those static libraries too. On a sanitizer leg core-cpp's compiled modules are instrumented along with morph's own targets, through the CORE_CPP_TARGETS property. Otherwise ThreadSanitizer would see only one side of the hand-off to TimeoutScheduler's loop thread. TimeoutScheduler keeps its public API and its promises. schedule() and cancel(Handle) are unchanged, the class is non-copyable and non-movable, and the destructor drops pending callbacks without firing them. A callback that throws is logged and swallowed. morph's own pending map, under a mutex, still owns every callback, so cancel() releases the callback and its captures before it returns, in both builds. The deadlines are now core::net::PlatformLoop timers: - Natively a thread runs the loop. schedule() and cancel() queue their change under the mutex and wake the loop once per batch rather than once per call. The destructor retires every timer on the loop thread, stops the loop and joins it. - Under single-threaded WebAssembly the loop is host-driven, pumped by the browser's timer, and has no thread. schedule() and cancel() arm and retire the timer directly, so cancel() now retires the timer instead of leaving it to fire into nothing. Two tests pin the promises the loop took over. One checks that cancel() releases the captures before it returns (a weak_ptr observes it). The other checks that the destructor drops a pending callback without firing it or waiting for its deadline. morph's headers now use a compiled library, so configure-time probes can no longer link them: a try_compile() project links only imported targets. The MORPH_CLIENT_ONLY probes that must link or run become build-time executables and ctest cases. The negative probe stays a try_compile(), and now also checks that the linker named ClientOnlyModel, since failing for another reason would prove nothing. The QT_NO_SSL guard compiles to a static library, because what it guards is compilation. morph's install exports morph::morph, which links core-cpp's modules, so an install of morph installs core-cpp's package next to it: - MORPH_INSTALL is declared before core-cpp is added, and is passed on as CORE_CPP_INSTALL. - While morph installs, core-cpp's subdirectory is not EXCLUDE_FROM_ALL. CMake leaves an excluded subdirectory's install rules out of the parent's install. - morphConfig.cmake calls find_dependency(core-cpp 0.3). - scripts/check_install_export.sh and the README now build before they install, because core-cpp's static libraries have to exist to be installed. On a WebAssembly build, a browser pump that is still pending when the scheduler's host-driven loop is destroyed is safe from core-cpp 0.3.0 on: the pump holds a weak reference to the loop. Refs #805 Signed-off-by: Christian Parpart <christian@parpart.family>
The WebSocket handshake's base64 is core::base64::encode, the standard RFC 4648 alphabet with padding. It encodes both the SHA-1 digest of Sec-WebSocket-Accept and the random bytes of Sec-WebSocket-Key. morph/net/detail/base64.hpp goes. tests/net/test_base64.cpp keeps the RFC 4648 vectors, now against core::base64, and adds a high-byte vector that pins the '+' and '/' of the standard alphabet. SocketServer's accept loop now waits on core::platform::Wakeup instead of its own nested WakeupPipe. That is an eventfd on Linux and a non-blocking self-pipe on macOS and the BSDs. listen() creates a fresh one each time, so a signal an earlier close() left undrained cannot end the next accept loop at once. It still fails closed when the kernel refuses one: Wakeup's constructor throws, and listen() returns false and starts no thread. close() signals it before joining. Session tokens keep their own canonical base64url decoder: core::base64::decode makes no promise to reject non-canonical input, which token verification depends on. docs/spec/security.md says so. The README and docs/ARCHITECTURE.md now list core-cpp among morph's dependencies and say that its static modules are built with morph. Refs #805 Signed-off-by: Christian Parpart <christian@parpart.family>
docs/spec/core/coroutines.md is the authoritative design for the coroutine support that follows. bridge.md, completion.md and the spec map link to it, and backend.md's ActionCall table gains the two fields the local path needs. The client side: - Completion<T> is awaitable through operator co_await() &&. It yields T or rethrows, and resumes in the context the coroutine suspended in, or else on the completion's executor. - A stop on the awaiting promise's token withdraws the await through a CallbackToken and resumes the coroutine with OperationCancelled. - spawn(executor, task) starts a coroutine from ordinary code, and delay(scheduler, duration) is a stop-aware timer. The model side: - An execute() that returns core::async::Task<R> is driven on the model's strand through StrandCoroExecutor. - A per-instance action gate keeps the next action from starting while a handler is suspended. - An execute deadline stops a suspended handler. The spec also records what this does not do: - Holding ExecuteOrderGate's ticket for the whole suspension would block pool threads that the suspended handlers' own awaits need. - Cancellation does not cross the wire. - A synchronous ActionDispatcher::dispatch, and journal replay, cannot run a Task handler. Refs #805 Signed-off-by: Christian Parpart <christian@parpart.family>
…ning model handlers This implements docs/spec/core/coroutines.md; tests and implementation land together because the tests do not compile without it. - Completion<T> is awaitable through operator co_await() &&. The await is one more then/onError pair on the completion, holding a heap state and a CallbackScope token, never the frame. It resumes in the context the coroutine suspended in: a thread-local resumption context that the executors resuming morph coroutines install per resumption, because core::async::Task carries a stop token from awaiter to awaitee but no executor. A stop request on the awaiting promise's token withdraws the await and resumes with core::async::OperationCancelled; exactly one of settlement and stop resumes it. - morph::async::spawn(executor, task) starts a detached Task<void> whose every step, the first included, is posted to a morph executor, and logs what it lets escape. morph::async::delay(scheduler, duration) is a stop-aware wait on a TimeoutScheduler entry. - A model's execute may return core::async::Task<R>. ActionTraits::Result is R. The handler runs on the model's strand through StrandCoroExecutor, which reinstalls the session context on every resumption. A per-model ActionGate keeps actions non-reentrant: the next action waits until a suspended Task handler has completed, and a queue of ordinary handlers drains in a loop rather than a recursion. The gate's queue is allocated on first use so a model holder that never queues stays under the allocation-failure test's size threshold. - An execute deadline on a Task handler requests stop on the handler's token, so a handler suspended in a co_await is resumed with OperationCancelled instead of running on after its caller gave up. - RemoteServer runs Task handlers through ActionDispatcher::dispatchAsync; dispatch() throws std::logic_error for one. A handler that throws something other than a std::exception now gets an err reply instead of none. LimitPolicy::executeTimeout requests stop on a per-dispatch StopSource after replying, so a suspended handler leaves the gate. - ~LocalBackend ends the Task handlers it started before its strand goes: it requests stop on every live Task run (each has a StopSource now, deadline or not), so they resume cancelled through the still-open strand; it waits for the strand to drain, where an action whose call cancelPending already failed is skipped rather than run; then it closes the StrandLink the handlers post through. A handler suspended where no stop reaches resumes inline after that instead of into a freed strand. The strand itself is not shared, because ~StrandExecutor waits for its in-flight tasks and one of them could hold the last reference. - A handler's end -- recording it, settling the call, leaving the action gate and starting the next action -- always runs on the model's strand, on LocalBackend and RemoteServer alike. A core-cpp awaiter such as AsyncQueue::pop resumes a handler on its own executor, and the handler may end there; the end is then posted to the strand, or runs inline once the strand is closed. morph::async::resumeContext() names the strand, so a handler can go back to it with core::async::ResumeOn after such an await. clang-tidy: .clang-tidy exempts the coroutine protocol's names (await_ready, promise_type and the rest) from the naming rules, because the compiler looks them up by those names. The clang-tidy-diff job also passes -Wno-pragma-once-outside-header. It analyses a changed header as a main file, so every new header's #pragma once was reported against it. MSVC: types reflected by glaze live in a named namespace (C7631), a coroutine does not end in a throw (C4033), and no co_await shares a full-expression with another call (C4737). Refs #805 Signed-off-by: Christian Parpart <christian@parpart.family>
…tebin GUI The bank's BudgetModel answers SpendingByKind with a core::async::Task<SpendingReport> handler. It does not suspend today; the point is that its callers, local and remote, are unchanged, because the bridge deduces the Task's result type and drives it on the model's strand. The pastebin presenter's list() is a coroutine: it awaits the ListPastes completion and emits listed or failed. The presenter base gains trackFlow(executor, flow), the coroutine counterpart of track(): the flow is started with morph::async::spawn on the presenter's executor, so every step runs on the GUI thread, and it counts in busy() until it finishes, whether it returned or threw. The flow holds a QPointer and checks it after the await, as track()'s handlers do. The coroutine headers are hidden from moc. Refs #805 Signed-off-by: Christian Parpart <christian@parpart.family>
A failed assert(), an abort() or a crash in a Windows test executable opened a modal dialog (CRT assert, abort, Windows Error Reporting) and waited for a click. Under ctest nobody clicks, so the test held the run until its timeout. On a desktop, the dialog lands on the user's screen. Every morph test executable on Windows now links core-cpp's core::testing_dialogs. That is one object whose static initialiser calls core::testing::suppressWindowsDialogs() before main() runs: - CRT assert, error and warning reports go to stderr; - abort() writes its message to stderr, shows no message box and asks for no fault report, and Windows Error Reporting shows no UI (core-cpp 0.3.0); - an invalid CRT parameter is handled rather than raising a dialog; - SetErrorMode turns off the critical-error, GP-fault and open-file boxes. The process exits, and the test fails loudly. The Catch2 suites reach it through morph_test_log_level, which every one of them links, whichever main() it has. Because an OBJECT library's objects reach only a direct linker, its objects are named there as an interface link item, as core-cpp's core::testing_main does. The test executables without Catch2 call morph_suppress_test_dialogs() themselves: - the MORPH_CLIENT_ONLY probes and the journal skew writers; - qt_test_server/client; - morph_bench_alloc and morph_forms_qml_tests; - the ladder's headless test children. morph itself and the example applications are untouched. morph_windows_dialog_canary proves it in a child process: modes assert, abort and invalid-parameter. Each is judged by the marker it prints before failing (PASS_REGULAR_EXPRESSION), with a 60-second timeout that catches a run waiting on a dialog, as core-cpp's WindowsDialogCanary is. In a Debug build the abort run must also show abort()'s message on stderr. All three pass on cl-debug. Refs #805 Signed-off-by: Christian Parpart <christian@parpart.family>
A then() or onError() handler attached before settlement runs inside the settle-time fan-out, which wraps each handler in its own try/catch and logs a throw. One attached after settlement ran in a closure of its own with no try, so its throw escaped into the executor: the attaching call over an inline executor, the pump over a pumped one. Presenter::track()'s destroy-then-throw tests rely on the isolation, and failed whenever the backend settled before track() attached -- a race that a cheaper local execute path now wins more often (CI, all optional features (clang), on c49feba). Both late-attach closures now log and continue, as the fan-out does. Two tests pin it; both failed against the old header with the handler's own exception. Signed-off-by: Christian Parpart <christian@parpart.family>
christianparpart
force-pushed
the
build/core-cpp
branch
from
September 25, 2026 08:25
09b2ff3 to
86f6858
Compare
This branch has not been deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Moves morph onto core-cpp v0.2.1, the shared C++23 foundation of the Contour Terminal projects, and adds coroutine support on
core::async. It is one branch because every step builds on the one before. Tracking issue: #805.What changes
build: fetch dependencies with CPM instead of FetchContentCPMAddPackage.cmake/CPM.cmakeis core-cpp's pinned 0.40.8 bootstrap, SHA-256 checked.cmake/DepCache.cmakeis replaced by CPM's source cache:CPM_SOURCE_CACHE, default.cache/cpm.ci.yml,wasm-ladder.ymlandwasm-demo.ymlrestore and save it.find_packagefirst, rather than settingCPM_USE_LOCAL_PACKAGES. That option is global, and it would refuse the distribution's Catch2 3.4.0 that the clang-tidy job is pinned to.core: TimeoutScheduler is one implementation over core-cpp's event-loop timerscore::base,core::async,core::netand, natively,core::platform, so a consumer of the header-onlymorphtarget now also builds those static libraries.TimeoutSchedulerkeeps its API and its promises, but its deadlines arecore::net::PlatformLooptimers:cancel()still releases the callback's captures before it returns. In the browser it now also retires the timer.MORPH_CLIENT_ONLYlink and run probes move fromtry_compileto build-time targets, because atry_compileproject cannot link a library this project builds.net: base64 and the wakeup pipe come from core-cppcore::base64::encodereplacesmorph/net/detail/base64.hpp.core::platform::WakeupreplacesSocketServer's nestedWakeupPipe.core::async— spec first,docs/spec/core/coroutines.md:Completion<T>is awaitable throughoperator co_await() &&, with stop-aware cancellation.morph::async::spawn(executor, task)starts a coroutine from ordinary code.morph::async::delay(scheduler, duration)waits on a timer and honours a stop.core::async::Task<R>. They are driven on the model's strand throughmorph::exec::StrandCoroExecutor, behind a per-instance action gate that stops the next action from starting while one is suspended. An execute deadline stops a suspended handler.bank'sBudgetModel::execute(SpendingByKind)andpastebin'sPastePresenter::list, which the WebAssembly ladder client compiles.Gates
Local runs after review fix round 3 (tip
df3fbbd9; the dialog-suppression commit is last). Each is compared with a baseline build of05222af3made with the same flags. Every run passes-DMORPH_INSTALL=OFF(see the core-cpp v0.3.0 row). On Windows, every tree ran the dialog canary before anything else.cl-debug(vcpkg)df3fbbd9morph_windows_dialog_canarymodes includedcl-debug+/fsanitize=address,[coroutine],[timeout_scheduler],[concurrency]df3fbbd9assert,abort,invalid-parameter, in a child process, 60-second timeout)df3fbbd9cl-debugtreegcc-debug(Catch2 3.4.0)df3fbbd9clang-asandf3fbbd9OomInjector|morph#108is excluded, asci.ymlexcludes them under ASanclang-debuge063ab38(round 1)windows-everything, Qt 6.11.1, fuzzers offe063ab38(round 1)qt_tls_example_runs(a Qt DLL not onPATH, the same as the baseline when run alone). The failures both builds share are ODBC-backed: this host has no SQLite ODBC driver.wasm-ladder: emsdk 3.1.56, Qt 6.8.3wasm_singlethread, run locally with the workflow's configuree063ab38(round 1)ladder_{bookmarks,pastebin,polls}_gui_wasm.wasm, and the pastebin client carries the coroutine demo.wasm-demo: the same toolchain, targetbank_gui_wasm,MinSizeRele063ab38(round 1)clang-format22test_dep_cache.shtest_dep_cache.sh(cl-debugconfigure, freshCPM_SOURCE_CACHE)find_package(morph CONFIG)consumabilityinstall(EXPORT "morphTargets" ...) includes target "morph" which requires target "core-cpp-base" that is not in any export set, and the same for core-cpp-async, -net and -platform. Once v0.3.0 is tagged, the series will be rewritten to pin it, withCORE_CPP_INSTALL ${MORPH_INSTALL}andfind_dependency(core-cpp 0.3).The tests below were red before their fixes.
Bridge::switchBackendhad freed.hold-start, hold-end, log-2.Red before the round 3 fix, on
ec3d67e4(round 2 plus the dialog commit): a handler parked in a core-cppAsyncQueue::popwas stopped by its deadline and unwound on the queue's executor. The action queued behind it then ran on that same foreign thread, not on the strand (55308 != 55308). Now a handler's end is always posted to the strand.Review item 6 (withdrawing the await when an attach throws) was verified by code reading only. No test injects a throw into
attachThen/attachOnError.Found while running the round 2 gates. The first version skipped a queued action whose call
cancelPendinghad failed without settling its sink, on the assumption thatcancelPendingwould. That assumption does not hold when the run owns the last reference to the sink: a caller that drops itsCompletionleavescancelPendingholding only an expiredweak_ptr.concurrent executeVia under repeated switchBackendcaught it by leaving completions unresolved. The skipped run now settles the sink itself, with the reasoncancelPendingrecorded. That test then passed 30 of 30 repeats.Found by
windows-everythingin the first pass, and fixed in the commits they belong to:lifetimeboundonspawn's executor parameter: clang rejects it on a function that returnsvoid;core::names resolving tomorph::corewhereverfile_io_ops.hppis included first.Not from this branch: with
MORPH_BUILD_FUZZERS=ON,windows-everythingfails on master too, because clang-cl rejects-fno-omit-frame-pointer.Consumer impact
morph::morphnow builds core-cpp v0.2.1'score::base,core::async,core::netandcore::platform(fetched through CPM). It needs a C++23 toolchain core-cpp supports, which morph already required.find_package(morph)from an install does not work until core-cpp exports its targets (gap 1 above).core::async::Task<R>fromexecute. Every existing handler is unchanged, andActionTraits<A>::Resultof a Task handler isR.ActionDispatcher::dispatchthrowsstd::logic_errorfor a Task handler, because it cannot wait for one.RemoteServeruses the newdispatchAsync.journal::replaycannot replay a Task handler's entries.RemoteServernow replieserr "unknown exception"to a handler that throws something other than astd::exception. It used to send no reply, which left the caller waiting for its deadline.LocalBackendno longer runs an action whose callswitchBackendor~Bridgehas already failed, and its destructor stops the Task handlers it started and waits for its strand to drain.ActionCallgainslocalOpAsyncandstopSource. Both default to null, so a hand-built call is unaffected.cmake/DepCache.cmakeandMORPH_DEP_CACHEare gone;CPM_SOURCE_CACHEreplaces them.morph/net/detail/base64.hppis gone; use<core/Base64.hpp>.