Skip to content

Move morph onto core-cpp v0.5.0: coroutine model handlers on core-cpp strands, CPM dependencies - #806

Merged
Yaraslaut merged 18 commits into
masterfrom
build/core-cpp
Sep 26, 2026
Merged

Yaraslaut merged 18 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.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

  • Dependencies come through CPM instead of FetchContent, and morph links core-cpp's core::base, core::async, core::net and, natively, core::platform.
  • Timers, base64 and the wakeup pipe are core-cpp's. TimeoutScheduler keeps its API, but runs on core::net::PlatformLoop timers: on a thread natively, and on a host-driven loop in single-threaded WebAssembly.
  • Coroutines: Completion<T> is awaitable. morph::async::spawn and morph::async::delay exist. An action handler may return core::async::Task<R>.
  • Strands: morph's own strand executor is gone. Model instances run on core-cpp's coroutine-aware KeyedStrands, behind a per-instance action gate, so the next action waits while a Task handler is suspended.
  • Teardown loses no handler's end or resumption. It runs stop → drain → seal → drain → close where threads exist, and seal → stop → close on single-threaded WebAssembly.
  • Windows test runs fail on an assertion instead of opening a dialog.

Commits

Area Commits
Build 18fe141b build: fetch dependencies with CPM instead of FetchContent · 5dde1cb4 build: core-cpp v0.5.0 (with 6b97e0dc/cbddd266, the intermediate 0.4.x pins)
Timers da5a09b5 core: TimeoutScheduler is one implementation over core-cpp's event-loop timers
Net 37bd4591 net: base64 and the wakeup pipe come from core-cpp
Coroutines 970af908 docs(spec): coroutines on core::async, specified before they exist · 35e4865c core: awaitable completions and Task-returning model handlers · 4f3040e1 examples: a Task handler in the bank, and a coroutine flow in the pastebin GUI
Strands a0a629bf core: morph's strands and resume context are core-cpp 0.4.0's · e74d5270 core: a Task handler's resumer keeps a detached chain's claim · 478d4f84 core: a suspended handler's session reaches its resumptions only (core-cpp#53)
Teardown e9c47412 core: teardown seals the strands before it drains them · 6ddc6566 core: the threaded teardown drains before it seals · a4372a52 core: the teardown's docs follow the new order
Tests and CI 5a810abf tests: assertions fail the run on Windows instead of opening a dialog · ec7ab48a ci: valgrind schedules spinning threads fairly · 02ef77a8 ci: a coverage allowlist line hint

What changes, by area

Build

  • glaze, Catch2, doxygen-awesome-css and both Lightweight sites go through CPMAddPackage (cmake/CPM.cmake, 0.40.8, SHA-256 checked).
  • CPM's source cache (CPM_SOURCE_CACHE, default .cache/cpm) replaces cmake/DepCache.cmake; the CI workflows restore and save it.
  • glaze and Catch2 still try find_package first.
  • core-cpp is pinned to GIT_TAG v0.5.0. An install of morph installs core-cpp's package next to it, and find_package(morph) finds it through find_dependency(core-cpp 0.5).
  • The MORPH_CLIENT_ONLY link and run probes are build-time targets, because a try_compile project cannot link a library this project builds.

Timers and net

  • TimeoutScheduler keeps 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::encode replaces morph/net/detail/base64.hpp.
  • core::platform::Wakeup replaces SocketServer's nested WakeupPipe.

Coroutines (spec: 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.
  • A handler may return core::async::Task<R>. It is driven on its model's strand through a TaskResumer, and an execute deadline stops it while suspended.
  • The bank's BudgetModel::execute(SpendingByKind) and pastebin's PastePresenter::list use them; the WebAssembly ladder client compiles the latter.

Strands and sessions

  • Each model instance is a key of core-cpp's 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.
  • A suspended handler's morph::session is installed only around its own coroutine resumptions (RunTask::kind() == TaskKind::Resumption, core-cpp#53). Callables on the same key run without it, for example onBackendChanged or an action queued behind the handler.

Teardown (ModelStrands::teardown)

  • Threaded: stop → drain → seal → drain → close. The first drain queues a stopped handler's end behind its instance's other tasks, so two threads can never be inside one action gate. After the seal, a refused end or resumption runs inline and is never dropped.
  • Single-threaded WebAssembly: seal → stop → close.
  • There is no in-flight count. A handler that ignores its stop lets ~LocalBackend return and finishes inline later.

Tests and CI

  • Windows test binaries turn assertions and abort into failures instead of dialogs. A canary proves this in a child process.
  • Valgrind runs with --fair-sched=yes. One pre-existing spinning test took 1982 s without it and 63 s with it.

Compatibility

  • A project linking morph::morph now also builds core-cpp v0.5.0's static libraries, fetched through CPM, and needs a C++23 toolchain core-cpp supports.
  • Model authors may return core::async::Task<R> from execute. Existing handlers are 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 replies err "unknown exception" to a handler that throws something other than a std::exception. Before, it sent no reply, which left the caller waiting for its deadline.
  • LocalBackend no longer runs an action whose call switchBackend or ~Bridge already failed. Its destructor stops the Task handlers it started and tears its strands down in the order above.
  • ActionCall gains localOpAsync and stopSource, both null by default.
  • Code that named morph::exec::detail::StrandExecutor uses morph::exec::detail::ModelStrands instead. It's a detail type, so outside the versioned API; only morph's own tests and the ladder testkit named it.
  • Removed: cmake/DepCache.cmake, MORPH_DEP_CACHE, and morph/net/detail/base64.hpp (use <core/Base64.hpp>).

Testing

Local gates ran on 15c3a30a (master f4a6b642). The branch was then rebased onto master 150c8816 (#834) as 478d4f84; its tree is byte-identical to 15c3a30a'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.

Gate Result
MSVC cl-debug 1683/1683
MSVC /fsanitize=address, [coroutine],[timeout_scheduler],[concurrency],[strand] 80 cases, 10 of 10 runs
WSL GCC 15 gcc-debug 1881/1881
Valgrind (--fair-sched=yes), morph_tests and morph_net_tests 0 errors, nothing lost, no stall (local run on a9f9a64d; CI's Valgrind leg passes on 478d4f84)
WSL clang-asan passes, apart from the 4 OomInjector/morph#108 tests that ci.yml excludes under ASan (local run on a9f9a64d; CI's ASan leg passes on 478d4f84)
clang-format 22.1.8, clang-tidy clean on the changed files
CI on 478d4f84 40/40

Key tests, each red before its fix:

  • A DetachedTask parked on a core-cpp AsyncQueue inside a handler: heap-use-after-free under ASan before the resumer kept the claim.
  • A handler's end or resumption arriving after the drain: queued and then dropped before the seal existed (4 of 4 checks failed).
  • A stopped handler's end racing a queued failed call on the same key: the action gate overlapped (2 overlaps against 0 allowed) with the seal-first threaded order.
  • A callable posted to a key with an enrolled handler: it saw the handler's session before core-cpp#53.
  • 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

  • A detached chain that a finished handler left behind comes back as a resumption, so it runs under the session of whichever handler is current on that key. The docs say so.
  • core-cpp documents a limit on very deep chains of synchronously completing co_awaits in GCC -O0 and WebAssembly builds (core-cpp#15, closed as a decision). morph's handlers don't approach it.

Related

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 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 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
christianparpart marked this pull request as ready for review September 26, 2026 13:45
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>
@christianparpart christianparpart changed the title Move morph onto core-cpp v0.2.1, and add coroutines on core::async Move morph onto core-cpp v0.5.0: coroutine model handlers on core-cpp strands, CPM dependencies Sep 26, 2026
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>
@Yaraslaut
Yaraslaut merged commit 26bd41a into master Sep 26, 2026
40 checks passed
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.

2 participants