Skip to content

Move morph onto core-cpp v0.2.1, and add coroutines on core::async - #806

Draft
christianparpart wants to merge 8 commits into
masterfrom
build/core-cpp
Draft

christianparpart wants to merge 8 commits into
masterfrom
build/core-cpp

Conversation

@christianparpart

@christianparpart christianparpart commented Sep 24, 2026 •

Copy link
Copy Markdown
Member

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.

Draft, and blocked on core-cpp. Two gaps in core-cpp v0.2.1 keep parts of this red, and neither is worked around here (details in #805):

  1. core-cpp has no install()/export. With MORPH_INSTALL on — the default for a top-level morph — the configure fails at generate: install(EXPORT "morphTargets" ...) includes target "morph" which requires target "core-cpp-base" that is not in any export set. Every CI job that configures morph top-level hits this until core-cpp exports its targets. The local runs below pass -DMORPH_INSTALL=OFF.
  2. A host-driven PlatformLoop destroyed while a browser timer is pending frees memory that timer later writes. This affects the single-threaded WebAssembly TimeoutScheduler. It is reproduced natively under ASan.

What changes

  1. build: fetch dependencies with CPM instead of FetchContent
    • glaze, Catch2, doxygen-awesome-css and both Lightweight sites go through CPMAddPackage. cmake/CPM.cmake is core-cpp's pinned 0.40.8 bootstrap, SHA-256 checked.
    • cmake/DepCache.cmake is replaced by CPM's source cache: CPM_SOURCE_CACHE, default .cache/cpm. ci.yml, wasm-ladder.yml and wasm-demo.yml restore and save it.
    • glaze and Catch2 keep an explicit find_package first, rather than setting CPM_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.
  2. core: TimeoutScheduler is one implementation over core-cpp's event-loop timers
    • morph links core::base, core::async, core::net and, natively, core::platform, so a consumer of the header-only morph target now also builds those static libraries.
    • TimeoutScheduler keeps its API and its promises, but its deadlines are core::net::PlatformLoop timers:
      • natively, a thread runs the loop;
      • under single-threaded WebAssembly there is no thread: the browser pumps a host-driven loop.
    • cancel() still releases the callback's captures before it returns. In the browser it now also retires the timer.
    • Sanitizer legs instrument core-cpp's modules too.
    • The MORPH_CLIENT_ONLY link and run probes move from try_compile to build-time targets, because a try_compile project cannot link a library this project builds.
  3. net: base64 and the wakeup pipe come from core-cpp
    • core::base64::encode replaces morph/net/detail/base64.hpp.
    • core::platform::Wakeup replaces SocketServer's nested WakeupPipe.
  4. Coroutines on core::async — spec first, docs/spec/core/coroutines.md:
    • Completion<T> is awaitable through operator 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.
    • Action handlers may return core::async::Task<R>. They are driven on the model's strand through morph::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.
    • Demonstrated in bank's BudgetModel::execute(SpendingByKind) and pastebin's PastePresenter::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 of 05222af3 made 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.

Gate Commit Result Baseline
MSVC cl-debug (vcpkg) df3fbbd9 1634/1634, the three morph_windows_dialog_canary modes included 1602/1602
MSVC cl-debug + /fsanitize=address, [coroutine],[timeout_scheduler],[concurrency] df3fbbd9 1535 assertions in 40 test cases, all pass; 10 of 10 repeat runs pass See below.
Windows dialog canary (assert, abort, invalid-parameter, in a child process, 60-second timeout) df3fbbd9 all three pass, in both the ASan tree and the cl-debug tree
WSL GCC 15 gcc-debug (Catch2 3.4.0) df3fbbd9 1830/1830 1799/1800
WSL Clang 22 clang-asan df3fbbd9 1824/1824 once OomInjector|morph#108 is excluded, as ci.yml excludes them under ASan
WSL Clang 22 clang-debug e063ab38 (round 1) 1823/1823
clang-cl windows-everything, Qt 6.11.1, fuzzers off e063ab38 (round 1) 2099/2863; every failure is also in the baseline, except qt_tls_example_runs (a Qt DLL not on PATH, the same as the baseline when run alone). The failures both builds share are ODBC-backed: this host has no SQLite ODBC driver. 2077/2841
WebAssembly wasm-ladder: emsdk 3.1.56, Qt 6.8.3 wasm_singlethread, run locally with the workflow's configure e063ab38 (round 1) configure and build pass. It produces ladder_{bookmarks,pastebin,polls}_gui_wasm.wasm, and the pastebin client carries the coroutine demo.
WebAssembly wasm-demo: the same toolchain, target bank_gui_wasm, MinSizeRel e063ab38 (round 1) configure and build pass
clang-format 22 tip clean on every changed C++ file
drift-guard, spec-sync, mutation, test_dep_cache.sh N/A: removed upstream in morph#755 (6d6a535)
CPM source cache, checked by hand in place of test_dep_cache.sh (cl-debug configure, fresh CPM_SOURCE_CACHE) Cold: glaze and core-cpp were cloned into the cache. Warm, with a new build tree: both came from the cache, and nothing was cloned. Miss, with glaze deleted from the cache: only glaze was cloned again. All three configures passed.
Follow-up issue #807
CI, and find_package(morph CONFIG) consumability blocked on core-cpp v0.3.0 (install/export). Generate fails with install(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, with CORE_CPP_INSTALL ${MORPH_INSTALL} and find_dependency(core-cpp 0.3).

The tests below were red before their fixes.

  • Round 1, under MSVC ASan: a heap-use-after-free on a strand that Bridge::switchBackend had freed.
  • Round 2, against round 1's code:
    • A handler suspended on a completion was not stopped by a backend switch.
    • Nor was one suspended on a delay.
    • Nor was one that loops on a delay.
    • An action queued behind a suspended handler ran after its call had already failed: the order was 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-cpp AsyncQueue::pop was 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 cancelPending had failed without settling its sink, on the assumption that cancelPending would. That assumption does not hold when the run owns the last reference to the sink: a caller that drops its Completion leaves cancelPending holding only an expired weak_ptr. concurrent executeVia under repeated switchBackend caught it by leaving completions unresolved. The skipped run now settles the sink itself, with the reason cancelPending recorded. That test then passed 30 of 30 repeats.

Found by windows-everything in the first pass, and fixed in the commits they belong to:

  • lifetimebound on spawn's executor parameter: clang rejects it on a function that returns void;
  • core:: names resolving to morph::core wherever file_io_ops.hpp is included first.

Not from this branch: with MORPH_BUILD_FUZZERS=ON, windows-everything fails on master too, because clang-cl rejects -fno-omit-frame-pointer.

Consumer impact

  • A project linking morph::morph now builds core-cpp v0.2.1's core::base, core::async, core::net and core::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).
  • Model authors may return core::async::Task<R> from execute. Every existing handler is unchanged, and ActionTraits<A>::Result of a Task handler is R.
  • ActionDispatcher::dispatch throws std::logic_error for a Task handler, because it cannot wait for one. RemoteServer uses the new dispatchAsync. journal::replay cannot replay a Task handler's entries.
  • RemoteServer now replies err "unknown exception" to a handler that throws something other than a std::exception. It used to send no reply, which left the caller waiting for its deadline.
  • LocalBackend no longer runs an action whose call switchBackend or ~Bridge has already failed, and its destructor stops the Task handlers it started and waits for its strand to drain.
  • ActionCall gains localOpAsync and stopSource. Both default to null, so a hand-built call is unaffected.
  • Build:
    • cmake/DepCache.cmake and MORPH_DEP_CACHE are gone; CPM_SOURCE_CACHE replaces them.
    • morph/net/detail/base64.hpp is gone; use <core/Base64.hpp>.

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>

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant