Move morph onto core-cpp v0.5.0: coroutine model handlers on core-cpp strands, CPM dependencies - #806
Merged
Merged
Conversation
christianparpart
force-pushed
the
build/core-cpp
branch
11 times, most recently
from
September 25, 2026 11:03
86f6858 to
e984ebe
Compare
Codecov Report❌ Patch coverage is 📢 Thoughts on this report? Let us know! |
christianparpart
added a commit
that referenced
this pull request
Sep 25, 2026
Valgrind runs one thread at a time, and by default a thread that yields can take the lock straight back. test_strand_race.cpp's drain-race case parks four chasers spinning on a gate and a producer yielding on a counter, so the pool worker that runs each round's task waits on scheduling luck: 1982 s for that case alone locally, about 45 of master's 54 minutes in this job, and more than three hours on one run of #806, which is what stalled it. With --fair-sched=yes the case takes 63 s (4 of 4 runs), and the whole morph_tests suite 217 s with 0 errors; morph_net_tests 27 s, 0 errors. Signed-off-by: Christian Parpart <christian@parpart.family>
christianparpart
force-pushed
the
build/core-cpp
branch
from
September 25, 2026 17:22
5fbc32e to
9da54d3
Compare
christianparpart
added a commit
that referenced
this pull request
Sep 25, 2026
Valgrind runs one thread at a time, and by default a thread that yields can take the lock straight back. test_strand_race.cpp's drain-race case parks four chasers spinning on a gate and a producer yielding on a counter, so the pool worker that runs each round's task waits on scheduling luck: 1982 s for that case alone locally, about 45 of master's 54 minutes in this job, and more than three hours on one run of #806, which is what stalled it. With --fair-sched=yes the case takes 63 s (4 of 4 runs), and the whole morph_tests suite 217 s with 0 errors; morph_net_tests 27 s, 0 errors. Signed-off-by: Christian Parpart <christian@parpart.family>
Yaraslaut
added a commit
that referenced
this pull request
Sep 26, 2026
…rgin tests/test_offline_integration.cpp decided two NetworkMonitor outcomes by sleeping a fixed wall-clock margin (80ms, then 150ms) before asserting on state that a probe thread sets asynchronously. Under load, the probe thread can be scheduled later than that margin, so the assertion races OS scheduling latency instead of the behaviour under test — reproduced on CI (PR #806) and, deterministically here, by widening probeInterval past the fixed margins on unmodified code. Replace both sites with morph::testing::waitUntil, the polling idiom already used for the same NetworkMonitor operations in tests/test_network_monitor.cpp. The test now fails only when the monitor genuinely does not reach the expected state, not when its thread is late. Removing the fixed 150ms sleep exposed a second, previously-masked race: the test called handler.execute() as soon as replayed.size()==3 became true, but bridge.switchBackend() runs immediately *after* the replay in the same onOnline callback, on the probe thread. The old fixed sleep almost always gave switchBackend() enough incidental slack to finish too; waitUntil can return the instant the replay condition is met, without that slack, letting the main thread call execute() while the switch is still in flight (observed as CI#830's Windows failure). Fixed by waiting on an explicit backendSwitched flag set after switchBackend() returns, instead of relying on replay completion as a stand-in for it. Also: two scoped_lock declarations flagged by clang-tidy-diff (misc-const-correctness) marked const. Fixes #821 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018fEUahMFF32wQLiWjbsfkc Signed-off-by: Yaraslau Tamashevich <yaraslau.tamashevich@gmail.com>
christianparpart
force-pushed
the
build/core-cpp
branch
from
September 26, 2026 10:00
0bef300 to
e78768b
Compare
christianparpart
added a commit
that referenced
this pull request
Sep 26, 2026
Valgrind runs one thread at a time, and by default a thread that yields can take the lock straight back. test_strand_race.cpp's drain-race case parks four chasers spinning on a gate and a producer yielding on a counter, so the pool worker that runs each round's task waits on scheduling luck: 1982 s for that case alone locally, about 45 of master's 54 minutes in this job, and more than three hours on one run of #806, which is what stalled it. With --fair-sched=yes the case takes 63 s (4 of 4 runs), and the whole morph_tests suite 217 s with 0 errors; morph_net_tests 27 s, 0 errors. Signed-off-by: Christian Parpart <christian@parpart.family>
Yaraslaut
added a commit
that referenced
this pull request
Sep 26, 2026
…rgin (#830) tests/test_offline_integration.cpp decided two NetworkMonitor outcomes by sleeping a fixed wall-clock margin (80ms, then 150ms) before asserting on state that a probe thread sets asynchronously. Under load, the probe thread can be scheduled later than that margin, so the assertion races OS scheduling latency instead of the behaviour under test — reproduced on CI (PR #806) and, deterministically here, by widening probeInterval past the fixed margins on unmodified code. Replace both sites with morph::testing::waitUntil, the polling idiom already used for the same NetworkMonitor operations in tests/test_network_monitor.cpp. The test now fails only when the monitor genuinely does not reach the expected state, not when its thread is late. Removing the fixed 150ms sleep exposed a second, previously-masked race: the test called handler.execute() as soon as replayed.size()==3 became true, but bridge.switchBackend() runs immediately *after* the replay in the same onOnline callback, on the probe thread. The old fixed sleep almost always gave switchBackend() enough incidental slack to finish too; waitUntil can return the instant the replay condition is met, without that slack, letting the main thread call execute() while the switch is still in flight (observed as CI#830's Windows failure). Fixed by waiting on an explicit backendSwitched flag set after switchBackend() returns, instead of relying on replay completion as a stand-in for it. Also: two scoped_lock declarations flagged by clang-tidy-diff (misc-const-correctness) marked const. Fixes #821 Claude-Session: https://claude.ai/code/session_018fEUahMFF32wQLiWjbsfkc Signed-off-by: Yaraslau Tamashevich <yaraslau.tamashevich@gmail.com> Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
christianparpart
marked this pull request as ready for review
September 26, 2026 13:45
This was referenced Sep 26, 2026
christianparpart
force-pushed
the
build/core-cpp
branch
from
September 26, 2026 15:27
e78768b to
15c3a30
Compare
christianparpart
added a commit
that referenced
this pull request
Sep 26, 2026
Valgrind runs one thread at a time, and by default a thread that yields can take the lock straight back. test_strand_race.cpp's drain-race case parks four chasers spinning on a gate and a producer yielding on a counter, so the pool worker that runs each round's task waits on scheduling luck: 1982 s for that case alone locally, about 45 of master's 54 minutes in this job, and more than three hours on one run of #806, which is what stalled it. With --fair-sched=yes the case takes 63 s (4 of 4 runs), and the whole morph_tests suite 217 s with 0 errors; morph_net_tests 27 s, 0 errors. Signed-off-by: Christian Parpart <christian@parpart.family>
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>
Valgrind runs one thread at a time, and by default a thread that yields can take the lock straight back. test_strand_race.cpp's drain-race case parks four chasers spinning on a gate and a producer yielding on a counter, so the pool worker that runs each round's task waits on scheduling luck: 1982 s for that case alone locally, about 45 of master's 54 minutes in this job, and more than three hours on one run of #806, which is what stalled it. With --fair-sched=yes the case takes 63 s (4 of 4 runs), and the whole morph_tests suite 217 s with 0 errors; morph_net_tests 27 s, 0 errors. Signed-off-by: Christian Parpart <christian@parpart.family>
core-cpp 0.4.0 ships the strand morph had here, as core::async::Strand and KeyedStrands, with what morph's switch found missing: posted callables at one allocation, try-forms that refuse once closed, an around-task hook that is given the key, idle() and single-threaded teardown. morph pins it and keeps only what is morph's: - ModelStrands: KeyedStrands<ModelId> over CoreExecutorOver, an adapter that posts a strand's pump to a morph IExecutor as a lambda holding the handle alone, so a turn costs no allocation. Posted callables go through LoggedTask, which keeps morph's catch-and-log policy (a core-cpp strand would propagate a throw, and end the process under MSVC's cl). - TaskResumer replaces StrandCoroExecutor and StrandLink: the current executor wherever a Task handler runs, queuing its resumptions with trySubmit and resuming inline once the strands are closed. The action's session is installed through the keyed around-task hook, for the model instance whose handler is enrolled; one atomic load per task otherwise. - The awaiters take core::async::ResumeTarget::current() in place of morph's thread-local resume context, which is gone with morph::async::resumeContext(). A handler that awaits AsyncQueue::pop now comes back to its strand. - ~LocalBackend and ~SynchronousBackendAdapter drain, then close: core-cpp's strands drop queued work when closed. The single-threaded WebAssembly build cannot wait, and drops it. StrandExecutor's own tests, the race cases included, go with it: core-cpp tests KeyedStrands. test_strand.cpp now tests what ModelStrands adds. bench.alloc_budget: 7.06 allocations and 878.7 bytes per call, from 8.06 and 902.7; the budget is unchanged. Signed-off-by: Christian Parpart <christian@parpart.family>
Review of the core-cpp 0.4.0 switch. TaskResumer::submit(ParkedWork) forwarded the handle alone, so the claim a core::async::DetachedTask chain carries died armed in the call and freed the frame while its handle waited on the strand: a detached chain a handler started, parked on AsyncQueue::pop and resumed by a push. The resumer now queues the work with its claim (ModelStrands::trySubmit over KeyedStrands' ParkedWork form), and where the strands are closed disarms it and resumes inline. Also from the review: - the enrolment table's lock is a shared_mutex, so the around-task hook does not serialise every strand task while a handler is enrolled; - withdraw(key, resumer) erases only the resumer that is enrolled; - the non-std::exception throw of a posted task is its own case again; - backend.md says that while a Task handler is suspended its session reaches every task of its instance, onBackendChanged included: core-cpp's hook is given the task, not whether it is a resumption. Signed-off-by: Christian Parpart <christian@parpart.family>
From the switch's review. ~LocalBackend drained its strands and then closed them, and a resumption or a handler's end that arrived between the two was queued on a strand the close dropped: the call's sink went unsettled and the frame leaked. On the single-threaded build nothing drains at all, so every handler suspended at teardown leaked the same way. core-cpp 0.4.1's seal() refuses the try-forms and keeps running what is queued. ModelStrands::teardown(stopHandlers, order) does the whole sequence: - where threads exist, stop the Task handlers, seal, drain, close: a stopped handler still unwinds on its strand, and whatever arrives after the seal runs inline through the fallbacks TaskResumer::submit and runOnStrand already had; - on the single-threaded build, seal first, then stop: each stopped handler's resumption is refused and unwinds inline, since nothing else could run it. ~LocalBackend and ~SynchronousBackendAdapter tear down through it. The order is a parameter, so a native test drives the single-threaded one. Also from the review: clang-tidy's findings on test_strand.cpp, and core-cpp#53 cited where the specs say a suspended handler's session reaches its instance's other tasks. The pin moves to v0.4.1. Signed-off-by: Christian Parpart <christian@parpart.family>
Signed-off-by: Christian Parpart <christian@parpart.family>
stop -> drain -> seal -> drain -> close where threads exist. Sealing straight after the stop let a handler's end arriving from a socket's loop be refused and run inline there, leaving the model's action gate while the drain ran a queued task of the same instance, which enters it, on a pool thread. The first drain queues that end behind the instance's tasks instead. The single-threaded order is unchanged. The new ModelStrands teardown case fails on the old order (the overlap counter counts two, 5 of 5 runs) and passes on this one. The specs also say that a detached chain a finished handler left behind runs under the next handler's session and resumer (core-cpp#53). Signed-off-by: Christian Parpart <christian@parpart.family>
~LocalBackend's comment gives stop, drain, seal, drain, close where threads exist and why the first drain comes first, and the single-threaded order. coroutines.md scopes step 3's guarantee to the handler's own chain (a DetachedTask it started is refused and can run inline beside it) and names the direct-destruction overlap a stop-ignoring handler can cause. The N1 case keeps one probe out at a time, so the first drain does not race a post per poll. The allowlist hint for backend.hpp moves to 1379 with the comment. Signed-off-by: Christian Parpart <christian@parpart.family>
It fixes DetachedTask under clang-cl at -O0 (core-cpp#51), which morph's Task handlers can start, and ScopedCapture from several threads. No signature changes; morphConfig still finds core-cpp 0.4. Signed-off-by: Christian Parpart <christian@parpart.family>
A patch release: a socket operation that waits reuses its handle's park instead of filing one in the loop's id map (core-cpp#52). No signature changes; morphConfig still finds core-cpp 0.4. Signed-off-by: Christian Parpart <christian@parpart.family>
A new minor. Its breaking changes are the WFMO backend's removal (core-cpp#6), the environment API (core-cpp#7) and cli::parse's std::expected (core-cpp#13); morph uses none of those APIs. It adds RunTask::kind() (core-cpp#53). morphConfig finds core-cpp 0.5. Signed-off-by: Christian Parpart <christian@parpart.family>
…e-cpp#53)
The keyed around-task hook asks the task its kind and installs the
enrolled handler's session and resumer around a coroutine resumption
only; a posted callable -- onBackendChanged, an action queued behind
the handler, the handler's end -- runs bare. core-cpp 0.5.0's
RunTask::kind() makes that possible. The specs drop the note that it
could not be done; a detached chain a finished handler left behind is
still a resumption, and still runs under the next handler's session.
The strand test's enrolled-callable case asserted the old behaviour; it
now fails on it ("alice" == "<none>") and passes with the fix.
Signed-off-by: Christian Parpart <christian@parpart.family>
christianparpart
force-pushed
the
build/core-cpp
branch
from
September 26, 2026 16:59
15c3a30 to
478d4f8
Compare
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.5.0, the shared C++23 foundation of the Contour Terminal projects. Model handlers may now be coroutines, driven on core-cpp's strands. It is one branch because each step builds on the one before. Tracking issue: #805.
Summary
core::base,core::async,core::netand, natively,core::platform.TimeoutSchedulerkeeps its API, but runs oncore::net::PlatformLooptimers: on a thread natively, and on a host-driven loop in single-threaded WebAssembly.Completion<T>is awaitable.morph::async::spawnandmorph::async::delayexist. An action handler may returncore::async::Task<R>.KeyedStrands, behind a per-instance action gate, so the next action waits while a Task handler is suspended.Commits
18fe141bbuild: fetch dependencies with CPM instead of FetchContent ·5dde1cb4build: core-cpp v0.5.0 (with6b97e0dc/cbddd266, the intermediate 0.4.x pins)da5a09b5core: TimeoutScheduler is one implementation over core-cpp's event-loop timers37bd4591net: base64 and the wakeup pipe come from core-cpp970af908docs(spec): coroutines on core::async, specified before they exist ·35e4865ccore: awaitable completions and Task-returning model handlers ·4f3040e1examples: a Task handler in the bank, and a coroutine flow in the pastebin GUIa0a629bfcore: morph's strands and resume context are core-cpp 0.4.0's ·e74d5270core: a Task handler's resumer keeps a detached chain's claim ·478d4f84core: a suspended handler's session reaches its resumptions only (core-cpp#53)e9c47412core: teardown seals the strands before it drains them ·6ddc6566core: the threaded teardown drains before it seals ·a4372a52core: the teardown's docs follow the new order5a810abftests: assertions fail the run on Windows instead of opening a dialog ·ec7ab48aci: valgrind schedules spinning threads fairly ·02ef77a8ci: a coverage allowlist line hintWhat changes, by area
Build
CPMAddPackage(cmake/CPM.cmake, 0.40.8, SHA-256 checked).CPM_SOURCE_CACHE, default.cache/cpm) replacescmake/DepCache.cmake; the CI workflows restore and save it.find_packagefirst.GIT_TAG v0.5.0. An install of morph installs core-cpp's package next to it, andfind_package(morph)finds it throughfind_dependency(core-cpp 0.5).MORPH_CLIENT_ONLYlink and run probes are build-time targets, because atry_compileproject cannot link a library this project builds.Timers and net
TimeoutSchedulerkeeps its API and promises.cancel()still releases a callback's captures before it returns, and in the browser it now also retires the timer.core::base64::encodereplacesmorph/net/detail/base64.hpp.core::platform::WakeupreplacesSocketServer's nestedWakeupPipe.Coroutines (spec:
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>. It is driven on its model's strand through aTaskResumer, and an execute deadline stops it while suspended.BudgetModel::execute(SpendingByKind)and pastebin'sPastePresenter::listuse them; the WebAssembly ladder client compiles the latter.Strands and sessions
KeyedStrands. A resumer hands a parked coroutine to the strand together with its ownership claim, so a detached chain a handler started is never freed while it waits.morph::sessionis installed only around its own coroutine resumptions (RunTask::kind() == TaskKind::Resumption, core-cpp#53). Callables on the same key run without it, for exampleonBackendChangedor an action queued behind the handler.Teardown (
ModelStrands::teardown)~LocalBackendreturn and finishes inline later.Tests and CI
abortinto failures instead of dialogs. A canary proves this in a child process.--fair-sched=yes. One pre-existing spinning test took 1982 s without it and 63 s with it.Compatibility
morph::morphnow also builds core-cpp v0.5.0's static libraries, fetched through CPM, and needs a C++23 toolchain core-cpp supports.core::async::Task<R>fromexecute. Existing handlers are 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.RemoteServerreplieserr "unknown exception"to a handler that throws something other than astd::exception. Before, it sent no reply, which left the caller waiting for its deadline.LocalBackendno longer runs an action whose callswitchBackendor~Bridgealready failed. Its destructor stops the Task handlers it started and tears its strands down in the order above.ActionCallgainslocalOpAsyncandstopSource, both null by default.morph::exec::detail::StrandExecutorusesmorph::exec::detail::ModelStrandsinstead. It's adetailtype, so outside the versioned API; only morph's own tests and the ladder testkit named it.cmake/DepCache.cmake,MORPH_DEP_CACHE, andmorph/net/detail/base64.hpp(use<core/Base64.hpp>).Testing
Local gates ran on
15c3a30a(masterf4a6b642). The branch was then rebased onto master150c8816(#834) as478d4f84; its tree is byte-identical to15c3a30a's, so these gates apply to it unchanged, and CI passes 40/40 on it. Every tree was clean, and on Windows the dialog canary ran first.cl-debug/fsanitize=address,[coroutine],[timeout_scheduler],[concurrency],[strand]gcc-debug--fair-sched=yes),morph_testsandmorph_net_testsa9f9a64d; CI's Valgrind leg passes on478d4f84)OomInjector/morph#108 tests thatci.ymlexcludes under ASan (local run ona9f9a64d; CI's ASan leg passes on478d4f84)478d4f84Key tests, each red before its fix:
DetachedTaskparked on a core-cppAsyncQueueinside a handler: heap-use-after-free under ASan before the resumer kept the claim.bench.alloc_budget: an intermediate round went over the 9.0 ceiling (10.06 per local execute) when the action gate type-erased each run. The branch now spends 7.06 allocations (878.7 B) per local execute, against master's 8.06 (902.7 B).Known limitations
co_awaits in GCC-O0and WebAssembly builds (core-cpp#15, closed as a decision). morph's handlers don't approach it.Related