diff --git a/.clang-tidy b/.clang-tidy index 103f2fb56..6e603dcf8 100644 --- a/.clang-tidy +++ b/.clang-tidy @@ -27,6 +27,12 @@ CheckOptions: value: CamelCase - key: readability-identifier-naming.FunctionCase value: camelBack + # The C++ coroutine protocol: names the compiler looks up, which no naming + # convention can change. + - key: readability-identifier-naming.FunctionIgnoredRegexp + value: "^(await_ready|await_suspend|await_resume|get_return_object|initial_suspend|final_suspend|return_void|return_value|yield_value|unhandled_exception)$" + - key: readability-identifier-naming.ClassIgnoredRegexp + value: "^promise_type$" - key: readability-identifier-naming.VariableCase value: camelBack - key: readability-identifier-naming.MemberPrefix diff --git a/.github/self-hosted-runner/README.md b/.github/self-hosted-runner/README.md index 48a1a5017..4c0ae04a3 100644 --- a/.github/self-hosted-runner/README.md +++ b/.github/self-hosted-runner/README.md @@ -465,10 +465,11 @@ needed for that case. ## Dependency clones and HTTP/2 -`CMakeLists.txt` falls back to `FetchContent` for glaze when no installed -copy is found, so every Linux configure step does one anonymous -`git clone https://github.com/stephenberry/glaze.git` — the only -unauthenticated clone in the build. Inside this image that clone fails +`CMakeLists.txt` fetches glaze through CPM when no installed copy is +found, so a Linux configure whose CPM source cache (`.cache/cpm`, restored +by `actions/cache`) misses does one anonymous +`git clone https://github.com/stephenberry/glaze.git` — an unauthenticated +clone. Inside this image that clone fails most of the time: GitHub answers the `info/refs` GET with 200 and then the `git-upload-pack` POST on the same reused HTTP/2 connection with a spurious `401` and `www-authenticate: Basic realm="GitHub"`, which diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 44b8f0836..61fc6c5e6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -136,10 +136,10 @@ env: # # Which artefact this names, checked rather than assumed: # ubuntu-24.04's package is 3.4.0-1build1 and it ships /usr/lib/cmake/Catch2, - # so `find_package(Catch2 CONFIG QUIET)` at CMakeLists.txt:557 SUCCEEDS on - # this runner and the FetchContent v3.8.1 fallback never runs -- reproduced + # so the root CMakeLists.txt's `find_package(Catch2 CONFIG QUIET)` SUCCEEDS on + # this runner and the CPM v3.8.1 fallback never runs -- reproduced # in a clean ubuntu:24.04 container, and corroborated by this job's own log - # (no `_deps/catch2-*`, no dep-cache line for Catch2) and by the report above + # (no Catch2 fetched by CPM) and by the report above # naming /usr/include. The pin therefore guards the installation this job # really analyses against. Catch2 reaches the translation units through the # compiler's implicit /usr/include with no include flag at all: over the 751 @@ -167,6 +167,16 @@ jobs: steps: - uses: actions/checkout@v4 + # CPM's source cache (CMakeLists.txt defaults CPM_SOURCE_CACHE to + # .cache/cpm): restored, a configure clones nothing; missed, it clones + # and this step saves the result for the next run. + - name: Cache CPM sources + uses: actions/cache@v4 + with: + path: .cache/cpm + key: cpm-${{ runner.os }}-${{ hashFiles('cmake/CPM.cmake', '**/CMakeLists.txt') }} + restore-keys: cpm-${{ runner.os }}- + - name: Setup MSVC environment uses: ilammy/msvc-dev-cmd@v1 @@ -294,6 +304,16 @@ jobs: steps: - uses: actions/checkout@v4 + # CPM's source cache (CMakeLists.txt defaults CPM_SOURCE_CACHE to + # .cache/cpm): restored, a configure clones nothing; missed, it clones + # and this step saves the result for the next run. + - name: Cache CPM sources + uses: actions/cache@v4 + with: + path: .cache/cpm + key: cpm-${{ runner.os }}-${{ hashFiles('cmake/CPM.cmake', '**/CMakeLists.txt') }} + restore-keys: cpm-${{ runner.os }}- + - name: Cache apt packages uses: actions/cache@v4 with: @@ -518,6 +538,16 @@ jobs: steps: - uses: actions/checkout@v4 + # CPM's source cache (CMakeLists.txt defaults CPM_SOURCE_CACHE to + # .cache/cpm): restored, a configure clones nothing; missed, it clones + # and this step saves the result for the next run. + - name: Cache CPM sources + uses: actions/cache@v4 + with: + path: .cache/cpm + key: cpm-${{ runner.os }}-${{ hashFiles('cmake/CPM.cmake', '**/CMakeLists.txt') }} + restore-keys: cpm-${{ runner.os }}- + - name: Cache apt packages uses: actions/cache@v4 with: @@ -730,6 +760,16 @@ jobs: steps: - uses: actions/checkout@v4 + # CPM's source cache (CMakeLists.txt defaults CPM_SOURCE_CACHE to + # .cache/cpm): restored, a configure clones nothing; missed, it clones + # and this step saves the result for the next run. + - name: Cache CPM sources + uses: actions/cache@v4 + with: + path: .cache/cpm + key: cpm-${{ runner.os }}-${{ hashFiles('cmake/CPM.cmake', '**/CMakeLists.txt') }} + restore-keys: cpm-${{ runner.os }}- + - name: Cache apt packages uses: actions/cache@v4 with: @@ -931,6 +971,16 @@ jobs: steps: - uses: actions/checkout@v4 + # CPM's source cache (CMakeLists.txt defaults CPM_SOURCE_CACHE to + # .cache/cpm): restored, a configure clones nothing; missed, it clones + # and this step saves the result for the next run. + - name: Cache CPM sources + uses: actions/cache@v4 + with: + path: .cache/cpm + key: cpm-${{ runner.os }}-${{ hashFiles('cmake/CPM.cmake', '**/CMakeLists.txt') }} + restore-keys: cpm-${{ runner.os }}- + - name: Cache apt packages uses: actions/cache@v4 with: @@ -1150,6 +1200,16 @@ jobs: steps: - uses: actions/checkout@v4 + # CPM's source cache (CMakeLists.txt defaults CPM_SOURCE_CACHE to + # .cache/cpm): restored, a configure clones nothing; missed, it clones + # and this step saves the result for the next run. + - name: Cache CPM sources + uses: actions/cache@v4 + with: + path: .cache/cpm + key: cpm-${{ runner.os }}-${{ hashFiles('cmake/CPM.cmake', '**/CMakeLists.txt') }} + restore-keys: cpm-${{ runner.os }}- + - name: Cache apt packages uses: actions/cache@v4 with: @@ -1341,6 +1401,16 @@ jobs: steps: - uses: actions/checkout@v4 + # CPM's source cache (CMakeLists.txt defaults CPM_SOURCE_CACHE to + # .cache/cpm): restored, a configure clones nothing; missed, it clones + # and this step saves the result for the next run. + - name: Cache CPM sources + uses: actions/cache@v4 + with: + path: .cache/cpm + key: cpm-${{ runner.os }}-${{ hashFiles('cmake/CPM.cmake', '**/CMakeLists.txt') }} + restore-keys: cpm-${{ runner.os }}- + - name: Cache apt packages uses: actions/cache@v4 with: @@ -1446,6 +1516,16 @@ jobs: with: fetch-depth: 0 # need history for the changed-paths diff below + # CPM's source cache (CMakeLists.txt defaults CPM_SOURCE_CACHE to + # .cache/cpm): restored, a configure clones nothing; missed, it clones + # and this step saves the result for the next run. + - name: Cache CPM sources + uses: actions/cache@v4 + with: + path: .cache/cpm + key: cpm-${{ runner.os }}-${{ hashFiles('cmake/CPM.cmake', '**/CMakeLists.txt') }} + restore-keys: cpm-${{ runner.os }}- + - name: Determine whether the ladder needs to run id: filter run: | @@ -1916,6 +1996,16 @@ jobs: with: fetch-depth: 0 # need history for the changed-paths diff below + # CPM's source cache (CMakeLists.txt defaults CPM_SOURCE_CACHE to + # .cache/cpm): restored, a configure clones nothing; missed, it clones + # and this step saves the result for the next run. + - name: Cache CPM sources + uses: actions/cache@v4 + with: + path: .cache/cpm + key: cpm-${{ runner.os }}-${{ hashFiles('cmake/CPM.cmake', '**/CMakeLists.txt') }} + restore-keys: cpm-${{ runner.os }}- + # Same filter as ladder-tests, and now literally the same generator # rather than a second hand-copy of the same list: the two jobs build the # identical tree and differ only in instrumentation, so a change that @@ -2220,6 +2310,16 @@ jobs: steps: - uses: actions/checkout@v4 + # CPM's source cache (CMakeLists.txt defaults CPM_SOURCE_CACHE to + # .cache/cpm): restored, a configure clones nothing; missed, it clones + # and this step saves the result for the next run. + - name: Cache CPM sources + uses: actions/cache@v4 + with: + path: .cache/cpm + key: cpm-${{ runner.os }}-${{ hashFiles('cmake/CPM.cmake', '**/CMakeLists.txt') }} + restore-keys: cpm-${{ runner.os }}- + - name: Cache apt packages uses: actions/cache@v4 with: @@ -2528,6 +2628,16 @@ jobs: steps: - uses: actions/checkout@v4 + # CPM's source cache (CMakeLists.txt defaults CPM_SOURCE_CACHE to + # .cache/cpm): restored, a configure clones nothing; missed, it clones + # and this step saves the result for the next run. + - name: Cache CPM sources + uses: actions/cache@v4 + with: + path: .cache/cpm + key: cpm-${{ runner.os }}-${{ hashFiles('cmake/CPM.cmake', '**/CMakeLists.txt') }} + restore-keys: cpm-${{ runner.os }}- + - name: Cache apt packages uses: actions/cache@v4 with: @@ -2602,7 +2712,15 @@ jobs: if [ "$suite" = "tests/morph_tests" ]; then EXTRA_ARGS=("~[oom-injector]" "~[issue108]") fi + # --fair-sched=yes: Valgrind runs one thread at a time, and by + # default a thread that yields can take the lock straight back. + # A test whose threads spin or yield while they wait for another + # thread then waits on luck. The strand drain-race case, removed + # with morph's own strand, did: 1982 s alone, 45 of master's 54 + # minutes here, and past three hours on one run of #806; with a + # fair queue, 63 s. Kept for the next test that waits that way. valgrind \ + --fair-sched=yes \ --tool=memcheck \ --leak-check=full \ --show-leak-kinds=definite,indirect \ @@ -2712,6 +2830,16 @@ jobs: with: fetch-depth: 0 # need full history for git diff against base + # CPM's source cache (CMakeLists.txt defaults CPM_SOURCE_CACHE to + # .cache/cpm): restored, a configure clones nothing; missed, it clones + # and this step saves the result for the next run. + - name: Cache CPM sources + uses: actions/cache@v4 + with: + path: .cache/cpm + key: cpm-${{ runner.os }}-${{ hashFiles('cmake/CPM.cmake', '**/CMakeLists.txt') }} + restore-keys: cpm-${{ runner.os }}- + - name: Cache apt packages uses: actions/cache@v4 with: @@ -3397,7 +3525,7 @@ jobs: # the one clang-tidy will ask. python3 /tmp/filter-unbuilt-sources.py \ build/clang-debug/compile_commands.json /tmp/changed.diff /tmp/analysed.diff \ - /tmp/tidy-db -std=c++23 -Wno-missing-include-dirs + /tmp/tidy-db -std=c++23 -Wno-missing-include-dirs -Wno-pragma-once-outside-header # -path /tmp/tidy-db, not build/clang-debug: that is the augmented # database, and pointing clang-tidy at it is the whole fix for @@ -3412,6 +3540,7 @@ jobs: -j "$(nproc)" \ -extra-arg=-std=c++23 \ -extra-arg=-Wno-missing-include-dirs \ + -extra-arg=-Wno-pragma-once-outside-header \ -quiet \ < /tmp/analysed.diff \ 2>&1 | tee clang-tidy-report.txt; then @@ -3434,6 +3563,16 @@ jobs: steps: - uses: actions/checkout@v4 + # CPM's source cache (CMakeLists.txt defaults CPM_SOURCE_CACHE to + # .cache/cpm): restored, a configure clones nothing; missed, it clones + # and this step saves the result for the next run. + - name: Cache CPM sources + uses: actions/cache@v4 + with: + path: .cache/cpm + key: cpm-${{ runner.os }}-${{ hashFiles('cmake/CPM.cmake', '**/CMakeLists.txt') }} + restore-keys: cpm-${{ runner.os }}- + # GCC 15 for the same reason the gcc-* presets use it: the default # logger needs a standard library with , and ubuntu-24.04 ships # GCC 13. diff --git a/.github/workflows/wasm-demo.yml b/.github/workflows/wasm-demo.yml index 60cdef30b..c2e01b14a 100644 --- a/.github/workflows/wasm-demo.yml +++ b/.github/workflows/wasm-demo.yml @@ -40,6 +40,16 @@ jobs: - name: Checkout uses: actions/checkout@v4 + # CPM's source cache (CMakeLists.txt defaults CPM_SOURCE_CACHE to + # .cache/cpm): restored, a configure clones nothing; missed, it clones + # and this step saves the result for the next run. + - name: Cache CPM sources + uses: actions/cache@v4 + with: + path: .cache/cpm + key: cpm-${{ runner.os }}-${{ hashFiles('cmake/CPM.cmake', '**/CMakeLists.txt') }} + restore-keys: cpm-${{ runner.os }}- + - name: Install build tools run: | sudo apt-get update -q diff --git a/.github/workflows/wasm-ladder.yml b/.github/workflows/wasm-ladder.yml index e8f799021..0d24d8e66 100644 --- a/.github/workflows/wasm-ladder.yml +++ b/.github/workflows/wasm-ladder.yml @@ -80,6 +80,16 @@ jobs: - name: Checkout uses: actions/checkout@v4 + # CPM's source cache (CMakeLists.txt defaults CPM_SOURCE_CACHE to + # .cache/cpm): restored, a configure clones nothing; missed, it clones + # and this step saves the result for the next run. + - name: Cache CPM sources + uses: actions/cache@v4 + with: + path: .cache/cpm + key: cpm-${{ runner.os }}-${{ hashFiles('cmake/CPM.cmake', '**/CMakeLists.txt') }} + restore-keys: cpm-${{ runner.os }}- + - name: Install build tools run: | sudo apt-get update -q diff --git a/CHANGELOG.md b/CHANGELOG.md index fbc9341b6..368c6e828 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,51 @@ API surface). ### Changed +- **morph's per-model strands are core-cpp's `KeyedStrands`.** + `morph::exec::detail::StrandExecutor` is gone: a backend's strands are + `morph::exec::detail::ModelStrands`, over core-cpp 0.4.0's + `core::async::KeyedStrands`, and morph keeps only the adapter to its + `IExecutor`, the log of a throwing task and a Task handler's session. What a + consumer can see: + - A Task handler that awaits `core::async::AsyncQueue::pop` comes back to its + model's strand, with its session, a stop included. + - An ordinary execute allocates less: `bench.alloc_budget` measures the local + round trip. + - Under single-threaded WebAssembly, `~LocalBackend` and + `~SynchronousBackendAdapter` no longer wait for their strands, which only + that thread could run. `~LocalBackend` seals its strands before it stops + its Task handlers, so each one unwinds inline; other work still queued is + dropped. + - A strand queues itself on the backend's `IExecutor` once per turn and runs + up to 32 tasks there, where it used to post each task. + +- **`LocalBackend` no longer runs an action whose call was already failed.** + An action still waiting for its model when `Bridge::switchBackend` or + `~Bridge` fails its call (`BackendChangedError`, `BridgeDestroyedError`) is + now skipped when its turn comes: its handler does not run, and it used to. + A skipped action still counts as a failed execute in the + `executeErrors` metric and ends its span as failed. + `~LocalBackend` also stops the Task handlers it started, then waits for its + strand to drain before it returns. See `docs/spec/core/coroutines.md`, + "Teardown". + +- **morph depends on core-cpp v0.5.0.** It is fetched through CPM, and + `morph::morph` links its `core::base`, `core::async`, `core::net` and, natively, + `core::platform`. morph stays header-only, but those are static libraries, so + a project that links morph now builds them. `TimeoutScheduler` runs on + core-cpp's event-loop timers: natively on a thread of its own, and under + single-threaded WebAssembly on a loop the browser's timer pumps, where + `cancel()` now also retires the timer. 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)`. + +- **Dependencies are fetched through CPM, and cached by CPM.** glaze, Catch2, + doxygen-awesome-css and Lightweight come through `CPMAddPackage` when no + installed copy is found, and CPM keeps their sources in `CPM_SOURCE_CACHE`, + which defaults to `.cache/cpm` inside the checkout. A second configure of the + checkout clones nothing. `cmake/DepCache.cmake` and its `MORPH_DEP_CACHE` + environment variable are gone: set `CPM_SOURCE_CACHE` instead. + - **`LocalBackend::execute` no longer rescans the pending-completion list on every dispatch.** `trackPending` used to `std::erase_if` the whole `_pending` vector before each append, so admitting one call with *n* already in flight @@ -119,6 +164,32 @@ API surface). ### Added +- **Coroutines on `core::async`.** + - `Completion` is awaitable: `co_await std::move(completion)` yields `T` + or rethrows, resumes on the executor the coroutine suspended on (core-cpp's + current-executor context), and honours a stop request on the awaiting + coroutine's token. + - `morph::async::spawn(executor, task)` starts a coroutine from ordinary + code, with every step on that executor. + - `morph::async::delay(scheduler, duration)` is a stop-aware timer. + - A model's `execute` may return `core::async::Task`. The bridge drives it + on the model's strand, and the model's next action waits until the Task has + completed. `ActionTraits::Result` is `R`. + - `ActionDispatcher::dispatchAsync` runs Task handlers remotely; + `ActionDispatcher::dispatch` throws `std::logic_error` for one, and + `ActionDispatcher::dispatchesAsync` says which of the two an action needs. + - `RemoteServer` now replies `err "unknown exception"` to a handler that + throws something other than a `std::exception`, where it used to send no + reply. + - `LimitPolicy::executeTimeout` also stops a suspended Task handler, so the + model's next action is not held behind it. + - A Task handler follows `ActionRecordingError` as an ordinary handler does: + once its Task has completed, a result that will not serialise or a journal + append that throws reaches the caller as `ActionRecordingError`, and is + never journalled as `Outcome::Failed`. + + See `docs/spec/core/coroutines.md`. + - **`SlotRegistry.byKind(kind, component)` — one host control per kind of control.** The JSON type `byType` keys on does not identify a control: a `Quantity` and a nested object are both `"object"`, a `Choice` is @@ -335,6 +406,10 @@ API surface). ### Removed +- **`morph/net/detail/base64.hpp`.** The WebSocket handshake uses core-cpp's + `core::base64::encode` (``), and `SocketServer`'s accept loop + waits on `core::platform::Wakeup` instead of a self-pipe of its own. + - **The reactive-draft mechanism** — `BridgeHandler::set<&A::field>`, `reset`, the action-keyed `subscribe`, and their in-flight coalescing. Its job is done better by a stateful model holding the draft itself, and @@ -347,6 +422,14 @@ API surface). ### Fixed +- **A `then()` or `onError()` handler attached after its `Completion` settled + let its exception escape into the executor.** A handler attached before + settlement has always been logged and skipped when it throws; one attached + after ran in a closure of its own with no `try`, so its throw reached the + attaching call over an inline executor, or the pump over a pumped one. Both + now log and continue. `Presenter::track()`'s destroy-then-throw tests failed + whenever the backend settled before `track()` attached. + - **An installed `qt_forms` component compiles.** `forms_controller_core.hpp` includes `morph/qt/qt_executor.hpp`, which was installed only by the `qt` component (`MORPH_BUILD_QT`, which needs Qt WebSockets), so an install built diff --git a/CMakeLists.txt b/CMakeLists.txt index 0b937c84e..f2c391f3e 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -111,7 +111,7 @@ include(cmake/compiler_options.cmake) # Compiler-cache launcher selection: fastcache-cc (when a fastcached daemon # answers) -> sccache -> ccache -> none. Included before any target or fetched -# dependency (FetchContent glaze below) so those get cached too. See +# dependency (glaze and core-cpp, through CPM, below) so those get cached too. See # cmake/CompileCache.cmake for the full selection logic; it declares its own # USE_COMPILER_CACHE/FASTCACHE_ADDR options. include(cmake/CompileCache.cmake) @@ -129,23 +129,100 @@ if(MORPH_BUILD_TESTS) enable_testing() endif() +# ── Dependencies: CPM, over one shared source cache ───────────────────────── +# Every dependency morph fetches goes through CPMAddPackage, and CPM keeps the +# sources it clones in CPM_SOURCE_CACHE, keyed on the package and its revision. +# A second configure -- another preset, another build directory, the install +# check's scratch tree -- then clones nothing, and CI restores the directory +# from actions/cache rather than cloning glaze, Catch2, Lightweight and +# doxygen-awesome-css again on every job: a burst of anonymous clones from one +# egress address is answered with 401 by github.com, which git reports as +# "could not read Username". A cache miss still fetches. +# +# The default is inside the checkout (.cache/ is ignored), so it needs no +# setup and is never shared between checkouts by surprise. An explicit +# -DCPM_SOURCE_CACHE or the environment variable of the same name wins. +if(NOT DEFINED CPM_SOURCE_CACHE AND NOT DEFINED ENV{CPM_SOURCE_CACHE}) + set(CPM_SOURCE_CACHE "${CMAKE_CURRENT_SOURCE_DIR}/.cache/cpm" CACHE PATH + "Where CPM keeps the sources of every fetched dependency") +endif() +include(cmake/CPM.cmake) + # ── glaze ──────────────────────────────────────────────────────────────────── -# On Windows vcpkg provides glaze. On Linux we fall back to FetchContent so -# that CI does not require vcpkg. The version bound matters: without it an -# older system/user install (e.g. a stray 4.x in ~/.local) silently shadows -# the pinned v7.4.0 and breaks schema generation. +# On Windows vcpkg provides glaze. Elsewhere CPM fetches it, so that CI does +# not require vcpkg. The version bound matters: without it an older +# system/user install (e.g. a stray 4.x in ~/.local) silently shadows the +# pinned v7.4.0 and breaks schema generation. # One place for the bound, because morphConfig.cmake has to state it too: an # installed morph whose package config asks for a different glaze than the # build used would resolve a header set the build never compiled against. +# +# find_package first and CPMAddPackage only when it finds nothing, rather than +# CPM_USE_LOCAL_PACKAGES: that option is global, so it would also change how +# Lightweight's own CPM dependencies resolve, and it passes the fetched pin as +# the version it asks find_package for -- which for Catch2 below would refuse +# the distribution's 3.4.0 that the clang-tidy leg is pinned to analyse. set(MORPH_GLAZE_VERSION 7.4) find_package(glaze ${MORPH_GLAZE_VERSION} CONFIG QUIET) if(NOT glaze_FOUND) - include(FetchContent) - include(cmake/DepCache.cmake) - morph_declare_dep(glaze https://github.com/stephenberry/glaze.git v7.4.0 - GIT_SHALLOW TRUE) - set(glaze_ENABLE_TESTS OFF CACHE BOOL "" FORCE) - FetchContent_MakeAvailable(glaze) + CPMAddPackage( + NAME glaze + GITHUB_REPOSITORY stephenberry/glaze + GIT_TAG v7.4.0 + OPTIONS "glaze_ENABLE_TESTS OFF") +endif() + +# ── core-cpp ──────────────────────────────────────────────────────────────── +# The shared C++23 foundation of the Contour Terminal projects: morph takes +# its event loop and timers, base64 and the wakeup primitive from it. Only the +# modules morph links are built (EXCLUDE_FROM_ALL, unless morph installs; see +# below), none of core-cpp's own tests, examples, TUI or TLS, and nothing is +# fetched on its behalf: with those off it needs no dependency but the thread +# library. Under Emscripten it +# builds its single-threaded WebAssembly subset, which is what morph uses +# there. SYSTEM, so morph's warning set does not apply to core-cpp's headers. +# +# morph stays a header-only INTERFACE target, but core::base, core::net and +# core::platform are static libraries, so every consumer of morph now builds +# them. +# +# morph's install exports morph::morph, which links core-cpp's modules, so an +# install of morph has to install core-cpp too: CMake refuses an export that +# names a target in no export set. CORE_CPP_INSTALL follows MORPH_INSTALL for +# that, and morphConfig.cmake finds the installed core-cpp package in turn. +# MORPH_INSTALL is described with the install rules below. EXCLUDE_FROM_ALL +# is off while morph installs: CMake leaves an excluded subdirectory's install +# rules out of the parent's install, so `cmake --install` would install +# morph's package without the core-cpp package it depends on. +option(MORPH_INSTALL "Generate morph's install and export rules" ${PROJECT_IS_TOP_LEVEL}) +if(MORPH_INSTALL) + set(_morph_core_cpp_exclude_from_all NO) +else() + set(_morph_core_cpp_exclude_from_all YES) +endif() +CPMAddPackage( + NAME core-cpp + GITHUB_REPOSITORY contour-terminal/core-cpp + GIT_TAG v0.5.0 + VERSION 0.5.0 + SYSTEM YES + EXCLUDE_FROM_ALL ${_morph_core_cpp_exclude_from_all} + OPTIONS "CORE_CPP_TESTING OFF" "CORE_CPP_BUILD_EXAMPLES OFF" "CORE_CPP_WITH_TUI OFF" + "CORE_CPP_WITH_TLS OFF" "CORE_CPP_FETCH_DEPS OFF" "CORE_CPP_INSTALL ${MORPH_INSTALL}") +unset(_morph_core_cpp_exclude_from_all) + +# A sanitizer leg instruments core-cpp's compiled modules with morph's own +# targets: TimeoutScheduler's loop thread runs inside core::net, and a +# ThreadSanitizer that cannot see one side of a hand-off reports races that +# are not there and misses ones that are. core-cpp lists those targets in the +# CORE_CPP_TARGETS global property for exactly this. +if(DEFINED AF_SANITIZER) + get_property(_morph_core_cpp_targets GLOBAL PROPERTY CORE_CPP_TARGETS) + foreach(_morph_core_cpp_target IN LISTS _morph_core_cpp_targets) + apply_sanitizers(${_morph_core_cpp_target} ${AF_SANITIZER}) + endforeach() + unset(_morph_core_cpp_targets) + unset(_morph_core_cpp_target) endif() # Emscripten's single-threaded build has no pthreads; requiring Threads there @@ -163,7 +240,10 @@ target_include_directories(morph INTERFACE $ $ ) -target_link_libraries(morph INTERFACE glaze::glaze) +target_link_libraries(morph INTERFACE glaze::glaze core::base core::async core::net) +if(NOT EMSCRIPTEN) + target_link_libraries(morph INTERFACE core::platform) +endif() if(NOT WIN32 AND NOT EMSCRIPTEN) target_link_libraries(morph INTERFACE Threads::Threads) endif() @@ -190,6 +270,7 @@ target_sources(morph include/morph/core/executor.hpp include/morph/core/strand.hpp include/morph/core/completion.hpp + include/morph/core/coroutine.hpp include/morph/core/callback_scope.hpp include/morph/core/async.hpp include/morph/core/timeout_scheduler.hpp @@ -266,7 +347,9 @@ target_sources(morph FILES include/morph/detail/fixed_string.hpp include/morph/detail/quantity_equation.hpp + include/morph/core/detail/completion_awaiter.hpp include/morph/core/detail/execute_order_gate.hpp + include/morph/core/detail/task_handler.hpp include/morph/core/detail/instance_directory.hpp include/morph/core/detail/reply_router.hpp include/morph/core/detail/subscription_registry.hpp @@ -561,11 +644,14 @@ endif() if(MORPH_BUILD_TESTS) find_package(Catch2 CONFIG QUIET) if(NOT Catch2_FOUND) - include(FetchContent) - include(cmake/DepCache.cmake) - morph_declare_dep(Catch2 https://github.com/catchorg/Catch2.git v3.8.1 - GIT_SHALLOW TRUE) - FetchContent_MakeAvailable(Catch2) + CPMAddPackage( + NAME Catch2 + GITHUB_REPOSITORY catchorg/Catch2 + GIT_TAG v3.8.1) + # Catch2 hands its `extras/` (Catch.cmake, catch_discover_tests) to + # its parent scope, which under CPM is the CPMAddPackage function + # rather than this directory; tests/ includes Catch from here. + list(APPEND CMAKE_MODULE_PATH "${Catch2_SOURCE_DIR}/extras") endif() # ── The `--log-level` gate, shared by every Catch2 suite ──────────────── @@ -591,6 +677,33 @@ if(MORPH_BUILD_TESTS) target_include_directories(morph_test_log_level INTERFACE ${CMAKE_CURRENT_SOURCE_DIR}/tests) target_link_libraries(morph_test_log_level INTERFACE morph::morph Catch2::Catch2) + # On Windows a failed assert(), an abort() or a crash in a test opens a + # modal dialog (CRT assert, abort, Windows Error Reporting) and waits for a + # click: under ctest nobody clicks, so the test holds the run until its + # timeout, and on a desktop it lands on the user's screen. core-cpp's + # core::testing_dialogs is one object whose static initialiser calls + # core::testing::suppressWindowsDialogs() before main() runs: CRT reports + # and abort()'s message go to stderr, abort() asks for no fault report, + # Windows Error Reporting shows no UI, and the process exits, so the test + # fails loudly instead. It is linked into test executables only, never into + # morph or the example applications. + # + # Every Catch2 suite reaches it through morph_test_log_level, which each of + # them links (through morph_test_main or directly, for the ones that own a + # QCoreApplication main). An OBJECT library's objects reach only the target + # that links it directly, so they are named as an interface link item too, + # as core-cpp's own core::testing_main does. Test executables without + # Catch2 call morph_suppress_test_dialogs() themselves. + function(morph_suppress_test_dialogs target) + if(WIN32 AND TARGET core-cpp-testing_dialogs) + target_link_libraries(${target} PRIVATE core::testing_dialogs) + endif() + endfunction() + if(WIN32 AND TARGET core-cpp-testing_dialogs) + target_link_libraries(morph_test_log_level INTERFACE + core::testing_dialogs "$") + endif() + # STATIC, not OBJECT: the only symbol it carries is `main`, which is always # an undefined symbol in the linking executable, so the archive member is # always extracted. (An OBJECT library would work too; a listener-based @@ -619,7 +732,7 @@ endif() # treats "not found" as a hard FATAL_ERROR (its own Catch2 does not get # fetched -- it relies on MORPH_BUILD_TESTS=ON having already resolved one). # Adding examples/ before this point would let that find_package() run before -# the Tests section's FetchContent fallback ever executes, breaking the +# the Tests section's CPM fallback ever executes, breaking the # no-system-Catch2 case even though MORPH_BUILD_TESTS=ON. if(MORPH_BUILD_LADDER) add_subdirectory(examples) @@ -730,7 +843,6 @@ if(MORPH_BUILD_NET) FILE_SET HEADERS BASE_DIRS include FILES - include/morph/net/detail/base64.hpp include/morph/net/detail/sha1.hpp include/morph/net/detail/ws_handshake.hpp include/morph/net/detail/tcp_socket.hpp @@ -775,7 +887,7 @@ morph_verify_warning_flags() # Before this section existed, `cmake --install` on a morph build **exited 0** # and installed Glaze's headers plus a working glazeConfig.cmake -- Glaze # carries its own install/export rules and gets them for free through -# FetchContent -- while installing zero morph headers and no +# FetchContent or CPM -- while installing zero morph headers and no # morphConfig.cmake. A consumer running the standard CMake install workflow # got a prefix silently holding someone else's dependency and none of the # library they meant to install, with nothing to flag it (morph#232). Every @@ -783,11 +895,13 @@ morph_verify_warning_flags() # install(TARGETS ... FILE_SET HEADERS) consumes directly, so the rules below # are mechanical rather than a retrofit. # -# Off when morph is a subproject: a parent that FetchContent's or +# Off when morph is a subproject: a parent that fetches (CPM, FetchContent) or # add_subdirectory's morph installs its own artefacts, and inheriting ours # would put morph's headers and package config into that project's prefix # uninvited -- which is the mirror image of the bug above. -option(MORPH_INSTALL "Generate morph's install and export rules" ${PROJECT_IS_TOP_LEVEL}) +# +# MORPH_INSTALL itself is declared above, before core-cpp is added, because +# core-cpp's own install rules follow it. if(MORPH_INSTALL) include(GNUInstallDirs) diff --git a/README.md b/README.md index a835cd1ef..e2562b0d4 100644 --- a/README.md +++ b/README.md @@ -265,7 +265,7 @@ opt-in header you include only if you need it. | Namespace | Header(s) | What it gives you | |---|---|---| -| `morph::exec` | `executor.hpp`, `strand.hpp` | `IExecutor`, `ThreadPoolExecutor`, `MainThreadExecutor`, per-model `StrandExecutor` | +| `morph::exec` | `executor.hpp`, `strand.hpp` | `IExecutor`, `ThreadPoolExecutor`, `MainThreadExecutor`, per-model strands (`ModelStrands`, over core-cpp's `KeyedStrands`) | | `morph::async` | `completion.hpp` | `Completion` — move-only result handle with `.then` / `.onError` | | `morph::model` | `registry.hpp`, `model.hpp`, `model_key.hpp` | Registration traits, validators, `ActionDispatcher`, type-erased holders, model primary keys | | `morph::backend` | `backend.hpp`, `remote.hpp` | `LocalBackend`, `RemoteServer`, `SimulatedRemoteBackend` | @@ -415,9 +415,20 @@ validates) and enforce security-critical checks inside the model. See - **Compiler:** C++23 (developed against recent Clang; libstdc++/libc++). The default logger uses `std::println`, so a C++23 standard library is required. -- **Dependencies:** [Glaze](https://github.com/stephenberry/glaze) (JSON - reflection), fetched via [vcpkg](https://vcpkg.io) (`vcpkg.json` manifest). - Optional: Qt 6 for the WebSocket transport and QML example. +- **Dependencies:** + - [Glaze](https://github.com/stephenberry/glaze) (JSON reflection), from + [vcpkg](https://vcpkg.io) (`vcpkg.json` manifest) or fetched through CPM. + - [core-cpp](https://github.com/contour-terminal/core-cpp) v0.5.0, the + shared C++23 foundation of the Contour Terminal projects, fetched through + CPM: morph's timers run on its event loop, and `morph::net` takes base64 + and its wakeup primitive from it. morph itself stays header-only, but + `core::base`, `core::net` and `core::platform` are static libraries, so + a project that links `morph::morph` also builds them. Under + single-threaded WebAssembly core-cpp builds its WebAssembly subset. + - Optional: Qt 6 for the WebSocket transport and QML example. + - Fetched dependencies are kept in CPM's source cache, `.cache/cpm` by + default (`CPM_SOURCE_CACHE` overrides it), so a second configure clones + nothing. - **Build system:** CMake (presets in `CMakePresets.json`) + Ninja. ```sh @@ -437,6 +448,7 @@ Relevant CMake options: `MORPH_BUILD_TESTS`, `MORPH_BUILD_EXAMPLES`, ```sh cmake -S . -B build-min -DMORPH_BUILD_TESTS=OFF -DMORPH_BUILD_EXAMPLES=OFF +cmake --build build-min cmake --install build-min --prefix /your/prefix ``` @@ -450,7 +462,10 @@ compile definitions, so nothing else has to be restated. Point `CMAKE_PREFIX_PATH` at the prefix you installed into. Glaze is installed alongside morph when the build fetched it, and `morphConfig.cmake` resolves it for you via `find_dependency` — an installed morph whose Glaze cannot be found -fails at `find_package` time rather than at compile time. +fails at `find_package` time rather than at compile time. core-cpp, whose +static modules morph links, is built and installed alongside morph the same +way, and found through `find_dependency(core-cpp 0.5)`: that is why the +install needs the build step before it. Optional components install only when their build option was on, and are requested by name: @@ -495,7 +510,7 @@ what the interface target would have: Glaze at the pinned version (currently 7.4), a C++23 standard library (the default logger uses `std::println`), the thread library, and each optional subsystem's own dependencies (Qt 6 for `morph::qt`, SQLite3 for `morph::offline_sqlite`). If the only Glaze around is -the copy `FetchContent` dropped in a build directory, that recipe also points +the copy CPM dropped in its source cache, that recipe also points your include path into someone's build tree. Prefer `find_package`. ## Examples diff --git a/cmake/CPM.cmake b/cmake/CPM.cmake new file mode 100644 index 000000000..729c2a6c0 --- /dev/null +++ b/cmake/CPM.cmake @@ -0,0 +1,57 @@ +# SPDX-License-Identifier: Apache-2.0 +# +# CPM.cmake bootstrap: downloads the pinned CPM once, verifies it and includes it. +# https://github.com/cpm-cmake/CPM.cmake +# +# Byte-for-byte core-cpp's cmake/CPM.cmake below this comment, so the two +# projects load the same CPM and a diff between the copies shows only this +# header. The pin is 0.40.8 with its SHA-256; the download is bounded when the +# configure defines FASTCACHED_FETCH_SILENCE_SECONDS and unbounded otherwise, +# because an empty INACTIVITY_TIMEOUT would break the argument list. +# +# With CPM_SOURCE_CACHE set (CMakeLists.txt defaults it to .cache/cpm) the +# bootstrap itself lives in the cache, so a warm cache downloads nothing. +set(_coreCppCpmBound "") +if(DEFINED FASTCACHED_FETCH_SILENCE_SECONDS) + set(_coreCppCpmBound INACTIVITY_TIMEOUT "${FASTCACHED_FETCH_SILENCE_SECONDS}") +endif() + +set(CPM_DOWNLOAD_VERSION 0.40.8) +set(CPM_HASH_SUM "78ba32abdf798bc616bab7c73aac32a17bbd7b06ad9e26a6add69de8f3ae4791") +set(CPM_DOWNLOAD_URL + "https://github.com/cpm-cmake/CPM.cmake/releases/download/v${CPM_DOWNLOAD_VERSION}/CPM.cmake") + +if(CPM_SOURCE_CACHE) + set(CPM_DOWNLOAD_LOCATION "${CPM_SOURCE_CACHE}/cpm/CPM_${CPM_DOWNLOAD_VERSION}.cmake") +elseif(DEFINED ENV{CPM_SOURCE_CACHE}) + set(CPM_DOWNLOAD_LOCATION "$ENV{CPM_SOURCE_CACHE}/cpm/CPM_${CPM_DOWNLOAD_VERSION}.cmake") +else() + set(CPM_DOWNLOAD_LOCATION "${CMAKE_BINARY_DIR}/cmake/CPM_${CPM_DOWNLOAD_VERSION}.cmake") +endif() + +# Expand a relative path, or one that starts with a tilde. +get_filename_component(CPM_DOWNLOAD_LOCATION "${CPM_DOWNLOAD_LOCATION}" ABSOLUTE) + +# `INACTIVITY_TIMEOUT` rather than `TIMEOUT`: the bound is on silence, so a slow +# download that keeps delivering still completes. `STATUS` because a failed +# `file(DOWNLOAD)` without it is silent and leaves a truncated file behind, which +# the `include()` below would then report as a syntax error in a file nobody wrote. +file(DOWNLOAD + "${CPM_DOWNLOAD_URL}" + "${CPM_DOWNLOAD_LOCATION}" + EXPECTED_HASH "SHA256=${CPM_HASH_SUM}" + ${_coreCppCpmBound} + STATUS cpmDownloadStatus +) +list(GET cpmDownloadStatus 0 cpmDownloadCode) +if(NOT cpmDownloadCode EQUAL 0) + message(FATAL_ERROR + "could not download the CPM.cmake bootstrap: ${cpmDownloadStatus}\n" + " from: ${CPM_DOWNLOAD_URL}\n" + " into: ${CPM_DOWNLOAD_LOCATION}\n" + "Re-run the configure, point CPM_SOURCE_CACHE at a directory that already holds " + "the bootstrap, or provide every dependency and set CORE_CPP_FETCH_DEPS=OFF. If the " + "transfer stalled, cmake/FetchTransferBound.cmake is what abandoned it and why.") +endif() + +include("${CPM_DOWNLOAD_LOCATION}") diff --git a/cmake/DepCache.cmake b/cmake/DepCache.cmake deleted file mode 100644 index 665738b0c..000000000 --- a/cmake/DepCache.cmake +++ /dev/null @@ -1,179 +0,0 @@ -# ── A shared source cache for FetchContent dependencies ────────────────────── -# -# Every configure in CI clones `glaze`, `Catch2`, `Lightweight` and -# `doxygen-awesome-css` again from github.com. One run configures more than a -# dozen times, so a single push produces dozens of anonymous clones from the -# self-hosted fleet's shared egress address -- and GitHub answers a throttled -# anonymous clone with 401, which makes git fall back to prompting for -# credentials and, with no TTY, fail as: -# -# fatal: could not read Username for 'https://github.com': No such device or address -# -# which reads like an auth misconfiguration and is not one. Measured on -# 2026-09-16 within a single run: six self-hosted clones succeeded between -# 20:12 and 20:14, then every job starting 20:19-20:22 failed this way -- the -# same five runners and the same egress address, so not an outage but a -# threshold. -# -# `FETCHCONTENT_SOURCE_DIR_` makes FetchContent use an existing tree and -# skip the download entirely. Pointing every configure at one cache directory -# therefore turns "a clone per configure" into "a clone per runner, once", -# which is the volume that trips the limit. -# -# Deliberately *not* `FETCHCONTENT_FULLY_DISCONNECTED`: a cache miss must fall -# back to cloning rather than fail the build. The cache is an optimisation, and -# an optimisation that can break a build is a liability. - -# Where cached sources live. An explicit `MORPH_DEP_CACHE` wins; otherwise CI -# gets a default under the runner's home, which persists across jobs on a -# self-hosted runner. A local build gets nothing unless it opts in -- a -# developer's builds are not what exhausts a rate limit, and silently sharing -# sources between their checkouts would be a surprising thing to do. -if(DEFINED ENV{MORPH_DEP_CACHE}) - set(MORPH_DEP_CACHE_DIR "$ENV{MORPH_DEP_CACHE}") -elseif(DEFINED ENV{CI} AND DEFINED ENV{HOME}) - set(MORPH_DEP_CACHE_DIR "$ENV{HOME}/.cache/morph-dep-cache") -else() - set(MORPH_DEP_CACHE_DIR "") -endif() - -# morph_declare_dep below calls FetchContent_Declare, so this file no longer -# works only beside an `include(FetchContent)` the caller remembered to write. -# include() is idempotent; every call site already does this too. -include(FetchContent) - -# ── Declaring and caching, split ───────────────────────────────────────────── -# -# `morph_declare_dep` is what call sites use; `morph_cache_dep` below is the -# caching half and is called only by it (and directly by -# a gate removed on 2026-09-23, which asserts that half's four properties on their -# own before asserting that declaring survives all four). -# -# The split is not stylistic. `morph_cache_dep` has three early returns -- no -# cache directory configured, no git, an explicit FETCHCONTENT_SOURCE_DIR_ -# override -- and the first of them is the *common* configuration: a local build -# opts out of the cache by default. So the caching half returns early on most -# machines, and folding `FetchContent_Declare` into it would leave the -# dependency undeclared on exactly those machines. Declaring is therefore -# unconditional and every early return is confined to the caching half. -# -# Why this wrapper exists at all: before it, every dependency wrote its revision -# twice -- once as `morph_cache_dep`'s `tag`, once as the `GIT_TAG` of the -# `FetchContent_Declare` beside it -- and the two could disagree. The divergence -# would be asymmetric in the worst way: a warm cache serves the first, an -# uncached configure fetches the second, both successfully and with no -# diagnostic anywhere. Comparing the two copies in a gate is possible; having -# only one copy leaves nothing to disagree. -# -# `GIT_REPOSITORY` and `GIT_TAG` are therefore refused in ARGN rather than -# forwarded: passing either would re-create the second copy inside the one call -# that was supposed to end it. -# -# Everything else in ARGN is forwarded to `FetchContent_Declare` verbatim. -# Today that is only `GIT_SHALLOW` -- TRUE for glaze, Catch2 and -# doxygen-awesome-css (tags, which a shallow clone resolves), FALSE for both -# Lightweight sites (a commit SHA, which it does not) -- but forwarding the -# rest of the argument list rather than one named option means a site that -# needs `SOURCE_SUBDIR` or `PATCH_COMMAND` next does not have to widen this -# function to get it. -function(morph_declare_dep name repository tag) - foreach(_argument IN LISTS ARGN) - if(_argument STREQUAL "GIT_REPOSITORY" OR _argument STREQUAL "GIT_TAG") - message(FATAL_ERROR - "morph_declare_dep(${name} ...) was passed ${_argument} as an extra " - "argument. The repository and the tag are this call's own second and " - "third arguments, and stating either of them twice is the divergence " - "this function exists to make unwritable: the cache keys " - "on what it is handed, FetchContent fetches what it is handed, and a " - "warm cache would then build a different revision than a cold one, " - "both successfully.") - endif() - endforeach() - - morph_cache_dep("${name}" "${repository}" "${tag}") - - FetchContent_Declare( - ${name} - GIT_REPOSITORY ${repository} - GIT_TAG ${tag} - ${ARGN} - ) -endfunction() - -# Points FetchContent at a cached checkout of @p name, populating the cache on -# first use. A no-op when no cache directory is configured, or when the caller -# already set FETCHCONTENT_SOURCE_DIR_ explicitly. -# -# `tag` is part of the directory name, so bumping a pin lands in a fresh -# directory instead of silently reusing the old revision -- the failure mode a -# cache keyed on name alone would have, and the one that is hardest to notice -# because everything still builds. -function(morph_cache_dep name repository tag) - if(MORPH_DEP_CACHE_DIR STREQUAL "") - return() - endif() - find_package(Git QUIET) - if(NOT Git_FOUND) - return() # FetchContent needs git too; let it produce the diagnostic - endif() - string(TOUPPER "${name}" _upper) - if(DEFINED FETCHCONTENT_SOURCE_DIR_${_upper} AND NOT FETCHCONTENT_SOURCE_DIR_${_upper} STREQUAL "") - return() # an explicit override wins, including the one CI may pass - endif() - - string(SUBSTRING "${tag}" 0 16 _short_tag) - string(MAKE_C_IDENTIFIER "${name}-${_short_tag}" _slug) - set(_dir "${MORPH_DEP_CACHE_DIR}/${_slug}") - # An explicit sentinel, written only after the clone *and* the checkout - # succeeded, rather than probing for a file the dependency might not have. - # The first version of this used `CMakeLists.txt`, which is not present in - # every dependency -- `doxygen-awesome-css` is a stylesheet repository -- - # so that entry would have been re-cloned on every configure while looking - # exactly like a working cache. It also distinguishes a complete entry from - # a tree left behind by an interrupted populate. - set(_stamp "${_dir}/.morph-dep-cache-ok") - - if(NOT EXISTS "${_stamp}") - message(STATUS "morph: dep cache: populating ${name} (${tag}) at ${_dir}") - file(MAKE_DIRECTORY "${MORPH_DEP_CACHE_DIR}") - # Clone into a per-process staging path and rename into place, so two - # configures racing on the same runner cannot leave a half-written tree - # that later builds would treat as a valid cache entry. The rename is - # atomic within one filesystem; whichever loses the race just discards - # its own copy. - string(RANDOM LENGTH 12 _stage_id) - set(_staging "${_dir}.tmp.${_stage_id}") - file(REMOVE_RECURSE "${_staging}") - execute_process( - COMMAND ${GIT_EXECUTABLE} clone --quiet "${repository}" "${_staging}" - RESULT_VARIABLE _clone_result - ERROR_VARIABLE _clone_error) - if(NOT _clone_result EQUAL 0) - # Not fatal: FetchContent will do its own clone, exactly as before. - message(STATUS "morph: dep cache: could not pre-clone ${name} (${_clone_error}); " - "leaving it to FetchContent") - file(REMOVE_RECURSE "${_staging}") - return() - endif() - execute_process( - COMMAND ${GIT_EXECUTABLE} -C "${_staging}" checkout --quiet "${tag}" - RESULT_VARIABLE _checkout_result) - if(NOT _checkout_result EQUAL 0) - message(STATUS "morph: dep cache: ${tag} did not check out for ${name}; leaving it to FetchContent") - file(REMOVE_RECURSE "${_staging}") - return() - endif() - file(TOUCH "${_staging}/.morph-dep-cache-ok") - if(NOT EXISTS "${_stamp}") - file(REMOVE_RECURSE "${_dir}") - file(RENAME "${_staging}" "${_dir}" RESULT _rename_result) - endif() - file(REMOVE_RECURSE "${_staging}") - endif() - - if(EXISTS "${_stamp}") - set(FETCHCONTENT_SOURCE_DIR_${_upper} "${_dir}" CACHE PATH - "Cached ${name} source tree" FORCE) - message(STATUS "morph: dep cache: ${name} from ${_dir}") - endif() -endfunction() diff --git a/cmake/morphConfig.cmake.in b/cmake/morphConfig.cmake.in index c5ea65f6c..e010aedf3 100644 --- a/cmake/morphConfig.cmake.in +++ b/cmake/morphConfig.cmake.in @@ -28,6 +28,12 @@ endif() # the consumer gets the bound too rather than rediscovering it the hard way. find_dependency(glaze @MORPH_GLAZE_VERSION@ CONFIG) +# morph::morph links core-cpp's modules (core::base, core::async, core::net and, +# natively, core::platform), which morph's install puts next to it. 0.3 is the +# minor version morph was built against; core-cpp's package accepts only that +# minor version while core-cpp is 0.x. +find_dependency(core-cpp 0.5 CONFIG) + if(NOT WIN32 AND NOT EMSCRIPTEN) find_dependency(Threads) endif() diff --git a/cmake/morph_add_rung.cmake b/cmake/morph_add_rung.cmake index 4c3ca81ff..5083bc813 100644 --- a/cmake/morph_add_rung.cmake +++ b/cmake/morph_add_rung.cmake @@ -114,7 +114,7 @@ function(morph_add_rung) # ── ladder__lib: models + db + app bootstrap (native only) ──── # Lightweight::Lightweight (ODBC) does not exist under Emscripten: # examples/common/CMakeLists.txt returns early, before its - # FetchContent_MakeAvailable(Lightweight) call, whenever EMSCRIPTEN is + # CPMAddPackage(Lightweight) call, whenever EMSCRIPTEN is # set. Persistence lives server-side behind the model for a WASM client # (IMPLEMENTATION.md rule 4's WASM clause), and ladder__gui_wasm # never links ladder__lib — so this target genuinely never needs @@ -365,7 +365,7 @@ function(morph_add_rung) # emitted, and the wasm link fails on every database symbol those # bodies reach (docs/spec/core/registry.md names a browser build as # the motivating case). That failure is a wall of undefined symbols - # from inside FetchContent'd code, so it is caught here instead. + # from inside fetched code, so it is caught here instead. if(_gui_wasm_sources AND NOT _gui_wasm_skips AND NOT MORPH_CLIENT_ONLY) message(FATAL_ERROR "morph_add_rung: rung '${_rung}' builds ladder_${_rung}_gui_wasm, which needs " @@ -623,6 +623,10 @@ endforeach() if(NOT EMSCRIPTEN AND _headless_sources AND TARGET ladder_${_rung}_gui_lib) add_executable(ladder_${_rung}_headless ${_headless_sources}) target_link_libraries(ladder_${_rung}_headless PRIVATE morph::ladder_${_rung}_gui_lib morph::ladder_app) + # A test's child process: see morph_suppress_test_dialogs. + if(COMMAND morph_suppress_test_dialogs) + morph_suppress_test_dialogs(ladder_${_rung}_headless) + endif() target_compile_features(ladder_${_rung}_headless PRIVATE cxx_std_23) set_target_properties(ladder_${_rung}_headless PROPERTIES AUTOMOC ON) apply_bigobj(ladder_${_rung}_headless) diff --git a/cmake/morph_demote_interface_includes.cmake b/cmake/morph_demote_interface_includes.cmake index 4470af4b6..2516a1190 100644 --- a/cmake/morph_demote_interface_includes.cmake +++ b/cmake/morph_demote_interface_includes.cmake @@ -70,7 +70,7 @@ include_guard(GLOBAL) # whose own argument must not be mistaken for an include directory. # # Idempotent by construction: a second call finds no `-I` left and returns -# without touching anything. Both FetchContent_MakeAvailable(Lightweight) sites +# without touching anything. Both CPMAddPackage(Lightweight) sites # call it, since whichever configures first is the one that defines the target. function(morph_demote_interface_includes_to_system target) if(NOT TARGET "${target}") @@ -133,7 +133,7 @@ function(morph_demote_interface_includes_to_system target) target_include_directories("${target}" SYSTEM INTERFACE ${_morph_dirs}) endfunction() -# Call immediately after FetchContent_MakeAvailable(Lightweight), from every +# Call immediately after CPMAddPackage(Lightweight), from every # site that makes it available -- see this file's header for why. function(morph_demote_lightweight_odbc_includes) morph_demote_interface_includes_to_system(Lightweight) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 701573c35..bd7986d05 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -4,7 +4,7 @@ `morph` is a typed, asynchronous bridge between a GUI thread and business-object models. Models may live in-process (local mode) or in a remote server process (remote mode). The GUI code is identical in both cases — only the backend implementation changes. -The framework is header-only (C++23, namespace `morph`), depends on Glaze for JSON reflection, and optionally integrates with Qt 6 via a separate target. +The framework is header-only (C++23, namespace `morph`), depends on Glaze for JSON reflection and on [core-cpp](https://github.com/contour-terminal/core-cpp) for its event-loop timers, base64 and wakeup primitive, and optionally integrates with Qt 6 via a separate target. core-cpp's `core::base`, `core::net` and `core::platform` are static libraries, so while morph's own surface is headers only, a project that links `morph::morph` also builds those. > **New to morph?** `docs/GETTING-STARTED.md` is the step-by-step tutorial that > comes before this document: it builds one small app end to end @@ -73,7 +73,7 @@ Every nested `detail` namespace under those topics holds implementation symbols. │ Internal async core │ │ IExecutor · ThreadPoolExecutor · MainThreadExecutor │ │ (executor.hpp) │ -│ StrandExecutor · ModelId (strand.hpp) │ +│ ModelStrands · ModelId (strand.hpp) │ │ CompletionState (completion.hpp) │ ├─────────────────────────────────────────────────────────────────┤ │ Cross-cutting │ @@ -90,7 +90,7 @@ GUI thread └─ bridge::BridgeHandler::execute(action) └─ bridge::Bridge::executeVia └─ backend::LocalBackend::execute - └─ StrandExecutor → worker thread → Model::execute(action) + └─ ModelStrands → worker thread → Model::execute(action) └─ async::Completion::then callback → GUI executor ``` @@ -102,7 +102,7 @@ GUI thread └─ bridge::Bridge::executeVia └─ backend::SimulatedRemoteBackend::execute └─ serialize action → backend::RemoteServer::handle (JSON wire envelope) - └─ ActionDispatcher → StrandExecutor → Model::execute + └─ ActionDispatcher → ModelStrands → Model::execute └─ serialize result → Completion::then → GUI executor ``` @@ -116,7 +116,7 @@ GUI thread (Qt process) Server process └─ qt::QtWebSocketBackend::execute (network client) └─ assign callId, send JSON ──► qt::QtWebSocketServer::handle └─ backend::RemoteServer::handle (JSON wire envelope) - └─ ActionDispatcher → StrandExecutor → Model::execute + └─ ActionDispatcher → ModelStrands → Model::execute ◄── JSON reply (ok|callId|result) ────────────────────────────── └─ resolve pending Completion └─ async::Completion::then callback → qt::QtExecutor → GUI thread @@ -194,11 +194,11 @@ All concurrency runs through `morph::exec::IExecutor::post(fn)`: - **`MainThreadExecutor`** — single-threaded queue with `runFor(timeout)` drain; used in non-Qt tests to pump the "GUI" thread. It catches only `std::exception` from a task, logs it via `morph::log`, and continues with the next task. - **`QtExecutor`** — posts via `QMetaObject::invokeMethod(Qt::QueuedConnection)`; safe from any thread; drops silently if the target object is deleted. -`morph::exec::detail::StrandExecutor` (below) is where `Model::execute()` actually runs; like `ThreadPoolExecutor`, it catches a task exception (`std::exception` or unknown) and logs it via `morph::log` so a throw neither stalls the strand nor vanishes — the next queued task for that model still runs. +A model instance's strand (`morph::exec::detail::ModelStrands`, below) is where `Model::execute()` actually runs; like `ThreadPoolExecutor`, it catches a task exception (`std::exception` or unknown) and logs it via `morph::log` so a throw neither stalls the strand nor vanishes — the next queued task for that model still runs. -### StrandExecutor +### Strands -`morph::exec::detail::StrandExecutor` guarantees that all tasks for the same `ModelId` are serialised while tasks for different models are parallelised. Internally keeps one `std::queue` and a `running` flag per model; tasks are dispatched to the underlying `IExecutor` one at a time. +`morph::exec::detail::ModelStrands` guarantees that all tasks for the same `ModelId` are serialised while tasks for different models are parallelised. It is core-cpp's `core::async::KeyedStrands` over the backend's `IExecutor`: a strand per model instance with work, made when it gets work and retired when it runs out, which queues itself on the executor once per turn and runs a batch of tasks there. morph adds the adapter to its `IExecutor`, the catch-and-log above, and the Task handler's session around each of the handler's resumptions (see `spec/core/coroutines.md`). ### Completion @@ -374,7 +374,7 @@ Conflict resolution during offline-to-online sync belongs entirely in the model. | `Completion` / `CompletionState` | Fully mutex-protected; callbacks always marshal to the supplied executor. | | `Bridge` | Handler list protected by mutex; register/deregister safe from any thread. | | Logger | Sink and level accesses protected by mutex. | -| `StrandExecutor` | Per-strand mutex + atomic running flag; safe from any thread. | +| `ModelStrands` | core-cpp's `KeyedStrands`: a registry lock taken before each strand's own; safe from any thread. | | `Bridge::switchBackend` | Holds bridge mutex while staging + committing; re-registration and notification are atomic with respect to new `execute` calls. Exception-safe: a registration failure rolls back and leaves the old backend and all `currentId`s untouched (no-op). Outgoing-backend cancellation runs after the mutex is released. | ## Error propagation @@ -389,7 +389,7 @@ Model::execute(action) throws If the `Completion` is abandoned (no `.onError` attached, or no callback executor to deliver it on), the destructor logs the exception through the orphan logger. Non-`std::exception` types are logged as "unknown exception". -Task exceptions on the executors themselves are handled independently: `ThreadPoolExecutor` and `StrandExecutor` catch and log every task throw via `morph::log`, and `MainThreadExecutor::runFor` catches `std::exception`. A throwing task therefore never kills a worker or stalls a strand — see "Executors" above. +Task exceptions on the executors themselves are handled independently: `ThreadPoolExecutor` and `ModelStrands` catch and log every task throw via `morph::log`, and `MainThreadExecutor::runFor` catches `std::exception`. A throwing task therefore never kills a worker or stalls a strand — see "Executors" above. ## Adding a new model and actions @@ -643,7 +643,7 @@ folder. |---|---| | `core/logger.hpp` | `LogLevel`, log configuration and level helpers; internals in `morph::log::detail` | | `core/executor.hpp` | `IExecutor`, `ThreadPoolExecutor`, `MainThreadExecutor` (`morph::exec::`) | -| `core/strand.hpp` | `ModelId`, `ModelIdHash`, `StrandExecutor` — serialises tasks per model (`morph::exec::detail::`) | +| `core/strand.hpp` | `ModelId`, `ModelIdHash`, `ModelStrands`, `TaskResumer` — serialises tasks per model over core-cpp's `KeyedStrands` (`morph::exec::detail::`) | | `core/completion.hpp` | `CompletionState` (detail) + `Completion` (public) — result handle | | `core/model.hpp` | `IModelHolder`, `ModelHolder`, `ModelFactory`, `IBackendChangedSink`, `BackendChangedNotifiable` — type-erased model storage; `IModelHolder::attachActionLog`/`hasActionLog`/`recordIfAttached` (`morph::model::detail::`) | | `core/registry.hpp` | `ModelTraits<>`, `ActionTraits<>`, `ActionValidator<>`, `ActionLogPolicy<>`, `Loggable` (public) + `ActionDispatcher` (also tracking each action's `coalesce` policy), `ModelRegistryFactory`, `defaultDispatcher()`, `defaultRegistry()`, `ParseError`, `registerModelOnce`, `registerActionOnce`, `actionLoggable()` (detail). Registration macros `BRIDGE_REGISTER_MODEL`, `BRIDGE_REGISTER_ACTION` (optional 4th `Loggable` argument), `BRIDGE_REGISTER_VALIDATOR` are defined here at file scope. | @@ -740,9 +740,10 @@ documented behavior. | Decision | Rationale | |---|---| -| Header-only library | Zero build-system friction; include and use. | +| Header-only library | Zero build-system friction; include and use. morph's own surface stays headers only; core-cpp's static modules are built with it. | +| core-cpp for timers, base64 and wakeup | One implementation shared with the other Contour Terminal projects, including a WebAssembly subset whose host-driven loop runs `TimeoutScheduler` on the browser's main thread. | | Per-topic public namespaces with per-topic `detail::` | Minimal public surface — callers see only what they need; internals are clearly walled off. | -| `StrandExecutor` per `ModelId` | Parallelism across models; serial within one model — model authors write single-threaded code. | +| A strand per `ModelId` | Parallelism across models; serial within one model — model authors write single-threaded code. | | `Completion` not `std::future` | Callbacks marshal to a specific executor; futures do not. | | `IBackend` in `detail::` | Users never type the interface — they construct concrete backends and let conversion happen implicitly. | | `HandlerBinding` with atomic `currentId` | Handlers survive backend replacement without re-registering from application code. | diff --git a/docs/CMakeLists.txt b/docs/CMakeLists.txt index b6a73a650..d74204bb2 100644 --- a/docs/CMakeLists.txt +++ b/docs/CMakeLists.txt @@ -1,25 +1,14 @@ find_package(Doxygen REQUIRED) message(STATUS "Doxygen found: ${DOXYGEN_EXECUTABLE}") -include(FetchContent) -include(${CMAKE_SOURCE_DIR}/cmake/DepCache.cmake) -# GIT_SHALLOW TRUE because `v2.3.4` is a tag, which a shallow clone resolves -- -# the same reason glaze and Catch2 carry it, and the opposite of both Lightweight -# sites, whose pins are commit SHAs and are therefore GIT_SHALLOW FALSE. Without -# it an uncached configure clones 232 commits of a stylesheet repository to use -# one file, `doxygen-awesome.css` (morph#724). -# -# What this does *not* speed up: wherever `cmake/DepCache.cmake`'s dependency -# cache is active -- which is every CI job, since a hosted runner sets `CI` and -# `HOME` -- `morph_cache_dep` pre-clones at full depth and points FetchContent at -# the result via FETCHCONTENT_SOURCE_DIR_DOXYGEN-AWESOME-CSS, so FetchContent -# never clones and GIT_SHALLOW is inert. The saving is on the uncached -# configure: a developer building the docs target locally. Recorded because the -# three tag pins previously differed here with no reason written beside any of -# them, which is what made this look deliberate. -morph_declare_dep(doxygen-awesome-css https://github.com/jothepro/doxygen-awesome-css.git v2.3.4 - GIT_SHALLOW TRUE) -FetchContent_MakeAvailable(doxygen-awesome-css) +# DOWNLOAD_ONLY: the repository is a stylesheet, and the one file used is +# `doxygen-awesome.css`; there is nothing to configure. CPM is loaded by the +# root CMakeLists.txt, and its source cache applies here as everywhere. +CPMAddPackage( + NAME doxygen-awesome-css + GITHUB_REPOSITORY jothepro/doxygen-awesome-css + GIT_TAG v2.3.4 + DOWNLOAD_ONLY YES) # ── Doxygen settings ──────────────────────────────────────────────────────── set(DOXYGEN_PROJECT_NAME "morph") diff --git a/docs/spec/README.md b/docs/spec/README.md index 2070c04fe..6b0fbdd27 100644 --- a/docs/spec/README.md +++ b/docs/spec/README.md @@ -89,6 +89,7 @@ behavioural differences between the two, collected in one table, are in [`core/backend.md`](core/backend.md) · [`core/registry.md`](core/registry.md) · [`core/completion.md`](core/completion.md) · +[`core/coroutines.md`](core/coroutines.md) · [`core/wire.md`](core/wire.md) · [`core/locality.md`](core/locality.md) diff --git a/docs/spec/concurrency_and_lifetimes.md b/docs/spec/concurrency_and_lifetimes.md index 8432ddf06..4454a74f4 100644 --- a/docs/spec/concurrency_and_lifetimes.md +++ b/docs/spec/concurrency_and_lifetimes.md @@ -8,7 +8,7 @@ subtle footguns live in the seams **between** subsystems, not inside any one of them. Read this before wiring up a `Bridge`, a `RemoteServer`, or a -`ThreadPoolExecutor`/`StrandExecutor` pair, and before changing any teardown +`ThreadPoolExecutor` and the strands over it, and before changing any teardown sequence. ## Contents @@ -37,7 +37,11 @@ is the concrete executor's job: | `ThreadPoolExecutor` | N fixed worker threads, FIFO MPMC queue | Runs model work (`Model::execute`) and remote message processing. | | `MainThreadExecutor` | The thread that calls `runFor()` | Stand-in "GUI" thread in non-Qt tests; pumped manually. | | `QtExecutor` | The Qt GUI thread | Real GUI executor; posts via `QMetaObject::invokeMethod(Qt::QueuedConnection)`. | -| `StrandExecutor` | *Borrows* a base `IExecutor` (usually the pool) | Serialises tasks per `ModelId` on top of the base executor. It owns no thread. | +| `ModelStrands` | *Borrows* a base `IExecutor` (usually the pool) | Serialises tasks per `ModelId` on top of the base executor: core-cpp's `KeyedStrands`, which reaches the base through `CoreExecutorOver`. It owns no thread. | + +A strand's own unit of work is a coroutine resumption, not a callable: it +queues its pump on the base once per turn, through the adapter, and a posted +callable is one task of that turn. That is still one `IExecutor::post` per turn. Because everything funnels through `post`, the concurrency model is fully determined by *which executor a task is posted to*. Model code never blocks the @@ -47,9 +51,9 @@ GUI, and the GUI thread never runs model work — the executors enforce the spli | Work | Runs on | Scheduled by | |---|---|---| -| `Model::execute(action)` (local mode) | Worker pool, inside a per-`ModelId` strand | `LocalBackend::execute` → `StrandExecutor::post` | -| `Model::onBackendChanged()` (local mode) | Worker pool, inside the model's per-`ModelId` strand (serialised with its `execute`) | `LocalBackend::notifyBackendChanged` → `StrandExecutor::post` | -| `ActionDispatcher::dispatch` → `Model::execute` (remote mode) | `RemoteServer`'s worker pool, inside a per-`ModelId` strand | `RemoteServer::dispatchExecute` → `StrandExecutor::post` | +| `Model::execute(action)` (local mode) | Worker pool, inside a per-`ModelId` strand | `LocalBackend::execute` → `ModelStrands::post` | +| `Model::onBackendChanged()` (local mode) | Worker pool, inside the model's per-`ModelId` strand (serialised with its `execute`) | `LocalBackend::notifyBackendChanged` → `ModelStrands::post` | +| `ActionDispatcher::dispatch` → `Model::execute` (remote mode) | `RemoteServer`'s worker pool, inside a per-`ModelId` strand | `RemoteServer::dispatchExecute` → `ModelStrands::post` | | Remote message decode / envelope handling | `RemoteServer`'s worker pool | `RemoteServer::handle` → `_pool.post` | | `Completion::then` / `onError` callbacks | The `cbExec` executor supplied at dispatch (the GUI executor for `BridgeHandler`) | `CompletionState::setValue`/`setException` → `cbExec->post` | | Subscription result / error sinks (`BridgeHandler::subscribe`) | The handler's `guiExec` | Same as `Completion` callbacks — they *are* completion callbacks | @@ -96,64 +100,46 @@ Key consequences: ## The strand model — one strand per `ModelId` -`StrandExecutor` (`strand.hpp`) sits on top of an arbitrary base `IExecutor` and -turns it into a set of per-key serial queues: +`ModelStrands` (`strand.hpp`) sits on top of an arbitrary base `IExecutor` and +turns it into a set of per-key serial queues. It is core-cpp's +`core::async::KeyedStrands`, which specifies and tests the strand +itself; [`core/executor.md`](core/executor.md), "Strands", says what morph adds. - `post(ModelId key, task)` appends `task` to the strand for `key`. Tasks with the same key run in FIFO order with **no overlap**; tasks with different keys may run concurrently on different pool threads. This is what removes the need for per-model mutexes. -- Each strand is a `shared_ptr` in a map guarded by `_mapMtx`. When a - strand's queue drains, the map entry is removed — `extract`ed into a - single-slot `_spare` the next miss re-keys, which recycles the node's memory - without changing when the entry leaves the map (see - [`core/executor.md`](core/executor.md), "Lifetime & ownership"). The - invariant is: **at most one - live strand per `ModelId`, and any `running` strand is the one currently in the - map** — that is what keeps a key's tasks from overlapping. Both sides that can - break it hold `_mapMtx` across their *whole* decision: `post()` takes `_mapMtx`, - looks up (or creates) the strand, and — still under `_mapMtx` — takes - `strand->mtx` to push the task and set `running`; the drain step takes the same - two locks in the same order to decide "keep running vs. erase". An earlier - design held the combined lock only inside the drain step while `post()` re-armed - the strand under `strand->mtx` alone (after releasing `_mapMtx`); a concurrent - drain could then erase the strand in that gap, orphaning a live strand, and the - next `post(key)` created a *second* strand for the same key — two strands - running the model's tasks concurrently (a data race). Serialising the lookup, - the re-arm, and the erase under `_mapMtx` closes that window: the drain never - erases a strand whose `pending` queue is non-empty, and a strand that becomes - `running` in `post()` is guaranteed to still be the map entry. Lock order is - always `_mapMtx` → `strand->mtx`, acquired as two sequential `scoped_lock`s at - both sites, so no lock-ordering deadlock. -- `_inFlight` counts strand lambdas currently dispatched to the base executor. - The destructor waits on `_cv` until `_inFlight == 0` before destroying the - map, so no pool thread can touch `_strands` after the executor is gone. -- **The drain's own work is one handoff per task and one notification per - quiescence, whatever the host is doing.** `_cv` is signalled only where - `--_inFlight` reaches zero, and `~StrandExecutor` is its only waiter, so the - `notify_all` wakes at most one thread — a handoff between two tasks for one - key signals nothing. What a loaded host adds is therefore *latency between* - those operations, not more of them: a drain of N tasks costs N dispatches - whose wall clock is the base executor's wakeup latency under the run queue of - the moment. Measured on a 12-thread host, the same drain's per-task cost - spans three orders of magnitude with machine load while every count above - stays fixed. A task body that calls `std::this_thread::yield()` pays far more - again, because a yielding thread goes to the back of the run queue with no - sleeper credit; that is a property of the posted task, not of the strand. -- **`~StrandExecutor` is a complete-drain barrier, not only a use-after-free - guard.** The re-arm in `scheduleNext` increments `_inFlight` for the next - dispatch *before* the current dispatch decrements its own, so the count never - dips to zero across a handoff; and a lambda that finds `pending` empty is the - only one that lets it reach zero. So once every caller has stopped posting — - which the "no `post()` may race or follow `~StrandExecutor`" corollary below - requires anyway — `_inFlight == 0` means **every queued task has run**, not - merely that none is running right now. Code that needs a strand quiesced - should therefore destroy it and rely on that wait, rather than poll a counter - against a wall-clock budget: the barrier is exact and does not depend on how - fast or how loaded the host is. - -`LocalBackend` owns one `StrandExecutor` over the worker pool; `RemoteServer` +- **At most one live strand per `ModelId`.** A key's strand is made when the key + gets work and retired when its queue runs out, and the retirement and a post + for the same key are serialised under the registry's lock, so a post never + finds a strand that has just been retired beside a new one. A coroutine that + parked on a strand that was retired meanwhile comes back to the key's current + strand. morph's own `StrandExecutor` had to fix this invariant twice; it is + core-cpp's to keep now. +- **A strand runs a batch per turn.** It queues itself on the base once however + many tasks arrive while it is busy, and runs up to 32 before it hands the base + back. A loaded host adds latency between turns, not more of them. +- **Closing drops; `teardown()` stops, drains, seals, drains again, then + closes** where threads exist, so the stopped handlers' ends reach their + strands while those still admit them; on the single-threaded build it seals, + stops, then closes. `close()`, and + the destructor, drop what is queued and wait only for a task running on + another thread. `drain()` blocks until nothing is queued or running on any + strand, work posted while it waits included. `seal()` refuses the try-forms + a Task handler's resumer and its end use, so a resumption or an end that + arrives afterwards runs inline where it arrives; a plain post is still queued + until the close. `~LocalBackend` and + `~SynchronousBackendAdapter` call `teardown()`, which does all of it, so + nothing reaches a strand between its last drain and its close. It must not be + called from one of the strands' own tasks; a debug build asserts that. The + single-threaded WebAssembly build has no other thread: there `drain()` + returns at once, and `teardown()` seals before it stops the Task handlers, so + each stopped handler unwinds inline. + +`LocalBackend` owns one `ModelStrands` over the worker pool; `RemoteServer` owns another over its worker pool. Both post model work keyed by `ModelId`. +Each shares its strands with the resumers of the Task handlers it started (see +[`core/coroutines.md`](core/coroutines.md)). ## Completion callback marshalling @@ -181,33 +167,32 @@ rules encode recent fixes to real deadlocks and use-after-frees. | This… | must outlive / be destroyed after… | Consequence if violated | |---|---|---| -| base `IExecutor` (e.g. `ThreadPoolExecutor`) | the `StrandExecutor` built on it | **Deadlock** in `~StrandExecutor` (see below) | +| base `IExecutor` (e.g. `ThreadPoolExecutor`) | the strands built on it, and the backend that owns them | **Hang** in the backend's drain (see below) | | `Bridge` | its `BridgeHandler`s (for normal `execute`/`set` calls) | Fine at teardown (order-independent, see below); a *call* on a handler whose bridge is gone is still UB | | `RemoteServer` (heap, `make_shared`) | every `SimulatedRemoteBackend`/transport holding `RemoteServer&` | Dangling `RemoteServer&` → use-after-free | | worker pool | the backend that posts to it (`LocalBackend`, `RemoteServer`) | Same deadlock/UAF family as the strand rule | | `session::Context` passed to `ScopedContext` | the scope in which the model runs | Dangling thread-local `Context*` | -### base `IExecutor` must outlive its `StrandExecutor` — and keep running +### base `IExecutor` must outlive its strands — and keep running -This is the sharpest edge in the framework. `~StrandExecutor` **blocks** until -`_inFlight == 0`, i.e. until every lambda it dispatched to the base executor has -actually run. `~ThreadPoolExecutor` **drains** its queue — after `_stop` is set, -workers keep running already-queued tasks until the queue is empty, then join — -so tasks already queued when destruction begins do run and decrement `_inFlight`. +This is the sharpest edge in the framework. `~LocalBackend` and +`~SynchronousBackendAdapter` **block** in `ModelStrands::drain()` until every +turn their strands queued on the base executor has run. `~ThreadPoolExecutor` +**drains** its queue — after `_stop` is set, workers keep running +already-queued tasks until the queue is empty, then join — so turns already +queued when destruction begins do run. -Draining is not enough to make arbitrary teardown order safe, because the strand -can still be *dispatching* while the pool tears down. If you destroy the pool -**first**, two things go wrong: an in-flight strand lambda may call -`base->post()` on a pool whose destructor has already run (undefined behaviour — -use-after-free on the pool), and a lambda posted after the workers have observed -`_stop && _q.empty()` and exited is never run, so its `--_inFlight` never happens -and `~StrandExecutor` waits forever → **deadlock**. The pool must be destroyed -*after* every `StrandExecutor` (and hence after `LocalBackend` / `RemoteServer`, -which own the strands). +Draining is not enough to make arbitrary teardown order safe, because a strand +can still be *queuing* turns while the pool tears down. If you destroy the pool +**first**, two things go wrong: a strand may post its next turn to a pool whose +destructor has already run (undefined behaviour — use-after-free on the pool), +and a turn posted after the workers have observed `_stop && _q.empty()` and +exited is never run, so the strand never goes idle and the backend's drain +waits forever → **hang**. The pool must be destroyed *after* every backend that +owns strands over it. -Corollary: **no `post()` may race or follow `~StrandExecutor`.** Once the strand -executor's destructor has started, posting to it is undefined. Stop feeding a -backend before you tear it down. +Corollary: **stop feeding a backend before you tear it down.** A post that races +the destructor may land after the drain, and is dropped by the close. Correct teardown order (innermost-first): @@ -253,7 +238,7 @@ passes, the `Bridge` finishes being destroyed, and `Bridge::deregisterHandler` then iterates the freed `_handlers`. The gate turns check-then-call into one indivisible step. -**`~Bridge` therefore blocks**, like `~StrandExecutor` above and for the same +**`~Bridge` therefore blocks**, like a backend's drain above and for the same reason. The wait is bounded and cannot cycle: the only guarded region is `deregisterHandler`, whose sole outward call is `IBackend::deregisterModel`, and no shipped backend blocks on another thread there — `LocalBackend` erases map @@ -547,8 +532,8 @@ what makes the model-side contract both true and safe: - **`switchBackend` from `onBackendChanged()` is still unsupported.** Not because of `_mtx` (that is free now) but because the callback runs on the *outgoing* backend's strand; a nested switch drops the last reference to that backend when - it returns, and `~StrandExecutor` blocks until in-flight strand tasks finish — - including the one calling it — a self-join hang. Re-register or reconcile from + it returns, and `~LocalBackend` drains its strands, which includes the one + calling it — a self-join hang, asserted in a debug build. Re-register or reconcile from the callback; do not swap the backend again inside it. - **`executeVia` IS safe from `onBackendChanged()`.** It never takes `_mtx`; it reads a **lock-free snapshot** of the backend `shared_ptr` (via `_backendMtx`, @@ -764,13 +749,13 @@ BridgeHandler(s) ← first to go (or any order vs. Bridge, on any threa Bridge backend ← LocalBackend / SimulatedRemoteBackend RemoteServer ← only in remote mode; keep its shared_ptr alive this long - ThreadPoolExecutor ← LAST: it must outlive every StrandExecutor it backs + ThreadPoolExecutor ← LAST: it must outlive every strand it backs ``` One-liners to remember: -- Never destroy the pool before the strand/backend → `~StrandExecutor` deadlocks. -- Never `post()` to a `StrandExecutor` whose destructor has begun. +- Never destroy the pool before the backend → the backend's drain hangs. +- Never `post()` to a backend whose destructor has begun. - `onBackendChanged()` runs posted on the model's strand (not inline under `_mtx`): `registerHandler`/`deregisterHandler`/`executeVia` are safe from it, but never call `switchBackend` there (it self-joins the strand it runs on). @@ -789,7 +774,7 @@ One-liners to remember: ## Cross-references - [`executor.md`](core/executor.md) — `IExecutor`, `ThreadPoolExecutor`, - `StrandExecutor`, `ModelId`; the "destroy strand before base pool" rule in + `ModelStrands`, `ModelId`; the "destroy the backend before the base pool" rule in detail. - [`completion.md`](core/completion.md) — `Completion` / `CompletionState` internals and orphan-error logging. diff --git a/docs/spec/core/backend.md b/docs/spec/core/backend.md index 5241b96de..90c2648ee 100644 --- a/docs/spec/core/backend.md +++ b/docs/spec/core/backend.md @@ -71,6 +71,8 @@ used locally or serialised for a remote round-trip: | `serializeAction` | `std::string (*)(const void* action)` | Serialises `action` to JSON. Only called on the remote path. | | `deserializeResult` | `std::shared_ptr (*)(std::string_view)` | Deserialises a JSON reply into the opaque result. Only called on the remote path. Never reads the action. | | `localOp` | `std::shared_ptr (*)(IModelHolder&, void* action)` | Executes `action` directly against a model holder. Only called on the local path. | +| `localOpAsync` | `void (*)(IModelHolder&, std::shared_ptr action, const std::shared_ptr&, core::async::StopToken, LocalDone)` | Set instead of `localOp` when the action's handler returns `core::async::Task`: starts it on the model's strand and reports the result or exception through `LocalDone` when the Task completes. Only called on the local path. See [`coroutines.md`](coroutines.md). | +| `stopSource` | `std::shared_ptr` | Null unless `Bridge::executeVia` armed an execute deadline for a Task handler; the deadline requests stop on it and `LocalBackend` hands its token to the handler. | | `session` | `morph::session::Context` | Session context. Local backends thread it through a thread-local before invoking `localOp`; remote backends serialise it into the wire envelope. | There is one member function, `serializeBody()`, which pairs @@ -468,9 +470,11 @@ natively](#the-structural-registration-surface-natively). would. - **Control calls are serialised** onto one strand, so the wrapped backend sees them one at a time, as it did when the blocking call itself serialised - callers. `~SynchronousBackendAdapter` waits for any in-flight control call, so - the executor must still be running tasks when the adapter is destroyed — the - same rule as `StrandExecutor`'s own `base`. + callers. `~SynchronousBackendAdapter` waits for every queued and in-flight + control call (`ModelStrands::drain`), so the executor must still be running + tasks when the adapter is destroyed — the same rule as for any backend's + strands. The single-threaded WebAssembly build has no thread to wait for, and + drops the control calls still queued. - **A control call issued from a reconnect handler runs on the strand**, never on the wrapped backend's transport thread — *provided the handler issues it through `bindModel`/`promoteModel`*. That proviso is load-bearing, and it is @@ -723,9 +727,9 @@ declared alongside them so callers catch every dispatch failure from one header: ## `LocalBackend` — in-process execution `LocalBackend` is the concrete in-process backend. It owns a -`StrandExecutor` (wrapping the `IExecutor&` worker pool, typically a -`ThreadPoolExecutor`) and a `detail::InstanceDirectory` holding its live model -instances. +`ModelStrands` (core-cpp's `KeyedStrands` over the `IExecutor&` worker pool, +typically a `ThreadPoolExecutor`; see [executor.md](executor.md)) and a +`detail::InstanceDirectory` holding its live model instances. Both it and `RemoteServer` keep those instances in one `detail::InstanceDirectory` (`core/detail/instance_directory.hpp`): one record @@ -767,7 +771,15 @@ there, rather than once per backend. captured by `shared_ptr`). Cost is O(change-aware models), not O(all models). Delivery is asynchronous and serialised against that model's `execute` tasks; it never runs under `_regMtx` or `Bridge::_mtx`, so a sink that re-enters the - bridge cannot deadlock. + bridge cannot deadlock. It runs without a session, even while a Task + handler of that model instance is suspended: the strand installs a + suspended handler's session around the coroutines resumed on its instance + only, and a posted callable such as this one is not one of them (see + [coroutines.md](coroutines.md), "The handler's resumer"). The same holds + for an action queued behind the handler, which installs its own session when + it starts. A detached chain that a finished handler A left behind is a + resumption, though: when it comes back after handler B of the same instance + started, it runs under B's session and B's resumer. - `setReconnectHandler`/`setConnectHandler`/`setDisconnectHandler` — no-op (no transport to (dis)connect). - `setSession` — not overridden (the default no-op stands): the local path never serialises a `Context` onto a wire envelope, so there is nothing to stamp. @@ -984,7 +996,7 @@ via `morph::observe::setMetricSink`/`setTraceSink` — see envelopes for the *same* model, sent back-to-back on one connection, are raced by two pool threads through identical pre-strand work (decode/authorize/authenticate/registry lookup). Whichever finishes that work -first reaches `_strand.post(mid, ...)` first. `StrandExecutor` serialises what +first reaches `_strands->post(mid, ...)` first. The strand serialises what it is given, but it can only serialise it in the order it is given — so with more than one pool worker free, the model could observe two actions in the opposite order from the one they were sent in. That is a correctness problem @@ -1015,9 +1027,9 @@ produces the canonical error reply for malformed input exactly as it would otherwise. **Where the order is enforced.** The ticket is waited on at one point only — -immediately before `_strand.post` — and released immediately after that call +immediately before `_strands->post` — and released immediately after that call returns. It deliberately does **not** span the strand task: once the post has -happened in the right order, `StrandExecutor` owns the sequencing from there, +happened in the right order, the strand owns the sequencing from there, and holding the ticket any longer would stall a different request's pre-strand work for no ordering benefit. The wait itself happens on a pool thread and blocks nothing else; a strand is never blocked by this gate. @@ -1265,7 +1277,7 @@ defaults to `0` ("unbounded"), so an unconfigured server's behavior is unchanged | Field | Default | Enforcement | |---|---|---| -| `executeTimeout` | `0` (disabled) | A timer arms when `execute` dispatches to the model's strand. If it fires first, the server replies `err "timeout"` and the eventual strand result (if the model finishes later) is discarded via a shared once-flag — `handle()`'s reply-exactly-once contract holds regardless of which path resolves first. The model keeps running to completion on its strand; morph never interrupts `Model::execute`. | +| `executeTimeout` | `0` (disabled) | A timer arms when `execute` dispatches to the model's strand. If it fires first, the server replies `err "timeout"` and the eventual strand result (if the model finishes later) is discarded via a shared once-flag — `handle()`'s reply-exactly-once contract holds regardless of which path resolves first. An ordinary handler keeps running to completion on its strand; morph never interrupts it. A handler returning `core::async::Task` is also asked to stop, through its stop token, and unwinds at its next stop-aware `co_await` (see `docs/spec/core/coroutines.md`, "Execute deadlines"). | | `maxLiveModels` | `0` (unbounded) | Checked under `_regMtx` before `register` constructs a new instance; over the cap → `err "too many models"`. The check and the eventual insert are two separate critical sections (to avoid constructing an instance that will be rejected), so a burst of concurrent registers can overshoot the cap by a small, bounded amount — a soft, defense-in-depth limit, not a hard invariant. | | `maxInFlightExecutes` | `0` (unbounded) | An atomic counter, incremented when `execute` is admitted for dispatch (before the strand task is posted) and decremented when its reply is sent (success, exception, or timeout — whichever resolves the call first); over the cap → `err "server busy"`, no dispatch. | @@ -1276,10 +1288,10 @@ than a generic `std::runtime_error`, on both `SimulatedRemoteBackend` and The background timer that enforces `executeTimeout` is `morph::async::detail::TimeoutScheduler` (`include/morph/core/timeout_scheduler.hpp`) -— a single dedicated thread per `RemoteServer` (mirroring `NetworkMonitor`'s -condition-variable wait loop), lazily started by `setLimitPolicy` the first time -`executeTimeout` is configured, so a server that never uses the feature pays no -extra thread. The class lives in `morph::async::detail` rather than +— one per `RemoteServer`, each a thread running a core-cpp +`core::net::PlatformLoop` whose timers hold the deadlines, lazily created by +`setLimitPolicy` the first time `executeTimeout` is configured, so a server +that never uses the feature pays no extra thread. The class lives in `morph::async::detail` rather than `morph::backend::detail` because `Bridge` uses the same primitive for the *client*-side `setExecuteDeadline` — see [`completion.md`](completion.md), "Client-side execute deadline". @@ -1805,11 +1817,12 @@ Qt-free reference transport: they speak the same RFC 6455 WebSocket framing as raw POSIX (BSD) sockets instead of `QWebSocket`/`QWebSocketServer`. The module is header-only, gated behind the CMake option `MORPH_BUILD_NET` (default `OFF`; Linux/macOS only — see Limitations), and depends on nothing but `morph` -itself: the HTTP/1.1 Upgrade handshake (`Sec-WebSocket-Key`/ -`Sec-WebSocket-Accept`, via a hand-rolled SHA-1 + base64) and the masked/ -unmasked text-frame codec are implemented from scratch in -`include/morph/net/detail/` (`sha1.hpp`, `base64.hpp`, `ws_handshake.hpp`, -`ws_frame.hpp`, `tcp_socket.hpp`). Because both transports round-trip the same +and the core-cpp modules `morph` already links: the HTTP/1.1 Upgrade handshake +(`Sec-WebSocket-Key`/`Sec-WebSocket-Accept`, via a hand-rolled SHA-1 and +core-cpp's `core::base64::encode`) and the masked/unmasked text-frame codec are +implemented in `include/morph/net/detail/` (`sha1.hpp`, `ws_handshake.hpp`, +`ws_frame.hpp`, `tcp_socket.hpp`), and the accept loop's wakeup is core-cpp's +`core::platform::Wakeup`. Because both transports round-trip the same `wire::Envelope`, a `SocketBackend` client and a `QtWebSocketServer` interoperate (and vice versa) with no protocol changes on either side. @@ -1992,11 +2005,12 @@ any thread for exactly this purpose, on a **connected** socket. **The accept loop owns its own wakeup, and does not borrow the kernel's** The accept thread never parks in `accept(2)`. `listen()` sets -`O_NONBLOCK` on the listening socket and creates a self-pipe; the loop waits in -a single `poll()` over the listening fd and the pipe's read end, and takes a +`O_NONBLOCK` on the listening socket and creates a `core::platform::Wakeup` +(an eventfd on Linux, a self-pipe on macOS and the BSDs); the loop waits in a +single `poll()` over the listening fd and the wakeup's descriptor, and takes a ready connection with `TcpSocket::tryAccept()`, which answers `std::nullopt` -rather than parking when a readiness report has gone stale. `close()` writes one -byte to the pipe before `join()`, which is what ends the loop. +rather than parking when a readiness report has gone stale. `close()` signals +the wakeup before `join()`, which is what ends the loop. **The listener's non-blocking mode stops at the listener.** `TcpSocket`'s fd-adopting constructor clears `O_NONBLOCK` on every descriptor it @@ -2018,16 +2032,19 @@ That replaces, rather than supplements, the previous mechanism: `close()` no longer calls `shutdownBoth()` on the *listening* socket at all. It used to, and relied on `shutdown(2)` kicking a parked `accept()` — true on Linux, not a POSIX guarantee, and false on macOS/BSD, where the accept thread stayed parked and -`~SocketServer()` hung with no timeout on its join. Because the pipe is now the -only wakeup, the mechanism is exercised by every teardown on every platform, -including CI's: deleting the wakeup `write()` hangs the Linux build too. A -platform-conditional wakeup would instead have been a macOS-only path that -Linux-only CI could never execute. +`~SocketServer()` hung with no timeout on its join. Because the wakeup is the +only way the loop ends, the mechanism is exercised by every teardown on every +platform, including CI's: removing the `signal()` hangs the Linux build too. +The wakeup's kernel object does differ by platform — an eventfd on Linux, a +self-pipe elsewhere — and morph's Linux-only CI exercises only the first; the +self-pipe is core-cpp's, covered by its `Wakeup_test` on core-cpp's own macOS +legs, rather than a morph code path nothing here could execute. Two consequences follow, both deliberate: -- `listen()` **fails closed** if the pipe cannot be created (`pipe(2)` - answering `EMFILE`/`ENFILE`): it returns `false` and spawns no thread, rather +- `listen()` **fails closed** if the wakeup cannot be created (the kernel out + of descriptors, which `core::platform::Wakeup`'s constructor reports by + throwing): it returns `false` and spawns no thread, rather than starting an accept loop nothing could ever interrupt. - `close()` **releases the listening descriptor** once the accept thread has joined — after the join, so no fd number can be reused under a `poll()` still @@ -2044,7 +2061,7 @@ are: - **The worker pool must outlive the backend.** Every backend takes an `IExecutor& workerPool` by reference (`LocalBackend`, `RemoteServer`) and wraps - it in a `StrandExecutor`. The pool (typically a `ThreadPoolExecutor`) must be + runs its strands on it. The pool (typically a `ThreadPoolExecutor`) must be destroyed *after* the backend that references it — and, in practice, after the `Bridge` that owns the backend. Destroying the pool first leaves the strand pointing at freed storage. @@ -2143,7 +2160,7 @@ round-trip; model and GUI authors must not assume any two share a thread: |---|---| | `serializeAction` | The **calling / GUI thread** — `SimulatedRemoteBackend::execute` invokes it synchronously while building the envelope, before handing off to the pool. | | `deserializeResult` | The **reply / pool thread** — invoked inside the `handle()` reply callback when the server's `ok` arrives (for `SimulatedRemoteBackend`, that is a `RemoteServer` worker-pool thread). | -| `localOp` | The **model strand** (`LocalBackend` only) — posted on the per-`ModelId` `StrandExecutor`, serialised against other actions for the same model. Never invoked on the remote path. | +| `localOp` | The **model strand** (`LocalBackend` only) — posted on the per-`ModelId` strand, serialised against other actions for the same model. Never invoked on the remote path. | On the server side, `RemoteServer` runs authorize/authenticate and the model lookup on the pool thread that `dispatchMessage` runs on, then runs @@ -2413,9 +2430,9 @@ not a behavior change to the existing loopback-only default. | Method | Notes | |---|---| | `SocketServer(server, port = 0, cfg = Config{})` | Fronts `RemoteServer& server`. Does not start listening. | -| `listen()` | Binds `127.0.0.1:port`, makes the listening socket non-blocking, creates the accept loop's wakeup pipe, and spawns the accept thread; returns success. Fails closed (`false`, no thread) if the wakeup pipe cannot be created — an accept loop nothing can interrupt is worse than not listening. | +| `listen()` | Binds `127.0.0.1:port`, makes the listening socket non-blocking, creates the accept loop's wakeup (`core::platform::Wakeup`), and spawns the accept thread; returns success. Fails closed (`false`, no thread) if the wakeup cannot be created — an accept loop nothing can interrupt is worse than not listening. | | `port()` | Bound port (OS-assigned when constructed with `0`), or `0` before `listen()` succeeds. | -| `close()` | Stops accepting, shuts down and joins every client thread and the accept thread. Idempotent; also run by the destructor. Interrupts the accept loop by writing one byte to the wakeup pipe it polls — **not** by `shutdownBoth()` on the listening socket, which works only because Linux kicks a parked `accept(2)` on shutdown and leaves macOS/BSD teardown hanging forever. Releases the listening descriptor after the join, so `port()` reads `0` afterwards. Serialized against itself by a dedicated mutex, so concurrent callers on a **live** object are safe and each returns only once teardown is complete. A `_closing.exchange` guard is not enough: it lets a second caller reach `_acceptThread.join()` while the first is inside it — two joins on one `std::thread`, which hangs forever on Linux/glibc and throws `std::system_error` on macOS/libc++. The wakeup write runs under that same mutex and at most once per `listen()`/`close()` cycle. Racing `close()` against the *destructor* remains out of contract, as for any member call. | +| `close()` | Stops accepting, shuts down and joins every client thread and the accept thread. Idempotent; also run by the destructor. Interrupts the accept loop by signalling the wakeup it polls — **not** by `shutdownBoth()` on the listening socket, which works only because Linux kicks a parked `accept(2)` on shutdown and leaves macOS/BSD teardown hanging forever. Releases the listening descriptor after the join, so `port()` reads `0` afterwards. Serialized against itself by a dedicated mutex, so concurrent callers on a **live** object are safe and each returns only once teardown is complete. A `_closing.exchange` guard is not enough: it lets a second caller reach `_acceptThread.join()` while the first is inside it — two joins on one `std::thread`, which hangs forever on Linux/glibc and throws `std::system_error` on macOS/libc++. The wakeup signal runs under that same mutex and at most once per `listen()`/`close()` cycle. Racing `close()` against the *destructor* remains out of contract, as for any member call. | ## `executeInto` — settling the caller's own completion @@ -2472,7 +2489,7 @@ implementation to absorb — see | `setReconnectHandler` | Default no-op | Only backends with a transport layer (e.g. `QtWebSocketBackend`) need to react to reconnects. `LocalBackend` and `SimulatedRemoteBackend` never invoke it. | | `setConnectHandler`/`setDisconnectHandler` on `IBackend`, not only `QtWebSocketBackend` | Same no-op-default pattern as `setReconnectHandler` | Connection state is a property of any transport-backed backend; a UI observing it shouldn't have to downcast to a concrete backend type. A purely local backend has no meaningful connection state, so the base-class hook is simply inert for it — no behavior change, matching the existing `setReconnectHandler` precedent exactly. | | `setDisconnectHandler` fires before reconnect scheduling | Ordering choice, not incidental | An instant successful reconnect must not look, from an observer's perspective, like nothing happened — the disconnected state must be visible even when the very next thing that happens is a fresh `connected`. | -| Strand-per-model | `StrandExecutor` serialises actions per `ModelId` | Actions against the same model run sequentially; different models can run in parallel. No global lock on the pool. | +| Strand-per-model | `ModelStrands` (core-cpp's `KeyedStrands`) serialises actions per `ModelId` | Actions against the same model run sequentially; different models can run in parallel. No global lock on the pool. | | Overwrite `session.principal` on remote execute | `authenticate()` result replaces the client claim before dispatch | The client-asserted `Context::principal` is untrusted; a verifying authorizer makes the token-derived identity authoritative so `session::current()->principal` inside a model is trustworthy. Non-verifying authorizers return `nullopt` and change nothing. | | Opaque model ids | Monotonic counter run through a keyed 4-round Feistel permutation (`detail::OpaqueIdGenerator`), key drawn from `std::random_device` at construction | Guarantees uniqueness (Feistel networks are bijections for any round function) while making ids unguessable without the key; self-contained, no external crypto dependency — same posture as the reference HMAC-SHA256 in `session_auth.hpp`. | | WebSocket `deregisterModel` is fire-and-forget | Send-only, no nested event loop | A synchronous deregister would need a nested `QEventLoop`, which is typically driven from a destructor (`~BridgeHandler`) and can trip Qt asserts. A lost/undelivered deregister no longer leaks indefinitely: `QtWebSocketServer`'s connection scope reclaims the model at the next disconnect (see Limitations). | @@ -2481,12 +2498,12 @@ implementation to absorb — see | Reconnect handler skipped on first connect | Fired only when `_everConnected` was already true | The initial handler registration is driven by `BridgeHandler` constructors; firing the reconnect handler on the very first connect would double-register. | | No reconnect for never-connected sockets | `disconnected` schedules a retry only if `_everConnected` | A socket that never reached the server (bad URL / refused) fails fast via `waitForConnected` returning false, rather than backing off forever. | | Server reply marshalled to the Qt thread | `QMetaObject::invokeMethod(..., QueuedConnection)` with a `QPointer` | `RemoteServer::handle` produces the reply on a pool thread, but `QWebSocket::sendTextMessage` must run on the Qt thread; the weak `QPointer` drops the reply cleanly if the client disconnected meanwhile. | -| `executeTimeout` implementation | A dedicated, lazily-started background thread (`morph::async::detail::TimeoutScheduler`) per `RemoteServer`, not a per-call thread | `IExecutor` has no delayed-post primitive and `RemoteServer` is transport-agnostic (cannot assume Qt's `QTimer`). One thread amortizes across every timed call; it is only started the first time `executeTimeout` is actually configured, so a server that never uses the feature pays no cost. | +| `executeTimeout` implementation | A dedicated, lazily-started background thread (`morph::async::detail::TimeoutScheduler`, a thread running core-cpp's `PlatformLoop`) per `RemoteServer`, not a per-call thread | `IExecutor` has no delayed-post primitive and `RemoteServer` is transport-agnostic (cannot assume Qt's `QTimer`). One thread amortizes across every timed call; it is only started the first time `executeTimeout` is actually configured, so a server that never uses the feature pays no cost. The deadlines are the loop's own timers, so nothing polls: an armed timer is what bounds the loop's next wait. | | `messagesPerSecond` algorithm | Per-connection token bucket, capacity = rate, continuous refill; on empty the frame is refused with an `err` reply, and the connection is left open | Simplest correct rate limiter; allows a legitimate one-second burst without penalizing an otherwise well-behaved client. Refusing rather than closing keeps a transient burst from taking down the connection. The frame is *answered* rather than discarded because a reply costs nothing at the protocol level and is the difference between a caller's `Completion` failing and it hanging: the id is recovered by the same bounded prefix scan (`peekCallId`) the `maxMessageBytes` branch uses, so no decode of a frame that will not run is needed. | -| Graceful shutdown drains via a shared in-flight counter, not a new `IExecutor::waitIdle` | `RemoteServer` counts its own accepted-but-unreplied executes rather than adding a general drain API to `IExecutor`/`StrandExecutor` | The drain condition morph can define precisely — "every accepted execute has replied" — lives at the server layer, where the work is counted; executor.md's "no graceful drain / `waitIdle`" limitation is deliberately left as-is for raw executor users. | +| Graceful shutdown drains via a shared in-flight counter, not a new `IExecutor::waitIdle` | `RemoteServer` counts its own accepted-but-unreplied executes rather than adding a general drain API to `IExecutor` | The drain condition morph can define precisely — "every accepted execute has replied" — lives at the server layer, where the work is counted; executor.md's "no graceful drain / `waitIdle`" limitation is deliberately left as-is for raw executor users. | | Backend-change-awareness captured at registration | `IModelHolder::isBackendChangeAware()` (compile-time answer per model type) + `LocalBackend::_changeAware`, maintained by `registerModel`/`deregisterModel` | Replaces a per-`notifyBackendChanged`-call `dynamic_cast` sweep over every live model with a virtual query done once at registration, and a lookup restricted to the models that actually opted in. No RTTI dependency; cost is O(change-aware models) instead of O(all models) under `_regMtx`. No change to the model-facing contract (`IBackendChangedSink`, `BackendChangedMixin`) or to when/where `onBackendChanged()` runs. | | `morph::net`'s I/O model | A dedicated I/O thread + `std::condition_variable`, instead of the Qt event loop | Lets `SocketBackend`/`SocketServer` run with no GUI event loop and no Qt dependency, and — as a side effect — lets `SocketBackend` be driven safely from multiple threads (`QtWebSocketBackend` cannot be, since it is pinned to one event-loop thread). | -| `morph::net` frame/handshake implementation | Hand-rolled RFC 6455 (SHA-1 + base64 + HTTP Upgrade + frame codec), not a third-party library | The spec's own interop requirement (a `morph::net` client/server must talk to the real Qt transport and vice versa) rules out a bespoke non-WebSocket framing; hand-rolling avoids adding a dependency to keep morph's default build dependency-free, and RFC 6455's core (handshake + frame codec, including fragment reassembly) is a small, bounded surface. | +| `morph::net` frame/handshake implementation | Hand-rolled RFC 6455 (SHA-1 + HTTP Upgrade + frame codec), with base64 from core-cpp, not a WebSocket library | The spec's own interop requirement (a `morph::net` client/server must talk to the real Qt transport and vice versa) rules out a bespoke non-WebSocket framing; hand-rolling avoids adding a dependency beyond core-cpp, which morph links anyway, and RFC 6455's core (handshake + frame codec, including fragment reassembly) is a small, bounded surface. | | `WsFrameReader` reassembles fragments | Accumulates continuation frames and returns only the completed message | Fragmentation is not an exotic case: a peer fragments whenever a message exceeds its outgoing frame size, and Qt's `QWebSocket` defaults that to 512 KiB. Rejecting fragments broke interop with the transport this project ships, for every payload past that size. Control frames interleaved between fragments pass through untouched, and the reassembled total is bounded by `wire::kMaxEnvelopeBytes` so a stream of tiny continuations cannot grow the buffer without limit. | | `WsFrameReader` rejects RFC 6455-illegal frames instead of tolerating them | Masking direction, RSV bits, opcode range, control-frame framing, Close status code, minimal length encoding and text-payload UTF-8 are all checked; a violation throws out of `tryExtractFrame()` and the call site drops the connection | The interop requirement above makes what the reader *refuses* part of the transport's contract rather than an implementation detail: a tolerant reader accepts ten classes of illegal frame, and a peer that sends one here gets disconnected instead. The reader is given its role at construction (`expectMasked`) because §5.1 is directional — a server MUST reject an unmasked client frame and a client MUST reject a masked server frame, and that rule is the anti-cache-poisoning defence, not a formality. Text UTF-8 is validated incrementally, since a multi-byte sequence may straddle a fragment boundary. On the sending side the mask key is drawn per frame from a thread-local `std::random_device` rather than a thread-local `std::mt19937`, whose state a peer can reconstruct from 624 observed keys (§5.3); `random_device` has no reproducible state to recover, and holding it thread-local keeps the entropy source open instead of reacquiring it on every outbound message. | | Registration continuation delivered via a caller-supplied `IExecutor&`, not on the backend's thread | `bindModel`/`promoteModel` return a `Completion` built with the caller's executor | A per-verb non-blocking twin can only state its threading contract in prose, and a violation of it is a use-after-free. Making the executor an argument moves the choice of delivery thread from fifteen implementors that know nothing about the caller's teardown to the one caller that does, and turns it from a `@note` into a value a call site must produce. Rejected: matching `execute`'s `IExecutor*` — a null pointer makes `Completion` drop every handler silently, which is the same unobservable failure the surface removes. | diff --git a/docs/spec/core/bridge.md b/docs/spec/core/bridge.md index b07f7c444..3b4439410 100644 --- a/docs/spec/core/bridge.md +++ b/docs/spec/core/bridge.md @@ -371,8 +371,8 @@ returns. Consequences: - **`switchBackend()` from `onBackendChanged()` is still unsupported** — but for a different reason than the lock. The callback runs on the *outgoing* backend's strand; a nested `switchBackend` would release the last reference to that - backend when it returns, and `~StrandExecutor` blocks until its in-flight tasks - finish — including the very task calling it — a self-join hang. Re-registering + backend when it returns, and `~LocalBackend` drains its strands — including + the very task calling it — a self-join hang, asserted in a debug build. Re-registering models or reconciling queue state is the supported reaction; swapping the backend again from inside the notification is not. @@ -1079,7 +1079,7 @@ backend — `LocalBackend` erases map entries under its own mutex, and `QtWebSocketBackend`/`SocketBackend` are documented fire-and-forget sends precisely so destruction never spins a nested event loop. A destructor blocking on a bounded predicate is the framework's existing idiom for this hazard — -`~StrandExecutor` blocks until `_inFlight == 0` +`~LocalBackend` blocks until its strands have drained ([concurrency_and_lifetimes.md](../concurrency_and_lifetimes.md)). **The lifetime rule.** The gate makes only *destruction* safe in @@ -1223,6 +1223,10 @@ carve-out remains part of the contract; the attribute simply cannot say it. See ## Cross-references +- [`coroutines.md`](coroutines.md) — action handlers that return + `core::async::Task`: how `executeVia`'s `localOpAsync` drives them on the + model's strand, the per-instance action gate, and how an execute deadline + stops a suspended handler. - [`backend.md`](backend.md) — `IBackend`, `LocalBackend`, `SimulatedRemoteBackend`, `registerModelWithContext`, `cancelPending`, `BackendChangedError`/`BridgeDestroyedError`, reconnect handlers. diff --git a/docs/spec/core/completion.md b/docs/spec/core/completion.md index a96908b82..d21df0f9b 100644 --- a/docs/spec/core/completion.md +++ b/docs/spec/core/completion.md @@ -92,7 +92,9 @@ built but never delivered. Each individual handler invocation inside that closure is wrapped in its own `try { ... } catch (...) { logError(...); }`, so a throwing handler is logged and skipped without preventing the handlers attached after it from running — fan-out means every attached handler gets its turn, -independent of an earlier one misbehaving. +independent of an earlier one misbehaving. A handler attached after the state +settled is posted in a closure of its own, wrapped the same way, so its throw +is logged too rather than escaping into whatever runs the executor. `setException` additionally sets `onErrAttached = (cbExec != nullptr)` — but only along the branch where at least one `onErr` handler was already @@ -390,11 +392,13 @@ pre-existing behavior exactly — a `Bridge` that never calls the setter behaves as it always did, and spawns no extra thread. The current value is readable via `Bridge::executeDeadline()`. -**Single-threaded WebAssembly.** `TimeoutScheduler` has a second build, -selected by `#if defined(__EMSCRIPTEN__) && !defined(__EMSCRIPTEN_PTHREADS__)`, -that uses the browser's own `setTimeout` (`emscripten_async_call`) instead of a -thread and fires its callbacks on the main thread — the same thread the Qt -event loop and every `QtExecutor`-posted completion callback already run on. +**Single-threaded WebAssembly.** `TimeoutScheduler` keeps its deadlines in a +core-cpp `core::net::PlatformLoop`. Natively a thread of its own runs that +loop. Under `#if defined(__EMSCRIPTEN__) && !defined(__EMSCRIPTEN_PTHREADS__)` +there is no thread: the loop is host-driven, pumped by the browser's own +`setTimeout` (`emscripten_async_call`), and fires its callbacks on the main +thread — the same thread the Qt event loop and every `QtExecutor`-posted +completion callback already run on. This is not a degradation switch: deadlines still fire, with the same first-result-wins race and the same `ClientTimeoutError`. It exists because a `wasm_singlethread` Qt build (what `.github/workflows/wasm-ladder.yml` installs @@ -403,25 +407,23 @@ Emscripten's non-pthread `pthread_create` stub, so constructing a `std::thread` throws `std::system_error` at runtime — which would have made `setExecuteDeadline` unusable from a browser tab, and with it `examples/common/gui/event_poller.hpp`, whose constructor calls it -unconditionally. Three behavioural differences, all documented in +unconditionally. Two behavioural differences, both documented in `timeout_scheduler.hpp`'s own `@file` comment: callbacks are never concurrent -with the caller; `cancel()` releases the callback immediately but leaves the -underlying browser timer to elapse harmlessly rather than clearing it; and -`cancel()` there really does mean "no callback runs after this returns", -whereas the threaded build's `cancel()` returns while an *already-started* -callback goes on running on the scheduler thread. A caller that -must work in both builds gets the weaker of the two: every scheduled callback -has to stay safe to run after its own `cancel()`, which the deadline callback -here does by settling a write-once `CompletionState` it holds a `shared_ptr` -to. **This -build has never been compiled or run in this repository** — no Emscripten -toolchain is available here; its only verification is the `ladder-wasm` CI -compile gate. +with the caller; and `cancel()` there really does mean "no callback runs +after this returns", whereas the threaded build's `cancel()` returns while an +*already-started* callback goes on running on the scheduler thread. In both +builds `cancel()` releases the callback and retires the loop's timer. A +caller that must work in both builds gets the weaker of the two: every +scheduled callback has to stay safe to run after its own `cancel()`, which the +deadline callback here does by settling a write-once `CompletionState` it +holds a `shared_ptr` to. The `wasm-ladder` and `wasm-demo` workflows compile +and link this build; no test in this repository runs it. **Mechanics.** Every `executeVia()` call made while a non-zero deadline is installed arms a timer on a `Bridge`-owned -`morph::async::detail::TimeoutScheduler` (a single background thread — or, in a -single-threaded WASM build, a browser timer; see above — created lazily on the +`morph::async::detail::TimeoutScheduler` (a single background thread running an +event loop — or, in a single-threaded WASM build, a loop the browser's timer +pumps; see above — created lazily on the first call that enables a deadline and torn down with the `Bridge`; the same class `RemoteServer` uses for its server-side `LimitPolicy::executeTimeout`). The timer's callback captures only the typed `CompletionState` — never the `Bridge` — and resolves it with @@ -592,7 +594,7 @@ See [`backend.md`, `IBackend::executeInto`](backend.md#executeinto--settling-the ## `morph/core/async.hpp` — the cheap include -`Completion`, `IExecutor`, `StrandExecutor` and `CallbackScope` are usable +`Completion`, `IExecutor`, the strands and `CallbackScope` are usable without a model, a registry, a wire envelope or a schema, and none of them reaches glaze. `core/async.hpp` is a facade over the four headers that carry them — it declares nothing of its own, so including it is exactly equivalent to @@ -693,6 +695,9 @@ outlives the `Completion`. See [concurrency_and_lifetimes.md](../concurrency_and ## Cross-references +- [`coroutines.md`](coroutines.md) — awaiting a `Completion` from a + coroutine (`operator co_await() &&`), where it resumes, and how a stop + withdraws the await. - [`executor.md`](executor.md) — `IExecutor` and its implementations; `cbExec` is the executor on which every callback is posted. - [`logger.md`](logger.md) — `morph::log::logError`, the error-handling sink diff --git a/docs/spec/core/coroutines.md b/docs/spec/core/coroutines.md new file mode 100644 index 000000000..e2d9f9a20 --- /dev/null +++ b/docs/spec/core/coroutines.md @@ -0,0 +1,469 @@ +# Coroutines — design + +morph's asynchronous surface is `Completion` plus the executors that run +its handlers. This spec describes how coroutines built on core-cpp's +`core::async::Task` sit on top of that surface. There are two sides: + +- **Client side.** A coroutine can `co_await` a `Completion`, which is + usually the result of a `BridgeHandler::execute`. +- **Model side.** A model's action handler can itself be a coroutine that + returns `core::async::Task`. The bridge drives it on the model's strand. + +It adds no wire message and changes no backend protocol. A Task handler +behind a remote backend is driven on the server exactly as it would be +locally, and its result travels back as the same `ok` or `err` envelope a +synchronous handler's does. + +The implementation lives in `include/morph/core/coroutine.hpp`, apart from +three pieces: the awaiter hook in `completion.hpp`, the handler detection in +`model.hpp`, and the driver at the two execution sites (`bridge.hpp`'s +`localOp` and `registry.hpp`'s `ActionDispatcher` runner). + +## Contents + +- [Where a coroutine resumes](#where-a-coroutine-resumes) +- [Client side](#client-side) + - [`co_await` on a `Completion`](#co_await-on-a-completiont) + - [Cancellation](#cancellation) + - [`spawn` — the entry point from non-coroutine code](#spawn--the-entry-point-from-non-coroutine-code) + - [`delay`](#delay) +- [Model side](#model-side) + - [Task handlers](#task-handlers) + - [The handler's resumer](#the-handlers-resumer) + - [Not re-entrant: the action gate](#not-re-entrant-the-action-gate) + - [Execute deadlines](#execute-deadlines) + - [Journal and observability](#journal-and-observability) +- [Single-threaded WebAssembly](#single-threaded-webassembly) +- [Failure modes](#failure-modes) +- [Design decisions](#design-decisions) +- [Limitations](#limitations) +- [Cross-references](#cross-references) + +## Where a coroutine resumes + +`core::async::Task` carries a stop token from awaiter to awaitee, but no +executor. Where a coroutine resumes is core-cpp's current-executor context +(``): + +- An executor that resumes coroutines states itself as the current executor + around each resumption, or each batch of them, with + `core::async::ExecutorScope`. morph's two do: the adapter `spawn` builds over + a `morph::exec::IExecutor`, and a Task handler's resumer (see + [The handler's resumer](#the-handlers-resumer)). So do core-cpp's: a strand + once per batch, `core::async::ThreadPoolExecutor` once per worker thread, + `core::net::EventLoop` once per turn. +- An awaitable reads it in `await_suspend`, on the thread that is suspending + the coroutine, as a `core::async::ResumeTarget`, and hands the continuation + back to it. Where the executor's lifetime is shared -- a model instance's + strand, a handler's resumer -- the target keeps it alive until then. + +The rule: + +> **A coroutine resumes on the executor it suspended on.** If an executor was +> current when it suspended, the continuation is submitted to it. If none was, +> it resumes wherever the awaited operation completed: for a `Completion` +> that is the completion's own executor, where its `then()` handlers run; for +> `delay` it is `TimeoutScheduler`'s thread. + +So a coroutine started with `spawn(exec, …)` resumes on `exec`, and a Task +handler on its model's strand, after every `co_await` of an awaitable that +follows the rule. This holds even when what it awaited completed on some other +executor, such as another model's completion delivered on the worker pool. + +morph's awaiters (`Completion`, `delay`) and core-cpp's +`core::async::AsyncQueue::pop` follow it, a stop included. `core::net`'s socket +and timer awaitables do not: they resume on their `EventLoop`, and a coroutine +that awaits one carries on on the loop's thread, off its strand, until it goes +back. Inside a Task handler `core::async::currentExecutor()` is the handler's +resumer, which lives as long as the handler does, so this is how: + +```cpp +auto* const strand = core::async::currentExecutor(); // on the strand +auto bytes = co_await socket.read(buffer); // on the loop +co_await core::async::ResumeOn{*strand}; // on the strand again +``` + +Whatever the handler does between the foreign await and the hop runs beside +its model's other work, not serialised with it. Its end is safe either way: the +driver runs what follows a handler's end -- recording it, settling the call, +leaving the action gate, starting the next action -- on the strand, from +wherever the handler finished. + +## Client side + +### `co_await` on a `Completion` + +```cpp +core::async::Task refresh(BridgeHandler& accounts) +{ + auto pending = accounts.execute(GetBalance{ .id = 42 }); + Balance const balance = co_await std::move(pending); // T, or rethrows + show(balance); +} +``` + +- **`operator co_await() &&` only.** Awaiting consumes the `Completion`, so it + is an rvalue operation. `co_await pending` on an lvalue does not compile, + and `tests/compile_checks/` holds a `requires` check that says so. +- **The result.** `co_await` yields `T` — a copy of the settled value, because + `CompletionState` never moves out of its stored value (other handlers may + read it) — or rethrows the stored `exception_ptr`. +- **How it attaches.** The awaiter uses the completion's ordinary `then` and + `onError` fan-out, so handlers attached before or after it still run. The + await is one more handler pair, not a replacement for them. +- **Where it resumes.** See the rule above: on the executor the coroutine + suspended on, or on the completion's executor if there was none. + With a `MainThreadExecutor` as the completion's executor, the coroutine + resumes inside `runFor`. +- **An already-settled completion.** The coroutine still suspends and resumes + through the executor. It never continues inline, because `then()` on a ready + state posts too, and one rule is simpler to reason about than two. +- **An empty completion.** A default-constructed or moved-from `Completion` + has no state. Awaiting it throws `std::logic_error` from `await_resume` + without suspending. + +### Cancellation + +If the awaiting promise satisfies `core::async::HasStopToken` (every +`core::async::Task` does) and its token can be stopped, the awaiter registers +a stop callback for the duration of the suspension: + +- **Stop wins or completion wins, exactly once.** The awaiter and its two + completion handlers share a small state with one atomic outcome: pending, + settled or cancelled. Whichever of *the completion's handler* and *the stop + callback* moves it off pending resumes the coroutine. The other finds it + already decided and does nothing. +- **A stop detaches the continuation.** The handlers attached to the + completion hold only that shared state, never the coroutine frame, and are + guarded by a `CallbackToken` whose scope the stop ends. A completion that + settles later therefore reaches nothing, and the coroutine's frame, with + everything it captured, is released as soon as the coroutine unwinds. +- **The coroutine resumes with `core::async::OperationCancelled`,** thrown + from `await_resume`. It resumes where a normal completion would have: on the + executor it suspended on, or on the completion's executor. +- **A token already stopped at `co_await`** resumes with + `OperationCancelled` without attaching anything to the completion. + +The completion itself is not cancelled: it is still settled by whatever +produces it, and its other handlers still run. Cancellation withdraws this one +await. + +### `spawn` — the entry point from non-coroutine code + +```cpp +void morph::async::spawn(morph::exec::IExecutor& exec, core::async::Task task); +``` + +`spawn` starts @p task detached, and every resumption of it — its first +step included — is posted to `exec`. This is the Qt/QML entry point: with a +`QtExecutor`, a GUI flow written as a coroutine runs every step on the GUI +thread. + +- **Detached.** Nothing waits for the task. The frame is freed when it + finishes. +- **An exception the task lets escape is logged** through `morph::log` and + swallowed, the same as an exception from a posted task on morph's executors. +- **`exec` must outlive the task.** The adapter that resumes the task holds + `exec` by reference, as every `Completion` holds its callback executor. +- **No stop source.** The task's stop token is one that is never stopped. A + flow that needs cancellation owns a `core::async::StopSource` and checks it, + or awaits under a scope of its own. + +### `delay` + +```cpp +auto morph::async::delay(morph::async::detail::TimeoutScheduler& scheduler, + std::chrono::milliseconds duration); // an awaiter +``` + +`co_await delay(scheduler, 50ms)` suspends for at least @p duration. + +- **Timing.** The timer is one `TimeoutScheduler` entry, so it fires on the + scheduler's loop thread natively and on the browser's timer under + single-threaded WebAssembly. +- **Where it resumes.** On the executor the coroutine suspended on, or on the + scheduler's thread if there was none. +- **Stop-aware.** With a stoppable token the awaiter registers a stop + callback. A stop cancels the scheduler entry, which releases the timer's + capture at once, and resumes the coroutine with `OperationCancelled`: on the + executor it suspended on, or, if there was none, inline on the thread that + requested the stop. + +## Model side + +### Task handlers + +```cpp +class LedgerModel +{ + public: + core::async::Task execute(PostEntry entry); // a Task handler + Balance execute(GetBalance query); // an ordinary handler +}; +``` + +A model's `execute(Action)` may return `core::async::Task`. + +- **Registration.** `model::HandlerResult` maps `Task` to `R` and + everything else to itself. `BRIDGE_REGISTER_ACTION` deduces + `ActionTraits::Result` through it, so the action's result type on the + wire and in every `Completion` is `R`, not the Task. + `BRIDGE_REGISTER_ACTION_FOR_CLIENT` names `R` as the result, as for any + handler. `ActionDispatcher::registerAction` files a Task handler under + `dispatchAsync`'s runner. +- **One handler shape per action.** A model mixes Task and ordinary handlers + freely. +- **Where it runs.** Both execution sites detect a Task handler at compile time + (`model::isTaskHandler`) and drive it instead of calling it synchronously: + `Bridge::executeVia`'s `localOp` for `LocalBackend`, and + `ActionDispatcher`'s runner for `RemoteServer`. Validation, computed-field + recompute and precision reconciliation all run before the handler starts, + exactly as for an ordinary handler. +- **How it is driven.** The handler is called on the model's strand. That + constructs the Task, which is lazy: `core::async::Task` suspends at + `initial_suspend`. It is then started on the strand, inside the handler's + resumer, and every later resumption goes through that resumer. When + it finishes, the result — or the exception — settles the call exactly where + an ordinary handler's return value or throw would. + +### The handler's resumer + +A Task handler's resumptions go through one `morph::exec::detail::TaskResumer`, +made for it when it starts, and its model instance's strand: one of the +backend's `ModelStrands`, core-cpp's `KeyedStrands` keyed by `ModelId` (see +[`executor.md`](executor.md), "Strands"). + +- **The current executor wherever the handler runs**: its first step, started + on the strand, and every resumption after. An awaitable that resumes on the + current executor therefore hands the handler back to the resumer, whose + `submit` queues it on the model's strand, serialised with that model's other + work. A handler that awaits another model's `execute` resumes on its own + strand, not on the other model's. The `ParkedWork` overload queues the work + with its claim: the handler's own frames belong to the driver and carry none, + but a `core::async::DetachedTask` the handler starts, and that parks on an + awaitable resuming on the current executor, reaches the resumer with its + claim armed, and keeping it is what stops the chain being freed while its + handle waits on the strand. Where the strands are closed, the resumer disarms + the claim and resumes the chain inline, as it does a handler. +- **The action's session, around every resumption.** The strands' keyed + around-task hook installs the session, with the resumer as the current + executor, around every coroutine resumed on a model instance whose Task + handler has started and not finished (`ModelStrands::enroll`); it asks the + task its kind (core-cpp's `RunTask::kind()`) and runs a posted callable + bare. The action gate lets one action run on an instance at a time, so an + instance has at most one. So `onBackendChanged`, an action queued behind the + handler and the handler's end run without the handler's session. A detached + chain that an earlier, finished handler A left behind and that comes back + after handler B started is a resumption, and runs under B's session and + resumer (see [`backend.md`](backend.md)). +- **Held by the driver.** The driver's frame and every `ResumeTarget` taken + inside the handler hold the resumer, so it lives until the last of them has + run. + +The resumer shares its backend's strands, so it outlives the backend. Once a +`LocalBackend` has closed them, `trySubmit` refuses, and the resumer resumes the +handler inline instead, with the same session installed and itself the current +executor, on the thread that submitted the resumption. For a `Completion` +that is the completion's callback executor, where its handlers run; for `delay` +it is `TimeoutScheduler`'s thread; for a stop it is the thread that requested +it. Only a handler no stop can reach gets here; see Limitations, "Teardown". + +### Not re-entrant: the action gate + +A strand serialises the *tasks* posted to it, and a suspended Task handler is +not a task: while it waits, the strand is free. Without more, the next action +for the same model would start while the first was suspended half-way. That +is re-entrancy, which a model author writing sequential code does not expect. + +So each model instance has an **action gate** (`model::detail::ActionGate`, +owned by its `IModelHolder`): + +- Every action, Task or ordinary, *enters* the gate on the model's strand + before it runs, and *leaves* when it is finished. For an ordinary handler + that is when it returns or throws; for a Task handler, when the Task + completes. +- An action that finds the gate held is queued in the gate, in arrival order, + and its strand task returns. +- When the holder leaves, the gate starts the oldest queued action, still on + the strand. That action enters before anything else can. +- Resumptions of the holder's Task do not enter the gate — they belong to the + action that holds it — so the handler that holds the gate keeps running. +- On `LocalBackend`, an action whose call `cancelPending` failed while it was + on its way to the gate is skipped when its turn comes: it leaves at once, + and its handler does not run for a caller that has already been answered. + +The next action for a model therefore starts only after the current handler's +Task has completed. The order is still arrival order. `ExecuteOrderGate`, +which orders the *posting* of remote executes to the strand, is unchanged; it +releases its ticket once the post has happened, as before, and does not wait +for the action to run. The gate is touched only on the model's strand, so it +needs no lock. + +A Task handler that awaits a second action *on its own model* waits forever: +that action is queued behind the gate the awaiting handler holds. This is the +same deadlock as a synchronous handler blocking on its own strand, and the +spec forbids it the same way rather than detecting it. + +### Execute deadlines + +`Bridge::setExecuteDeadline` arms a timer per call that rejects the caller's +`Completion` with `ClientTimeoutError`. For a call that reaches a Task +handler through `LocalBackend`, the same timer also requests stop on a +`core::async::StopSource` that the bridge creates per call. It hands that +source to the backend in `ActionCall::stopSource`, and the driver installs its +token as the handler Task's stop token. `LocalBackend` creates one itself for +a call that has no deadline, so that its destructor can stop the handler (see +Limitations, "Teardown"). So: + +- the caller's `Completion` rejects with `ClientTimeoutError`, exactly as + before; +- the suspended handler receives `core::async::OperationCancelled` at its next + `co_await` of a morph awaiter or a stop-aware core-cpp one, unwinds, and + leaves the action gate; +- its eventual outcome, now `OperationCancelled`, is discarded by the + completion's first-result-wins rule, and journalled as a failure like any + other throw. + +A synchronous handler cannot be interrupted, and is not. On a remote backend +the client's deadline cannot reach the server's handler, since nothing on the +wire carries it. The server's own `LimitPolicy::executeTimeout` stops it +instead: `RemoteServer` creates a `StopSource` per dispatch when a timeout is +configured, and hands its token to the handler. The timeout's timer replies +`err "timeout"` to the caller as before, then requests stop, and the handler +unwinds and leaves the action gate as above. + +### Journal and observability + +A Task handler is journalled when it completes: `Outcome::Succeeded` with the +serialised `R`, or `Outcome::Failed` with the exception's `what()` when the +handler threw. As for an ordinary handler, `Failed` means the model rejected the +action and nothing else: once the Task has completed, the model's mutation has +committed, and a failure to serialise `R` or to append the entry reaches the +caller as `ActionRecordingError` and is not journalled as `Failed`. The +execute span and the latency metric cover the time from when the action +entered the gate to when the Task completed. Suspended time is included, +because it is time the caller waits. + +## Single-threaded WebAssembly + +The same code runs on the browser's single main thread: + +- `spawn` over a `QtExecutor` posts every resumption through Qt's event loop; +- `delay` rides `TimeoutScheduler`'s host-driven loop; +- a strand's base executor is the Qt executor. + +Nothing here starts a thread or blocks. One thing differs, and it is core-cpp's +`CORE_CPP_ASYNC_HAS_THREADS`, not `__EMSCRIPTEN__`, that decides it: a backend's +destructor does not wait for its strands to drain, since there is no other +thread to finish the work and a blocking wait is not allowed. It seals them +before it stops the Task handlers, so each stopped handler's resumption is +refused and unwinds inline, and closing them drops only what was queued before +(`ModelStrands::teardown`). + +## Failure modes + +| Situation | Outcome | +|---|---| +| A Task handler throws, before or after a suspension | The exception settles the call: `onError` on the caller's `Completion`, an `err` reply remotely. The failure is journalled and the gate is left. | +| A Task handler is abandoned (the frame destroyed unfinished) | Never: the driver owns the frame and frees it when the Task completes. A handler whose await never completes is leaked instead. See Limitations. | +| A stop arrives while the handler is not suspended | Seen at its next stop-aware `co_await`. A handler that never awaits again finishes normally. | +| The awaited completion settles after a stop | Its handlers find the await cancelled and do nothing. | +| `spawn`'s task throws | Logged and swallowed. | +| A `co_await` of an empty `Completion` | `std::logic_error`, without suspending. | + +## Design decisions + +| Decision | Chosen | Why | +|---|---|---| +| The coroutine type | `core::async::Task` | One coroutine type across the Contour Terminal projects, with a stop token that propagates down a chain of awaits. morph adds awaiters and executors, not a second task type. | +| Where resumption happens | core-cpp's current-executor context, stated by every executor that resumes coroutines and read by every awaitable that follows it | `Task`'s promise carries no executor, and a handler that awaits another model's completion must come back to its own strand, not to wherever that completion was delivered. One context shared with core-cpp brings a handler back from `AsyncQueue::pop` too, which morph's own context could not. | +| The session across a suspension | The strands' keyed around-task hook, for the instance's one running Task handler | A context carried by `Task` itself would cost every `co_await` of every consumer; the gate makes one handler per instance the only coroutine the hook has to find. | +| Non-reentrancy | A per-instance action gate on the strand | Holding `ExecuteOrderGate`'s ticket until the Task completes would block a pool thread in `awaitTurn` for the whole suspension. With enough suspended handlers that exhausts the pool that their own awaits need. It would also leave `LocalBackend`, which has no ticket, unordered. | +| Lvalue `co_await` | Refused at compile time | Awaiting consumes the completion's one await slot and moves the handle; an lvalue await would hide that. | + +## Limitations + +- **No cancellation across the wire.** A client-side deadline or stop does not + reach a remote handler; only the server's own `executeTimeout` does. +- **Teardown.** `Bridge::switchBackend` and `~Bridge` fail every pending call + through `cancelPending` and, holding its last reference, destroy the + outgoing `LocalBackend`. `Bridge::pendingCalls()` reaching zero says nothing + about a handler still suspended then, because its call was among those + failed. So `~LocalBackend` ends them itself, in this order: + 1. It requests stop on every Task run it started that is still alive. Every + Task run has a stop source for this, whether or not the call has a + deadline. A handler suspended in an awaitable that resumes on the current + executor -- morph's own, `AsyncQueue::pop` -- resumes with + `OperationCancelled` through the strand, which is still open, and + unwinds. One suspended on a `core::net` socket or timer resumes on that + loop instead and unwinds there; its end is posted to the strand. + 2. It waits for the strands to drain: the resumptions and ends queued on + them, and every action queued behind them. A handler unwinding on another + executor whose end arrives meanwhile is queued behind its instance's + other tasks and runs in turn, on the strand. An action whose call + `cancelPending` had already failed when it reached the gate is skipped. + Its handler does not run, and its caller keeps the error it was given. + 3. It seals the strands: they refuse the try-forms, and a resumption or a + handler's end that arrives from now on runs inline, where it arrives. + Nothing of the handler's own chain runs on a strand beside it: step 2 + left the strands idle, and what reached them after step 2 returned is the + stopped handlers' own last steps, one at a time per handler. Sealing + before step 2 would let such an end run inline on a loop thread while + step 2 ran a task of the same instance on a pool thread, two threads + inside that instance's action gate. A `core::async::DetachedTask` the + handler started is not part of its chain: its resumption comes back + through the same resumer, is refused just the same, and can run inline + beside the handler's own step, as it could once the strands were closed. + 4. It waits for the strands to drain again, for whatever reached them + between steps 2 and 3. It does not wait for a handler still unwinding on + another executor; that handler's end runs inline. + 5. It closes the strands. A coroutine outside any Task handler that captured + a key's strand itself, and comes back through a plain submit after this, + is dropped by the closed strand: its owner has to resume it elsewhere. + + On the single-threaded WebAssembly build the strands are sealed first, before + step 1, and steps 2 and 4 wait for nothing: every stopped handler's + resumption is refused and unwinds inline, in the stop, since nothing else + could run the strand, and nothing else runs beside it. + + A handler that catches `OperationCancelled` and carries on holds up step 2 + while it runs on the strand: its later morph awaits see the stop at once. + `~LocalBackend` must not run on one of its own strand threads, whose drain + it would wait for; a debug build asserts it. The only + handler that survives teardown is one suspended where no stop reaches, inside + an awaitable that does not observe the promise's stop token, such as a + coroutine type from outside `core::async`. When that await completes, the + handler resumes inline (see [The handler's resumer](#the-handlers-resumer)), holding its model + instance alive; whatever it does from there runs, and its outcome is + discarded. If that await never completes, the handler is never resumed and + never freed — a leak. Such a handler can also overlap the action gate, when + a `LocalBackend` is destroyed directly rather than through a `Bridge`, whose + `cancelPending` would have failed every queued call: a queued Task handler B + whose call was not failed is started by the previous handler's `leave()` in + step 4, suspends in such an awaitable, and its end, arriving inline, can run + while that `leave()` is still unwinding on the pool thread. A `RemoteServer` + needs none of this, since a suspended handler keeps its server alive. +- **A handler awaiting its own model deadlocks**, as described under the + action gate. +- **Direct `Model::execute` calls** from outside the bridge — tests, replay + tooling — receive the Task itself and must drive it. +- **Synchronous dispatch.** `ActionDispatcher::dispatch`, which returns the + JSON result, cannot wait for a Task and throws `std::logic_error` for a Task + handler. `RemoteServer` asks `dispatchesAsync` which of the two an action + needs, and uses `dispatchAsync` for a Task handler. `journal::replay`, which + dispatches synchronously, therefore cannot replay an entry whose handler is + a Task handler; such an action is not replayable until replay is + asynchronous. + +## Cross-references + +- [`completion.md`](completion.md) — `Completion`, its handlers and the + client-side execute deadline. +- [`bridge.md`](bridge.md) — `executeVia` and `localOp`, where Task handlers + are driven for `LocalBackend`. +- [`backend.md`](backend.md) — `RemoteServer`'s dispatch and + `ExecuteOrderGate`. +- [`executor.md`](executor.md) — the strand and the executors a coroutine + resumes on. +- [`concurrency_and_lifetimes.md`](../concurrency_and_lifetimes.md) — the + destruction-order rules the driver follows. diff --git a/docs/spec/core/executor.md b/docs/spec/core/executor.md index 4d4e7d2c1..cad5f86f9 100644 --- a/docs/spec/core/executor.md +++ b/docs/spec/core/executor.md @@ -13,7 +13,7 @@ threading, and serialisation semantics differ per implementation. - [`ThreadPoolExecutor`](#threadpoolexecutor) - [`MainThreadExecutor`](#mainthreadexecutor) - [`QtExecutor`](#qtexecutor) -- [`StrandExecutor` and `ModelId`](#strandexecutor-and-modelid) +- [Strands: `ModelStrands` and `ModelId`](#strands-modelstrands-and-modelid) - [Lifetime & ownership](#lifetime--ownership) - [Thread safety](#thread-safety) - [Failure modes](#failure-modes) @@ -24,7 +24,7 @@ threading, and serialisation semantics differ per implementation. ## Type overview -There are seven types, split across `morph::exec` (in `executor.hpp`), +There are nine types, split across `morph::exec` (in `executor.hpp`), `morph::exec::detail` (in `strand.hpp`), and `morph::qt` (in `qt/qt_executor.hpp`): @@ -36,14 +36,16 @@ There are seven types, split across `morph::exec` (in `executor.hpp`), | `QtExecutor` | `morph::qt` | Posts tasks to a Qt event loop; they run on the configured context object's thread (the `QCoreApplication`/GUI thread by default). | | `ModelId` | `morph::exec::detail` | Opaque 64-bit identifier for a model instance, used as a strand key. | | `ModelIdHash` | `morph::exec::detail` | Hash functor so `ModelId` can be an `unordered_map` key. | -| `StrandExecutor` | `morph::exec::detail` | Per-key serialising wrapper — tasks with the same `ModelId` never overlap. | +| `ModelStrands` | `morph::exec::detail` | One strand per `ModelId` over an `IExecutor`: core-cpp's `core::async::KeyedStrands`, with what morph adds. Tasks with the same `ModelId` never overlap. | +| `CoreExecutorOver` | `morph::exec::detail` | A `core::async::IExecutor` over a morph `IExecutor`: how a core-cpp strand's pump reaches it. | +| `TaskResumer` | `morph::exec::detail` | A Task handler's resumer: the current executor while the handler runs, queuing its resumptions on its model's strand (see [`coroutines.md`](coroutines.md)). | `IExecutor` and the two thread-based concrete executors live in the public `morph::exec` namespace. `QtExecutor` lives in `morph::qt` (in the separate `qt/qt_executor.hpp` header) because it depends on Qt; only the GUI/bridge layer -pulls it in. `StrandExecutor`, `ModelId`, and `ModelIdHash` live in -`morph::exec::detail` because they are implementation details of the morph model -framework, not general-purpose utilities. +pulls it in. `ModelStrands`, `CoreExecutorOver`, `TaskResumer`, `ModelId` and +`ModelIdHash` live in `morph::exec::detail` because they are implementation +details of the morph model framework, not general-purpose utilities. ## `IExecutor` — the abstract interface @@ -64,15 +66,14 @@ FIFO order from a single mutex-protected queue. `n` is **clamped to a minimum of 1**. A pool with zero workers would accept posted tasks that no thread could ever run, so every `post()` would hang forever -and any `StrandExecutor` built on it would deadlock in its destructor waiting on -`_inFlight`. Passing `0` therefore yields a usable single-worker pool rather than +and any strands built on it would never drain. Passing `0` therefore yields a usable single-worker pool rather than a silently dead one; values `≥ 1` spawn exactly that many workers. The destructor signals stop, notifies all workers, and joins every thread. The workers **drain** the queue before exiting: once `_stop` is set the loop exits only when `_stop && _q.empty()`, so workers keep popping and running -already-queued tasks (including strand lambdas re-posted from within a running -task) until the queue is empty. The join therefore blocks until every task +already-queued tasks (including a strand's next turn, queued from within a +running task) until the queue is empty. The join therefore blocks until every task queued before destruction has run. The one thing not covered is a task `post()`ed concurrently with or after destruction: it races the last worker's exit and may be silently lost. Exceptions from tasks are caught in the worker @@ -190,204 +191,130 @@ pointer. There is **no** per-task implicit cancellation — callers that capture `QObject` must guard it themselves (e.g. `QPointer` or a liveness token) (see [Limitations](#limitations)). -## `StrandExecutor` and `ModelId` +## Strands: `ModelStrands` and `ModelId` -A per-key serialising executor built on top of any `IExecutor`. Tasks posted -with the same `ModelId` key execute in FIFO order with no overlap, even when the -underlying executor is a thread pool. Tasks with different keys may run -concurrently. +A strand per model instance over any `IExecutor`. Tasks posted with the same +`ModelId` key execute in FIFO order with no overlap, even when the underlying +executor is a thread pool. Tasks with different keys may run concurrently. `ModelId` is an opaque 64-bit identifier. Zero is reserved and means "not bound". Non-zero values are assigned by the backend and are stable for the lifetime of the model. It supports three-way comparison and can be used as an `unordered_map` key via `ModelIdHash`. -Internally `StrandExecutor` maintains a map of `ModelId → shared_ptr` -(shared state per key). A `Strand` holds a pointer to the base `IExecutor`, a -mutex, a pending queue, and a `running` flag. The executor also tracks an -`_inFlight` counter (guarded by the map mutex) that the destructor waits on, -and a single-slot `_spare` node handle (also guarded by the map mutex) that -recycles one detached map entry — see -[Lifetime & ownership](#lifetime--ownership). - -The pending queue is `StrandExecutor::PendingQueue`, not `std::queue`. It is a -FIFO with the head task stored **inside** the `Strand` and a lazily constructed -`std::deque` behind it, and it exists purely to make the common case cheaper: -because the drain step below detaches the whole map entry as soon as the queue -empties, a serial workload (one action at a time, each waited out) starts from -an empty queue on every dispatch and puts exactly one task in it — and -libstdc++'s -`std::deque` allocates its node map *and* a 512-byte first buffer in its -default constructor, whether or not anything is ever pushed. See -[Lifetime & ownership](#lifetime--ownership) below for the measurement. -`PendingQueue` does no locking of its own; every access is -under the owning `Strand::mtx`, exactly as the `std::queue` it replaced was, -and it tracks occupancy with a flag rather than by testing the callable, so an -empty `std::function` is queued and dispatched like any other. It changes no -lifetime or locking rule: the removal still fires when `empty()` becomes true, -still under the `{_mapMtx, strand->mtx}` pair. - -**`_inFlight` is incremented with the *decision* to dispatch, not lazily.** -`post()` increments `_inFlight` in the same `_mapMtx` critical section that flips -`running` true and decides to schedule, before releasing the lock; the re-arm -step in the strand task likewise increments under the `_mapMtx` it already holds, -before the current run's own decrement. This closes an internal window that would -otherwise exist if the increment were deferred to a later `_mapMtx` acquisition -in `scheduleNext`: between releasing `_mapMtx` in `post()` and re-taking it to -count the dispatch, `~StrandExecutor` could acquire `_mapMtx`, observe -`_inFlight == 0`, and destroy the map before the dispatched lambda touched it. -Because "decided to schedule" and "counted as in-flight" are now atomic under one -lock, and the re-arm's increment precedes the prior run's decrement, `_inFlight` -never dips to a spurious 0 across a scheduling hand-off. (This is distinct from -the caller-discipline rule below, which concerns a `post()` that genuinely -arrives after teardown has begun.) - -**The per-key serialisation invariant:** at most one live `Strand` exists per -`ModelId`, and any strand that is (or becomes) `running` is the strand currently -stored in the map for that key. This is what guarantees a key's tasks never -overlap — a single strand runs them one at a time. - -Two operations can violate that invariant if they interleave: `post()` pushing a -task and flipping `running` true, and `scheduleNext`'s drain step clearing -`running` and *erasing* the map entry when the queue empties. Holding the -combined `{_mapMtx, strand->mtx}` lock only inside the drain step is **not** -enough: the earlier design took `_mapMtx` in `post()` only long enough to look -the strand up, released it, and then re-armed the strand under `strand->mtx` -alone. A concurrent drain could erase the strand in that gap, orphaning a live -strand — and the next `post(key)` would then create a *second* strand for the -same key, so two strands ran the key's tasks concurrently. - -The fix makes **both** sides hold `_mapMtx` across their whole decision. `post()` -takes `_mapMtx`, does the slot lookup/create, and then — *still holding -`_mapMtx`* — takes `strand->mtx` to push the task and set `running`. The drain -step takes the same two locks in the same order. Because the map lookup, the -re-arm, and the erase are all serialised by `_mapMtx`, a strand that becomes -`running` in `post()` is guaranteed to still be the map entry, and the drain -never erases a strand whose `pending` queue is non-empty. The orphaning window is -gone. Lock order is always `_mapMtx` → `strand->mtx`; both sites acquire them as -two sequential `scoped_lock`s in that order (rather than one `scoped_lock` over -the pair, whose `std::lock` back-off can grab them in address order), so a single -consistent order holds everywhere and there is no lock-ordering deadlock. - -Each strand task is the point where the model's own code actually runs, so the -task wrapper catches exceptions and logs them via `morph::log::logError` (see -[Failure modes](#failure-modes)) before deciding whether to keep the strand -running. A throw therefore neither stalls the strand nor skips the drain-and-erase -bookkeeping: the next queued task for that key still runs. - -The destructor waits for all in-flight tasks to complete (`_inFlight == 0`) -before destroying the strand map. - -**Testing per-model ordering without naming `StrandExecutor`/`ModelId`.** -`RemoteServer` (see `backend.md`) owns a `StrandExecutor` internally, but every -task it ever dispatches — the top-level `handle()` post and the internal -per-model strand dispatch alike — funnels through the single `IExecutor` the -server was constructed with. A caller that wants a deterministic, hand-stepped -interleaving harness against `RemoteServer`'s real per-model ordering does not -need to touch `morph::exec::detail::StrandExecutor` or -`morph::exec::detail::ModelId` at all: constructing the server against a -single-step, test-controlled `IExecutor` (see `tests/test_support.hpp`'s -`morph::testing::StepExecutor`) and driving it one task at a time is enough — -`RemoteServer`'s own wire replies carry the model id as a plain `uint64_t` -(`wire::Envelope::modelId`), so a test never needs the `ModelId` vocabulary -either. +The strands are core-cpp's `core::async::KeyedStrands` +(``, since core-cpp 0.4.0), which was written after +the `StrandExecutor` morph had here and replaces it. What it guarantees, core-cpp +specifies and tests: + +- **One strand per key, made when the key gets work and retired when it runs + out,** under the registry's lock, so a post that races a retirement never + leaves two strands for one key. Up to 32 retired strands are kept, with their + pump frames, queue room and map nodes, for the next key that needs one: in the + steady state a post costs one allocation (the callable) and nothing else. A + kept strand and a kept map node each still hold the key they last served, so + up to 64 `ModelId`s outlive their work; a `ModelId` is eight bytes. +- **A pump per strand, a batch per turn.** A strand queues itself on the base + once however many tasks arrive while it is busy, runs at most + `StrandOptions::batch` (32) tasks per turn, and hands the base back. +- **A current executor per batch.** A strand states itself with + `core::async::ExecutorScope` while it runs, so `runningHere(key)` answers + inside its tasks, and an awaitable that resumes on the current executor + brings a coroutine back to it (see [`coroutines.md`](coroutines.md)). + +What `ModelStrands` adds: + +- **The base adapter.** `CoreExecutorOver` turns the morph `IExecutor` into a + `core::async::IExecutor`. A strand hands its base one bare coroutine handle + per turn, which the adapter posts as a lambda holding that handle alone: + trivially copyable, so it fits `std::function`'s small buffer and a turn costs + no allocation. The lambda does not refer to the adapter. +- **A throw is logged, not propagated.** `post(key, fn)` wraps `fn` in + `LoggedTask`, which catches what it throws and logs it: `std::exception` as + `"[strand] task threw: " + what()`, any other type as `"[strand] task threw + unknown exception"`. The next task for the key runs as usual. A core-cpp strand + would propagate the throw to whoever resumed its pump, and under MSVC's `cl` + end the process. +- **`runOnStrand(key, fn)`** runs `fn` at once when the calling thread is inside + a task of `key`'s strand, posts it there otherwise, and runs it at once when + the strands are closed. It is how a Task handler's end reaches its strand from + wherever the handler finished. +- **`drain()`** blocks until nothing is queued or running on any strand, + including work posted while it waits; not from one of the strands' own tasks, + which a debug build asserts. The single-threaded WebAssembly build has no + other thread to finish the work and allows no blocking wait, so there it + returns at once. +- **`close()`** closes every strand: queued work is dropped, a task running on + another thread is waited for, and a later `post` is dropped too. +- **`seal()`** (core-cpp 0.4.1) refuses the try-forms and keeps running what + is queued: `trySubmit` and `runOnStrand`'s post are refused, so their + callers run the work inline, while a plain `post` -- and a coroutine coming + back through a plain submit -- is still queued until the close. +- **`teardown(stopHandlers, order)`** is the whole sequence: stop the Task + handlers and seal, in `order`, then `drain()`, then `close()`. Where threads + exist the order is stop, `drain()`, seal, so a stopped handler still unwinds + on its strand, and a handler's end that arrives from a socket's loop while + its instance's tasks drain is queued behind them rather than run inline + beside one, which would enter the action gate on two threads at once. On + the single-threaded build it is seal then stop, so a stopped + handler's resumption is refused and runs inline in the stop, since nothing + else could run it. Once sealed, a resumption or a handler's end that arrives + -- between the drain and the close included -- runs inline where it arrives, + instead of reaching a strand the close would drop. +- **The Task handler's context.** `enroll(key, resumer)` installs a Task + handler's session and resumer around every coroutine resumed on `key`'s + strand until `withdraw(key, resumer)`, through the strands' keyed around-task + hook, which runs a posted callable bare (`RunTask::kind()`). The hook costs + one load per task while no handler is enrolled. + +`ModelStrands` is held by `std::shared_ptr`: a `TaskResumer` shares it, so a +handler suspended past its backend's destruction can still ask whether the +strands are closed. + +**Testing per-model ordering without naming `ModelStrands`/`ModelId`.** +`RemoteServer` (see `backend.md`) owns its strands internally, but every +task it ever dispatches — the top-level `handle()` post and every strand's turn +alike — funnels through the single `IExecutor` the server was constructed with. +A caller that wants a deterministic, hand-stepped interleaving harness against +`RemoteServer`'s real per-model ordering does not need to touch +`morph::exec::detail::ModelStrands` or `morph::exec::detail::ModelId` at all: +constructing the server against a single-step, test-controlled `IExecutor` (see +`tests/test_support.hpp`'s `morph::testing::StepExecutor`) and driving it one +task at a time is enough — `RemoteServer`'s own wire replies carry the model id +as a plain `uint64_t` (`wire::Envelope::modelId`), so a test never needs the +`ModelId` vocabulary either. ## Lifetime & ownership -`StrandExecutor` stores a raw pointer to the base `IExecutor` (`_base`, copied -into each `Strand::base`). It does **not** own the base and never extends its -lifetime. Two invariants make the arrangement safe, and violating either is a -latent bug: - -1. **The base `IExecutor` must outlive the `StrandExecutor`.** Every strand - dispatch calls `strand->base->post(...)`, and `~StrandExecutor` blocks until - the last of those dispatched lambdas has run. So the base must still be alive - for the whole life of the strand, *including* the destructor's wait. - -2. **The base must actually run — not lose — every task the strand posts.** - `~StrandExecutor` only returns once `_inFlight` reaches 0, and `_inFlight` is - decremented *inside* the dispatched lambda, after the task runs. `_inFlight` - is incremented on the strand thread *before* the lambda is handed to - `base->post()`. If a posted lambda never runs — because it was handed to a - pool that is already being destroyed or has already joined its workers — that - decrement never happens and the strand destructor waits forever. - -These two combine into the framework's most important ordering rule for these -types. `~ThreadPoolExecutor` **drains** its queue (workers run every -already-queued task before joining), whereas `~StrandExecutor` **blocks** until -`_inFlight == 0`. Draining is not enough to make arbitrary teardown order safe, -because the strand can still be *dispatching* while the pool tears down. -Therefore: - -> **Always destroy the `StrandExecutor` before the base pool it wraps.** - -If the base `ThreadPoolExecutor` is destroyed first, two things go wrong. A -strand lambda still in flight may call `base->post()` on a pool whose destructor -has run — undefined behaviour (use-after-free on the pool's queue/mutex). Even -absent UB, a lambda posted after the pool's workers have already observed -`_stop && _q.empty()` and exited is never run, so its `--_inFlight` never -happens and the subsequent `~StrandExecutor` deadlocks on its condition variable -forever. With member declaration order this means the pool must be declared -*before* the strand (members destroy in reverse order), or the two must be torn -down explicitly in that order. - -A second rule follows from the same wait: **no `post()` may race with or follow -`~StrandExecutor`.** The destructor takes `_mapMtx` and waits for -`_inFlight == 0`, but it does not block new `post()` calls. A `post()` that -arrives concurrently with (or after) destruction can enqueue work and re-arm a -strand after the destructor believed it had quiesced, reintroducing exactly the -data race the `_inFlight` wait exists to prevent. Callers must ensure all task -sources are shut down before the `StrandExecutor` is destroyed. - -The strand map is self-cleaning: when a strand drains (its `pending` queue is -empty), `scheduleNext` clears `running` and removes the map entry under the -combined `{_mapMtx, strand->mtx}` lock. Live memory therefore tracks the set of -*currently active* models rather than every model ever seen — there is no -per-model registration to leak. - -**The cost it would otherwise carry is allocation churn.** A model posted to -serially — one action at a time, each waited out — never has a task queued at -the instant the previous one finishes, so it never keeps a strand: every -dispatch misses in the map, and a naive implementation rebuilds the map node -and the `Strand` each time. Measured with -`tests/bench/bench_dispatch_allocations.cpp` (see -[testing_strategy.md](../testing_strategy.md)), x86-64 Linux, GCC 16.2.1 / -libstdc++, `-O2`, that comes to **4 allocations and 760 of the 1990 bytes** a -local `execute` round trip costs — 38% of the bytes, for a strand that is -rebuilt and thrown away. 576 of those bytes are not the strand at all but -`std::queue`'s `std::deque` eagerly allocating a node map and a 512-byte first -buffer in its default constructor, which is why the pending queue is -`PendingQueue`, holding the head task inline: that alone cuts the strand's -share to **2 allocations and 152 bytes** and the whole round trip to 18.9 -allocations / 1396 bytes. - -**The remaining two allocations — the map node and the `Strand` itself — are -recycled rather than removed.** Removing them means keeping the slot alive -across the drain, which trades the churn for a per-model entry nothing -reclaims, since `StrandExecutor` has no deregistration hook. Recycling avoids -that trade: the entry leaves the map at exactly the same moment, under exactly -the same locks; the drain calls `extract` instead of `erase` and parks the -detached node in a single-slot `_spare` member, and the next `post()` that -misses re-keys that node and inserts it back. The map stays bounded by the -removal — `_spare` holds **at most one** node, is guarded by `_mapMtx` like the -map itself, and is freed with the executor. - -Reusing the parked node's `Strand` object is guarded additionally by -`use_count() == 1`: the recycled node is then the only owner, so no strand task -can still reach the object and reusing it is indistinguishable from -constructing a new one. When that guard fails — a finishing strand lambda still -holds its `shared_ptr` when the next `post()` looks — a fresh `Strand` is -constructed and only the node is recycled. To make the guard -usually hold, the strand lambda drops its `shared_ptr` immediately after the -drain block rather than at its own destruction; nothing after that point -touches the strand. That timing affects *whether* the object is recycled, never -whether the recycling is safe. - -Measured with the same instrument, x86-64 Linux, **clang 22.1.8 / libstdc++ -16.2.1, Release**: recycling takes the round trip from **18.90 allocations / -1394.8 bytes** to **16.95 / 1244.6** — the full 2 allocations and ~150 bytes -the strand had left. Six alternating runs of each binary; spread within 0.1 -allocations and 2 bytes per call. The magnitude is libstdc++-specific. +`ModelStrands` holds its base `IExecutor` by reference (inside +`CoreExecutorOver`) and does not own it. Two rules follow: + +1. **The base `IExecutor` must outlive the strands and keep running tasks until + `drain()` has returned.** Every strand's turn is posted to it, and `drain()` + waits for the turns that are queued. A pool destroyed first would drop them, + and the drain would wait forever. +2. **What the strands posted must still run, or be dropped by the base.** A + strand's pump that the base runs after the strands were closed finds its + strand closed and ends. One the base drops unrun (a `MainThreadExecutor` + destroyed with its queue) leaks that strand's state: close the strands, and + pump the base, before destroying it. + +Destroying the strands, or calling `close()`, drops what is still queued and +waits for a task running on another thread. It does not wait for the task it is +called from: a task may release the last reference to the strands' owner, as a +`RemoteServer`'s may. A backend whose queued work must run tears its strands +down with `teardown()` instead: `~LocalBackend` and `~SynchronousBackendAdapter` +do. + +> **Destroy the backend before the base pool it runs on.** + +With member declaration order this means the pool must be declared *before* +the backend (members destroy in reverse order), or the two must be torn down +explicitly in that order. + +The strand registry is self-cleaning: a key's strand is retired when its queue +runs out, so live strands track the model instances with work, not every model +ever seen. Retired strands kept for reuse are bounded at 32. ## Thread safety @@ -401,20 +328,11 @@ concurrently. - `MainThreadExecutor` guards its queue with `_m`. `post()` may be called from any thread, but `runFor()` must be called only from the single owning ("main") thread; concurrent `runFor()` calls are not supported. -- `StrandExecutor` uses two lock levels: `_mapMtx` protects the `_strands` map, - the `_spare` recycled node and the `_inFlight` counter, and each - `Strand::mtx` protects that strand's - `pending` queue and `running` flag. Both operations that can break the - per-key invariant hold `_mapMtx` across their whole decision: `post()` takes - `_mapMtx`, does the slot lookup/create, and then — still holding `_mapMtx` — - takes `strand->mtx` to push and re-arm; the drain-and-erase step in - `scheduleNext` takes the same two locks in the same order. This serialises the - lookup, the re-arm, and the erase, so a concurrent `post()` can no longer - re-arm a strand *after* a drain has erased it (which would orphan a live - strand and let two strands for one key run concurrently). Lock order is always - `_mapMtx` → `strand->mtx`, acquired as two sequential `scoped_lock`s (not one - `scoped_lock` over the pair) so a single consistent order holds at every site - and there is no lock-ordering deadlock. The net guarantee: tasks with the same +- `ModelStrands`' members are callable from any thread. Its locking is + core-cpp's: the registry's lock is taken before any strand's own, so a post + and a retirement for one key are serialised (see `KeyedStrands.hpp`). The + enrolled-handler table has a mutex of its own, taken by the around-task hook + only while a handler is enrolled. The net guarantee: tasks with the same `ModelId` never overlap; tasks with different keys may run in parallel on the base pool. - `QtExecutor` holds only a `QObject*` context pointer; its thread safety is @@ -428,14 +346,14 @@ concurrently. | Executor | What happens when a task throws | |---|---| | `ThreadPoolExecutor` | The worker `loop` catches it. `std::exception` is logged as `"[thread-pool] task threw: " + what()`; any other type is logged as `"[thread-pool] task threw unknown exception"`. The worker keeps looping. | -| `StrandExecutor` | The strand task wrapper catches it. `std::exception` is logged as `"[strand] task threw: " + what()`; any other type is logged as `"[strand] task threw unknown exception"`. The strand's drain/erase bookkeeping and `_inFlight` decrement still run, so the next task for the key proceeds. | +| `ModelStrands` | `LoggedTask`, around every posted callable, catches it. `std::exception` is logged as `"[strand] task threw: " + what()`; any other type is logged as `"[strand] task threw unknown exception"`. The next task for the key proceeds. | | `MainThreadExecutor` | `runFor` catches **only** `std::exception`, logged as `"[main-thread] callback threw: " + what()`, then continues with the next task. **Any non-`std::exception` type propagates out of `runFor()`** and is the caller's problem. | | `QtExecutor` | No `try`/`catch` of its own. A throwing task propagates into whoever drives the target thread's event loop (`QCoreApplication::exec` by default, or the worker thread's loop for a custom context); Qt's default behaviour is to `std::terminate`. Tasks posted through it must not let exceptions escape. | All logging goes through `morph::log::logError`. The design principle: a task failure must never kill a worker/strand or abort sibling tasks, but it must also never be *invisible*. Previously these exceptions were swallowed silently; they -are now logged. `ThreadPoolExecutor` and `StrandExecutor` catch `(...)` and so +are now logged. `ThreadPoolExecutor` and `ModelStrands` catch `(...)` and so contain every exception type; `MainThreadExecutor` deliberately narrows its `catch` to `std::exception` (a non-standard throw surfaces on the drain thread rather than being hidden). @@ -486,27 +404,36 @@ rather than being hidden). |---|---|---| | `operator()` | `std::size_t operator()(ModelId mid) const noexcept` | Hashes `mid.v`. | -### `StrandExecutor` (`morph::exec::detail`) +### `ModelStrands` (`morph::exec::detail`) | Member | Signature | Notes | |---|---|---| -| ctor | `explicit StrandExecutor(IExecutor& base)` | Wraps `base`. | -| dtor | `~StrandExecutor()` | Blocks until `_inFlight == 0`. Requires the base to outlive it and to run every posted task — otherwise deadlocks (see Lifetime & ownership). | -| `post` | `void post(ModelId key, std::function task)` | Enqueues for strand `key`. FIFO per key, concurrent across keys. Thread-safe. Task exceptions caught and logged. Must not race/follow the destructor. | +| ctor | `explicit ModelStrands(IExecutor& base, core::async::StrandOptions options = {})` | Strands over `base`. `options.aroundTask` must be unset. | +| dtor | `~ModelStrands()` | Closes the strands: drops what is queued, waits for a task running on another thread. | +| `post` | `template void post(ModelId key, F&& task)` | Queues on `key`'s strand. FIFO per key, concurrent across keys. Thread-safe. One allocation; the throw is logged. Dropped once closed. | +| `runOnStrand` | `template void runOnStrand(ModelId key, F task)` | Runs here on `key`'s strand, posts off it, runs here once closed. | +| `trySubmit` | `bool trySubmit(ModelId key, std::coroutine_handle<> handle)` | Queues a resumption unless closed. | +| `runningHere` / `runningAnyHere` | `bool runningHere(ModelId key) const noexcept` / `bool runningAnyHere() const noexcept` | Whether the calling thread is inside a task of `key`'s strand / of any. | +| `idle` | `bool idle() const` | Nothing queued or running. | +| `drain` | `void drain()` | Blocks until idle, where threads exist. Not from a task of these strands. | +| `close` | `void close()` | As the destructor. Idempotent. | +| `seal` | `void seal()` | Refuses `trySubmit` and `runOnStrand`'s post; queued work still runs, and `post` is still admitted. Idempotent. | +| `teardown` | `template void teardown(Stop&& stopHandlers, TeardownOrder order = buildTeardownOrder)` | Stop and seal in `order`, then `drain`, then `close`. | +| `enroll` / `withdraw` | `void enroll(ModelId key, const std::shared_ptr&)` / `void withdraw(ModelId key, const TaskResumer*)` | Install / remove a Task handler's session and resumer around the coroutines resumed on `key`'s strand. | ## Design decisions | Decision | Choice | Why | |---|---|---| | Task signature | `std::function` | Simple, universal. Every executor accepts the same callable type. No return value, no cancellation. | -| Exception handling | **Caught and logged, never propagated out of a worker/strand** | A task failure must not crash unrelated tasks *or* vanish. `ThreadPoolExecutor` and `StrandExecutor` catch `(...)` and log via `morph::log::logError`; `MainThreadExecutor` narrows its catch to `std::exception` so a non-standard throw surfaces on the synchronous drain thread. See [Failure modes](#failure-modes). | -| ThreadPoolExecutor drain-on-dtor | **Drain the queue, then join** | Workers run every already-queued task before exiting, so a `StrandExecutor`'s in-flight lambdas complete and decrement `_inFlight` as long as the pool outlives the strand. There is no public `waitIdle`/graceful-shutdown API; tasks posted after destruction begins may be lost, so the caller must still synchronise teardown order externally. | +| Exception handling | **Caught and logged, never propagated out of a worker/strand** | A task failure must not crash unrelated tasks *or* vanish. `ThreadPoolExecutor` and `ModelStrands` catch `(...)` and log via `morph::log::logError`; `MainThreadExecutor` narrows its catch to `std::exception` so a non-standard throw surfaces on the synchronous drain thread. See [Failure modes](#failure-modes). | +| ThreadPoolExecutor drain-on-dtor | **Drain the queue, then join** | Workers run every already-queued task before exiting, so a strand's queued turn still runs, and finds its strand closed, as long as the pool outlives the strands. There is no public `waitIdle`/graceful-shutdown API; tasks posted after destruction begins may be lost, so the caller must still synchronise teardown order externally. | | MainThreadExecutor's `runFor` | **Wall-clock deadline** | Lets the caller batch-process tasks without spinning. The condition-variable wait avoids busy-waiting. | | MainThreadExecutor's `runOnce`/`drain` | **Thin wrappers sharing `runFor`'s dequeue-and-invoke step, added alongside it** | `runOnce()` steps exactly one task without blocking; `drain()` loops `runOnce()` until the queue is empty. Neither waits on new tasks from other threads, unlike `runFor()`'s deadline-scoped wait — this gives event-loop integrations and tests deterministic, non-blocking single-step control without replacing `runFor()`'s existing behavior. | | ModelId zero | **Reserved — "not bound"** | A natural sentinel for optional/uninitialised model handles. | -| StrandExecutor in `detail` | **Not a general-purpose utility** | Exists only for the morph model framework's per-model serialisation. The `ModelId` key is specific to model instances. | -| StrandExecutor destructor | **Waits for in-flight tasks** | Without this, a pool thread running `scheduleNext` can access `_strands` after it has been destroyed (TSan: data race on destructor vs erase). | -| Strand per-key invariant | **`post()` *and* drain-and-erase both hold `_mapMtx` across their whole decision** | Serialises lookup, re-arm, and erase so at most one live strand exists per key and any `running` strand is the map's current entry. Holding the combined lock only in the drain step was insufficient — `post()` re-armed under `strand->mtx` alone after releasing `_mapMtx`, so a drain could erase the strand in that gap, orphan it, and let a second strand for the same key run concurrently. | +| The strand | **core-cpp's `KeyedStrands`**, not morph's own | morph's `StrandExecutor` became core-cpp's `Strand` and `KeyedStrands` in 0.4.0, which the other Contour Terminal projects share; morph keeps only what is morph's: the adapter, the throw policy and the Task handler's context. Its two fixed races (a post racing the drain, a recycled strand under the wrong key) are core-cpp's to keep fixed now, in its own race tests. | +| `ModelStrands` in `detail` | **Not a general-purpose utility** | Exists only for the morph model framework's per-model serialisation. The `ModelId` key is specific to model instances. | +| Closing drops, `drain` waits | **Seal, drain, close, in `teardown`** | core-cpp's strands drop queued work when closed, so that a strand can be destroyed from one of its own tasks. A backend whose queued work must run seals first, so that nothing arriving after the drain is queued only to be dropped, and drains before it closes. | | No `std::future` / return value | **Fire-and-forget only** | Executors schedule side-effect tasks. Callers that need results use shared state or futures externally. | | No `std::executor` conformance | **Custom interface, not `std::executor`** | C++26 `std::executor` is not yet widely available. This is a minimal in-house abstraction. | | `QtExecutor` via `invokeMethod`, not a `QObject` subclass | **Near-stateless free-standing `IExecutor`, target configurable via ctor** | Uses `QMetaObject::invokeMethod(context, fn, Qt::QueuedConnection)`, so callers need no custom `QObject`, event type, or slot — they only optionally supply a `QObject*` to pick the target thread. Defaults to `QCoreApplication::instance()` so existing GUI-thread call sites are unaffected. Keeps the type a drop-in `IExecutor` holding a single pointer, with Qt's event loop as the sole dispatcher. | @@ -517,7 +444,7 @@ rather than being hidden). These are honest, known gaps — accepted trade-offs, not bugs: - **Unbounded queues / no backpressure.** `ThreadPoolExecutor`, `MainThreadExecutor`, - and each `Strand::pending` are all unbounded `std::queue`s. A producer that + and each strand's queue are all unbounded. A producer that outruns consumption grows memory without limit; `post()` never blocks or rejects. There is no bounded-queue option, no high-water mark, and no way for a caller to learn the queue is backing up. @@ -533,20 +460,15 @@ These are honest, known gaps — accepted trade-offs, not bugs: drains already-queued tasks but there is no method to wait until the queue is empty, to flush pending work before shutdown, or to reject work posted during shutdown (such a task may be lost). Callers who need to coordinate around - in-flight work must synchronise externally. (`StrandExecutor` waits for - `_inFlight`, but that is a lifetime-safety wait, not a general drain API, and it - relies on the base pool still running the strand's dispatched lambdas.) -- **Strand allocation churn.** The self-cleaning map (see - [Lifetime & ownership](#lifetime--ownership)) is good for memory — live entries - track active models — but a bursty model re-allocates a `Strand` every time its - queue empties and refills, instead of reusing one long-lived strand per key. + in-flight work must synchronise externally. (`ModelStrands::drain` waits for + the strands, but it relies on the base pool still running their turns.) ## Lifetime annotations -`StrandExecutor`'s constructor marks its `IExecutor& base` `MORPH_LIFETIMEBOUND` -(`morph/attributes.hpp`), so Clang diagnoses a call site that hands it a base -executor which does not outlive the strand — the deadlock described above, caught -at compile time instead of at teardown. See [concurrency_and_lifetimes.md](../concurrency_and_lifetimes.md#morph_lifetimebound--the-must-outlive-rules-told-to-the-compiler). +`ModelStrands`' and `CoreExecutorOver`'s constructors mark their +`IExecutor& base` `MORPH_LIFETIMEBOUND` (`morph/attributes.hpp`), so Clang +diagnoses a call site that hands one a base executor which does not outlive it — +the hang described above, caught at compile time instead of at teardown. See [concurrency_and_lifetimes.md](../concurrency_and_lifetimes.md#morph_lifetimebound--the-must-outlive-rules-told-to-the-compiler). ## Cross-references @@ -557,7 +479,7 @@ at compile time instead of at teardown. See [concurrency_and_lifetimes.md](../co per-task logging here plugs into (currently also summarised under *Error propagation* in `../../ARCHITECTURE.md`). - `concurrency_and_lifetimes.md` — the broader threading and teardown-ordering - model; the "destroy strand before base pool" rule above is a concrete instance + model; the "destroy the backend before the base pool" rule above is a concrete instance of it (see also *Thread safety* in `../../ARCHITECTURE.md`). - [`bridge.md`](bridge.md) — the bridge wires backends to a GUI executor and a strand-backed dispatcher; it is the primary consumer of these types. diff --git a/docs/spec/core/observability.md b/docs/spec/core/observability.md index 6544ff346..85059ffee 100644 --- a/docs/spec/core/observability.md +++ b/docs/spec/core/observability.md @@ -154,8 +154,8 @@ code called from the framework's hot paths: / `endSpan` and settles the caller's `Completion` *after* them, precisely so a completion callback cannot observe the dispatch as finished before its metrics land. An exception escaping instrumentation would therefore skip - `setValue`/`setException` entirely and be swallowed by `StrandExecutor`'s - catch-and-log, leaving that `Completion` unsettled forever — a hung caller + `setValue`/`setException` entirely and be swallowed by the strand's + catch-and-log (`LoggedTask`), leaving that `Completion` unsettled forever — a hung caller with neither a value nor an error, caused by a bug in a metrics callback. A sink that throws is otherwise ignored (a failed `beginSpan` degrades to the @@ -173,8 +173,7 @@ concept. `LocalBackend`'s in-flight counter is a `std::shared_ptr>` rather than a plain member: its strand-posted tasks capture a copy of the `shared_ptr`, never `this`, so the counter stays valid even if the backend is destroyed while a task is still -queued or running (see [backend.md](backend.md)'s Lifetime & ownership and -the `~StrandExecutor` note below). +queued or running (see [backend.md](backend.md)'s Lifetime & ownership). ## API reference diff --git a/docs/spec/error_handling.md b/docs/spec/error_handling.md index f8255b259..234442939 100644 --- a/docs/spec/error_handling.md +++ b/docs/spec/error_handling.md @@ -142,12 +142,12 @@ rather than swallow. | Executor | Catch behavior | Log prefix | |---|---|---| | `ThreadPoolExecutor` | Catches `std::exception` and `...` per task; worker loops on | `[thread-pool] task threw: ` / `[thread-pool] task threw unknown exception` | -| `StrandExecutor` | Catches `std::exception` and `...` per task; next queued task for the same key still runs | `[strand] task threw: ` / `[strand] task threw unknown exception` | +| `ModelStrands` | `LoggedTask` catches `std::exception` and `...` around every posted callable; next queued task for the same key still runs | `[strand] task threw: ` / `[strand] task threw unknown exception` | | `MainThreadExecutor::runFor` | Catches **only** `std::exception`; continues with the next task | `[main-thread] callback threw: ` | Notes that matter: -- The `StrandExecutor` is where `Model::execute` actually runs (for both +- A model instance's strand is where `Model::execute` actually runs (for both `LocalBackend` and `RemoteServer`). In normal operation the backend's own `try/catch` converts a throwing `execute` into `setException` **before** the strand's catch could see it, so `[strand] task threw:` fires only for diff --git a/docs/spec/security.md b/docs/spec/security.md index dffda56ac..36bbe7b15 100644 --- a/docs/spec/security.md +++ b/docs/spec/security.md @@ -338,7 +338,11 @@ testable without wall-clock dependence. #### Canonical base64url — no signature malleability `detail::base64UrlDecode` decodes **canonically**: it is a bijection over valid -tokens, so exactly one token string maps to any given byte sequence. base64url +tokens, so exactly one token string maps to any given byte sequence. It is +`session_auth.hpp`'s own, and deliberately not core-cpp's `core::base64`, which +`morph::net`'s WebSocket handshake uses: the handshake only encodes, with the +standard alphabet, whereas a token decoder must refuse every non-canonical +input below, a promise `core::base64::decode` does not make. base64url is a *bit*-oriented encoding, and a naive decoder that silently discards the leftover bits of the final symbol would let several distinct strings decode to the same MAC — a token-string malleability that lets an attacker perturb the diff --git a/examples/TESTING.md b/examples/TESTING.md index e3f807901..b45fa5479 100644 --- a/examples/TESTING.md +++ b/examples/TESTING.md @@ -665,7 +665,7 @@ root `CMakeLists.txt` — don't repeat that eight times): target that reaches a rung's models or tests carries this guard, so a `--preset clang-tsan` configure of the ladder actually instruments the code it builds (`.github/workflows/ci.yml`'s `kanban-tsan` job is the - first CI leg that exercises this). Lightweight's `FetchContent` + first CI leg that exercises this). Lightweight's CPM acquisition is hoisted once into `examples/common`, not repeated per rung. One trap when implementing it: `catch_discover_tests` cannot carry a **multi-value** `LABELS`. It forwards `PROPERTIES` as a flat list diff --git a/examples/bank/CMakeLists.txt b/examples/bank/CMakeLists.txt index c51fae737..a281838e2 100644 --- a/examples/bank/CMakeLists.txt +++ b/examples/bank/CMakeLists.txt @@ -28,7 +28,6 @@ if(EMSCRIPTEN) return() endif() -include(FetchContent) # ── Lightweight ORM (SQLite via ODBC) ──────────────────────────────────────── # Lightweight resolves reflection-cpp / stdexec via CPM and finds yaml-cpp / @@ -38,13 +37,9 @@ set(LIGHTWEIGHT_BUILD_EXAMPLES OFF CACHE BOOL "" FORCE) set(LIGHTWEIGHT_BUILD_TOOLS OFF CACHE BOOL "" FORCE) set(LIGHTWEIGHT_BUILD_BENCHMARK OFF CACHE BOOL "" FORCE) -include(${CMAKE_SOURCE_DIR}/cmake/DepCache.cmake) # Kept in sync with examples/common/CMakeLists.txt's identical pin -- see that -# file's comment for why this is a commit SHA, not a tag, and why GIT_SHALLOW -# is FALSE. -morph_declare_dep(Lightweight https://github.com/LASTRADA-Software/Lightweight.git - bbb972a78e1962b968a2c6ad93f7dade736eaa01 - GIT_SHALLOW FALSE) +# file's comment for why this is a commit SHA, not a tag. + # Lightweight's own install() rules unconditionally reference # $ on WIN32 (its CMakeLists.txt), which CMake # only allows for linker-created artifacts (DLL/EXE) -- invalid whenever @@ -52,13 +47,16 @@ morph_declare_dep(Lightweight https://github.com/LASTRADA-Software/Lightweight.g # forced by examples/common/CMakeLists.txt when it fetches Lightweight # first in the same configure), and it fails at generate time even though # nothing in this tree ever runs `cmake --install`. Skipping install-rule -# generation for just this FetchContent_MakeAvailable call sidesteps the +# generation for just this CPMAddPackage call sidesteps the # bad generator expression without touching Lightweight's vendored # CMakeLists.txt -- same fix as examples/common/CMakeLists.txt's own # Lightweight fetch. set(_morph_saved_skip_install_rules ${CMAKE_SKIP_INSTALL_RULES}) set(CMAKE_SKIP_INSTALL_RULES ON) -FetchContent_MakeAvailable(Lightweight) +CPMAddPackage( + NAME Lightweight + GITHUB_REPOSITORY LASTRADA-Software/Lightweight + GIT_TAG bbb972a78e1962b968a2c6ad93f7dade736eaa01) set(CMAKE_SKIP_INSTALL_RULES ${_morph_saved_skip_install_rules}) unset(_morph_saved_skip_install_rules) diff --git a/examples/bank/include/bank/models/budget_model.hpp b/examples/bank/include/bank/models/budget_model.hpp index 741e8b14c..44ea1d508 100644 --- a/examples/bank/include/bank/models/budget_model.hpp +++ b/examples/bank/include/bank/models/budget_model.hpp @@ -1,6 +1,7 @@ // SPDX-License-Identifier: Apache-2.0 #pragma once +#include #include #include @@ -20,7 +21,10 @@ class BudgetModel : private db::WithMapper { dto::BudgetInfo execute(const dto::SetBudget& action); dto::CommandResult execute(const dto::DeleteBudget& action); dto::BudgetList execute(const dto::ListBudgets& action); - dto::SpendingReport execute(const dto::SpendingByKind& action); + /// A coroutine handler (docs/spec/core/coroutines.md): the bridge drives it + /// on this model's strand. It does not suspend today; a report that awaited + /// another model's execute would, with no change to its callers. + core::async::Task execute(dto::SpendingByKind action); }; } // namespace bank diff --git a/examples/bank/src/models/budget_model.cpp b/examples/bank/src/models/budget_model.cpp index 965bd6095..52fff23cb 100644 --- a/examples/bank/src/models/budget_model.cpp +++ b/examples/bank/src/models/budget_model.cpp @@ -84,7 +84,7 @@ dto::BudgetList BudgetModel::execute(const dto::ListBudgets& action) { return out; } -dto::SpendingReport BudgetModel::execute(const dto::SpendingByKind& action) { +core::async::Task BudgetModel::execute(dto::SpendingByKind action) { // This action is addressed by account id and carries no `owner` field, so // `resolveOwner` never sees it and cannot be what scopes it. `db::loadOwned` // is: it navigates the row to its owner and compares that with the session @@ -122,7 +122,7 @@ dto::SpendingReport BudgetModel::execute(const dto::SpendingByKind& action) { for (const auto& [kind, spend] : byKind) { report.byKind.push_back(spend); } - return report; + co_return report; } } // namespace bank diff --git a/examples/common/CMakeLists.txt b/examples/common/CMakeLists.txt index 75e6c48da..3de1b3fc0 100644 --- a/examples/common/CMakeLists.txt +++ b/examples/common/CMakeLists.txt @@ -117,7 +117,6 @@ if(NOT MORPH_BUILD_TESTS) endif() # ── Lightweight ORM (hoisted here once; TESTING.md "Build system and CI") ─── -include(FetchContent) set(LIGHTWEIGHT_BUILD_TESTS OFF CACHE BOOL "" FORCE) set(LIGHTWEIGHT_BUILD_EXAMPLES OFF CACHE BOOL "" FORCE) set(LIGHTWEIGHT_BUILD_TOOLS OFF CACHE BOOL "" FORCE) @@ -130,28 +129,28 @@ set(LIGHTWEIGHT_BUILD_BENCHMARK OFF CACHE BOOL "" FORCE) # the DLL export boundary (and its warnings) entirely instead of punching warning # holes through every consumer target. set(LIGHTWEIGHT_BUILD_SHARED OFF CACHE BOOL "" FORCE) -include(${CMAKE_SOURCE_DIR}/cmake/DepCache.cmake) # Pinned to master's tip commit, not the latest tag (v0.20260625.0) -- # LASTRADA-Software/Lightweight#551 (BelongsTo's silent-modification-loss bug # this rung's MoveTaskPosition worked around) merged to master after that tag # was cut, with no newer tag since. Bump this SHA when a new tag lands. # -# GIT_SHALLOW FALSE because that pin is a commit SHA: a shallow clone fetches -# only the default branch's tip and cannot check one out. -morph_declare_dep(Lightweight https://github.com/LASTRADA-Software/Lightweight.git - bbb972a78e1962b968a2c6ad93f7dade736eaa01 - GIT_SHALLOW FALSE) +# CPM clones a commit-SHA pin in full (a shallow clone fetches only the +# default branch's tip and cannot check one out); it shallow-clones only tags. + # Lightweight's own install() rules unconditionally reference # $ on WIN32 (its CMakeLists.txt), which CMake # only allows for linker-created artifacts (DLL/EXE) -- invalid for the # static build LIGHTWEIGHT_BUILD_SHARED=OFF above now produces, and it fails # at generate time even though nothing in this tree ever runs `cmake # --install`. Skipping install-rule generation for just this -# FetchContent_MakeAvailable call sidesteps the bad generator expression +# CPMAddPackage call sidesteps the bad generator expression # without touching Lightweight's vendored CMakeLists.txt. set(_morph_saved_skip_install_rules ${CMAKE_SKIP_INSTALL_RULES}) set(CMAKE_SKIP_INSTALL_RULES ON) -FetchContent_MakeAvailable(Lightweight) +CPMAddPackage( + NAME Lightweight + GITHUB_REPOSITORY LASTRADA-Software/Lightweight + GIT_TAG bbb972a78e1962b968a2c6ad93f7dade736eaa01) set(CMAKE_SKIP_INSTALL_RULES ${_morph_saved_skip_install_rules}) unset(_morph_saved_skip_install_rules) diff --git a/examples/common/gui/event_poller.hpp b/examples/common/gui/event_poller.hpp index 8fcb40073..bbe0ad24c 100644 --- a/examples/common/gui/event_poller.hpp +++ b/examples/common/gui/event_poller.hpp @@ -107,14 +107,12 @@ /// constructs a `morph::async::detail::TimeoutScheduler`, which used to /// unconditionally spawn a `std::thread` — impossible in the /// `wasm_singlethread` Qt build this ladder's WASM clients are compiled -/// against. `timeout_scheduler.hpp` now selects a browser-timer -/// (`emscripten_async_call`) build of itself under -/// `__EMSCRIPTEN__ && !__EMSCRIPTEN_PTHREADS__`, so this constructor is -/// safe from a browser tab and deadlines still fire — see that file's -/// `@file` comment and `docs/spec/core/completion.md`. Neither the fix nor -/// the original hazard has been observed on a real Emscripten build; no -/// toolchain for one exists in this repository (the `ladder-wasm` CI job is -/// a compile gate). +/// against. Under `__EMSCRIPTEN__ && !__EMSCRIPTEN_PTHREADS__` the +/// scheduler starts no thread: its event loop is host-driven, pumped by the +/// browser's own timer, so this constructor is safe from a browser tab and +/// deadlines still fire — see `timeout_scheduler.hpp`'s `@file` comment and +/// `docs/spec/core/completion.md`. The `wasm-ladder` CI job compiles and +/// links that build; nothing in this repository runs it. /// /// @par Default poll interval and its trade-off /// `kDefaultInterval` is 3 seconds. This is this class's answer to the diff --git a/examples/common/gui/presenter.hpp b/examples/common/gui/presenter.hpp index 9b56b7ad1..6611a5d87 100644 --- a/examples/common/gui/presenter.hpp +++ b/examples/common/gui/presenter.hpp @@ -8,6 +8,13 @@ #include #include +// moc reads only the Q_OBJECT declarations below; the coroutine machinery is +// for the compiler. +#ifndef Q_MOC_RUN +#include +#include +#endif + /// @file /// Shared presenter base (examples/TESTING.md, "Presenter architecture" rule /// 3): "Observable quiescence." Every ladder presenter derives from this so @@ -176,7 +183,39 @@ class Presenter : public QObject { }); } + /// @brief Runs @p flow -- a coroutine that `co_await`s completions -- on + /// @p executor, counted in `busy()` until it finishes. + /// + /// The coroutine counterpart of `track()`: started with + /// `morph::async::spawn`, so every step of @p flow runs on @p executor, + /// whichever thread the completions it awaits settle on. A flow that may + /// outlive this presenter checks a `QPointer` after each `co_await`, as + /// `track()`'s handlers do. See docs/spec/core/coroutines.md. + /// @param executor Where the flow runs: the executor the presenter's + /// completions deliver on. + /// @param flow The coroutine to run. + void trackFlow(::morph::exec::IExecutor& executor, ::core::async::Task flow) { + _inFlight.fetch_add(1); + ::morph::async::spawn(executor, finishAfter(QPointer{this}, std::move(flow))); + } + private: + /// @brief Awaits @p flow, then counts it finished, whether it returned or threw. + static ::core::async::Task finishAfter(QPointer self, ::core::async::Task flow) { + std::exception_ptr escaped; + try { + co_await std::move(flow); + } catch (...) { + escaped = std::current_exception(); + } + if (!self.isNull()) { + self->finishOne(); + } + if (escaped) { + std::rethrow_exception(escaped); + } + } + void finishOne() { if (_inFlight.fetch_sub(1) == 1) { emit idle(); diff --git a/examples/common/testkit/step_executor.hpp b/examples/common/testkit/step_executor.hpp index 68fd4178f..81481c42d 100644 --- a/examples/common/testkit/step_executor.hpp +++ b/examples/common/testkit/step_executor.hpp @@ -31,7 +31,7 @@ namespace morph::ladder::testkit { /// the test explicitly asks, one at a time -- never on its own thread. /// /// Where `DeterministicExecutor` (strand_interleaver.hpp) sits *underneath* a -/// `StrandExecutor` to control the delivery order of continuations, this sits +/// `ModelStrands` to control the delivery order of continuations, this sits /// where a production `ThreadPoolExecutor` would: it is the worker. Injected /// as the executor a model or App posts background work to, it turns /// "eventually the job finishes" into a sequence of exact, assertable states: diff --git a/examples/common/testkit/strand_interleaver.hpp b/examples/common/testkit/strand_interleaver.hpp index 480c358e9..438b2aa9d 100644 --- a/examples/common/testkit/strand_interleaver.hpp +++ b/examples/common/testkit/strand_interleaver.hpp @@ -13,18 +13,18 @@ /// The strand interleaver's companion harness to the fault proxy /// (examples/TESTING.md): without it, strand-ordering bugs (kanban's /// MoveTaskPosition centerpiece) are probabilistic stress runs rather than -/// reproducible interleavings. Sits underneath a StrandExecutor as its `base` +/// reproducible interleavings. Sits underneath morph's strands as their base /// IExecutor so a test controls exactly which posted task runs next. /// /// `test_strand_interleaver.cpp`'s own tests place this class underneath a -/// real `morph::exec::detail::StrandExecutor` keyed by real +/// real `morph::exec::detail::ModelStrands` keyed by real /// `morph::exec::detail::ModelId`s and name both directly — the production /// components whose per-key ordering guarantee is the point of this harness. /// A stand-in would prove nothing here: unlike `morph::testing::StepExecutor` /// (a public seam, used elsewhere to interleave `RemoteServer` dispatch -/// *without* naming `StrandExecutor`), these particular tests exist to test -/// `StrandExecutor` itself. This is a deliberate, accepted testkit-layer -/// reach-in into a `detail::` namespace, not a gap awaiting a public seam. +/// *without* naming `ModelStrands`), these particular tests exist to test the +/// strands themselves. This is a deliberate, accepted testkit-layer reach-in +/// into a `detail::` namespace, not a gap awaiting a public seam. namespace morph::ladder::testkit { @@ -33,11 +33,11 @@ namespace morph::ladder::testkit { /// /// Single-threaded by construction: `post()` just appends to a deque under a /// mutex (posts can legitimately arrive from other threads — e.g. a -/// `StrandExecutor` posting a same-key continuation from inside a running -/// task — but every task itself runs synchronously on whichever thread calls +/// strand queueing its next turn from inside a running task — but every task +/// itself runs synchronously on whichever thread calls /// `step()`/`runSchedule()`). /// -/// Unlike `ThreadPoolExecutor`/`StrandExecutor`, a task's exception is not +/// Unlike `ThreadPoolExecutor` and morph's strands, a task's exception is not /// caught and logged here: it propagates straight out of `step()`/ /// `runSchedule()` to the caller. That is deliberate — the caller is a test, /// and the exception is often a `REQUIRE` failure the test needs to see diff --git a/examples/common/testkit/test_presenter.cpp b/examples/common/testkit/test_presenter.cpp index 4904c3321..d132d3a17 100644 --- a/examples/common/testkit/test_presenter.cpp +++ b/examples/common/testkit/test_presenter.cpp @@ -486,7 +486,7 @@ TEST_CASE("Presenter::busy() stays true while a second tracked completion is sti // `processEvents` slice runs, draining both before either is observed). // `DeterministicExecutor` (testkit/strand_interleaver.hpp -- the // established "control exactly which posted task runs next" harness, - // same one `test_strand_interleaver.cpp` drives a `StrandExecutor` + // same one `test_strand_interleaver.cpp` drives a `ModelStrands` // through) is a plain `IExecutor`, so it can stand in as the presenter's // own client-facing executor: `step()` runs exactly the oldest-queued // callback and nothing else. diff --git a/examples/common/testkit/test_strand_interleaver.cpp b/examples/common/testkit/test_strand_interleaver.cpp index 928248175..2c6fa5975 100644 --- a/examples/common/testkit/test_strand_interleaver.cpp +++ b/examples/common/testkit/test_strand_interleaver.cpp @@ -1,6 +1,7 @@ // SPDX-License-Identifier: Apache-2.0 #include #include +#include #include #include #include @@ -10,7 +11,9 @@ TEST_CASE("DeterministicExecutor runs same-key strand tasks in FIFO order under a scripted interleaving", "[ladder][testkit][strand-interleaver]") { morph::ladder::testkit::DeterministicExecutor det; - morph::exec::detail::StrandExecutor strand{det}; + // One task per turn, as morph's own strand ran them: a turn of several + // would run the key's queued tasks inside one step. + morph::exec::detail::ModelStrands strand{det, core::async::StrandOptions{.batch = 1}}; std::vector order; morph::exec::detail::ModelId key{1}; @@ -30,7 +33,7 @@ TEST_CASE("DeterministicExecutor runs same-key strand tasks in FIFO order under } // key's two tasks must have run in post order relative to each other - // (StrandExecutor's own guarantee); otherKey's task may interleave + // (the strand's own guarantee); otherKey's task may interleave // anywhere since it is a different key — assert only the same-key // relative order, which is the property this harness exists to make // reproducible. @@ -56,16 +59,17 @@ TEST_CASE("DeterministicExecutor::runSchedule executes queued tasks in the calle REQUIRE(order == std::vector{3, 1, 2}); } -TEST_CASE("DeterministicExecutor::runSchedule forces a non-default interleaving across two StrandExecutor keys", +TEST_CASE("DeterministicExecutor::runSchedule forces a non-default interleaving across two strand keys", "[ladder][testkit][strand-interleaver]") { // Plain FIFO draining (the previous test case) happens to run `key`'s // two tasks with `otherKey`'s task landing *between* them, because - // StrandExecutor::post appends a same-key continuation to the *back* of - // the base executor's queue rather than re-running it immediately: after - // posting key/otherKey/key, the DeterministicExecutor's queue holds only - // two entries — [keyTask1, otherKeyTask] — since the second `key` post - // finds the strand already running and just enqueues onto the strand's - // own pending list rather than posting a third entry to `det`. Stepping + // a strand that runs one task per turn hands its base back after each + // task and queues itself again at the *back* of the base executor's + // queue: after posting key/otherKey/key, the DeterministicExecutor's + // queue holds only two entries — [key's strand, otherKey's strand] — + // since the second `key` post finds the strand already scheduled and just + // enqueues onto the strand's own queue rather than a third entry on + // `det`. Stepping // that queue FIFO therefore already interleaves otherKey's task between // key's two tasks, without any deliberate scripting. // @@ -76,9 +80,11 @@ TEST_CASE("DeterministicExecutor::runSchedule forces a non-default interleaving // queue's current contents before consuming each index (the second // `key` task's post-to-`det` entry does not exist yet at schedule- // construction time; it only appears once the first `key` task has run - // and StrandExecutor re-arms the strand). + // and the strand queues itself on `det` again). morph::ladder::testkit::DeterministicExecutor det; - morph::exec::detail::StrandExecutor strand{det}; + // One task per turn, as morph's own strand ran them: a turn of several + // would run the key's queued tasks inside one step. + morph::exec::detail::ModelStrands strand{det, core::async::StrandOptions{.batch = 1}}; std::vector order; morph::exec::detail::ModelId key{1}; @@ -88,21 +94,20 @@ TEST_CASE("DeterministicExecutor::runSchedule forces a non-default interleaving strand.post(otherKey, [&] { order.push_back(100); }); strand.post(key, [&] { order.push_back(2); }); - // det's queue right now: [0] = key's first-task dispatch, [1] = otherKey's - // dispatch. key's second task is not queued on `det` yet — it is sitting - // in the strand's own pending list, waiting for the strand to be re-armed. + // det's queue right now: [0] = key's strand, [1] = otherKey's strand. + // key's second task is not queued on `det` itself — it is sitting in the + // strand's own queue, waiting for the strand's next turn. REQUIRE(det.pending() == 2); - // Step 1: run index 0 (key's first task). This both runs task 1 *and* - // causes StrandExecutor to re-arm the key strand, appending a new - // dispatch to the back of det's queue — so afterwards det's queue is - // [otherKey's dispatch, key's second-task dispatch]. + // Step 1: run index 0 (key's strand, one task). This both runs task 1 + // *and* queues the key's strand on det again, at the back — so + // afterwards det's queue is [otherKey's strand, key's strand]. // - // Step 2: run index 1 — *not* index 0 — to run key's second-task - // dispatch (the one that only just appeared) ahead of otherKey's, + // Step 2: run index 1 — *not* index 0 — to run key's second task (the + // entry that only just appeared) ahead of otherKey's, // deliberately keeping key's two tasks contiguous. // - // Step 3: only otherKey's dispatch is left, at index 0. + // Step 3: only otherKey's strand is left, at index 0. det.runSchedule({0, 1, 0}); REQUIRE(order == std::vector{1, 2, 100}); diff --git a/examples/kanban/tests/test_kanban_stress.cpp b/examples/kanban/tests/test_kanban_stress.cpp index ff26fd075..0cca397db 100644 --- a/examples/kanban/tests/test_kanban_stress.cpp +++ b/examples/kanban/tests/test_kanban_stress.cpp @@ -12,10 +12,10 @@ // // 1. The class the brief calls "StrandInterleaver" does not exist anywhere // in the tree; strand_interleaver.hpp defines `DeterministicExecutor`, -// which sits *underneath* a `morph::exec::detail::StrandExecutor` as its +// which sits *underneath* a `morph::exec::detail::ModelStrands` as its // `base` `IExecutor` and only runs posted tasks when explicitly // `step()`/`runSchedule()`-d. It is exercised directly against -// `StrandExecutor` in test_strand_interleaver.cpp, naming the production +// `ModelStrands` in test_strand_interleaver.cpp, naming the production // `detail::` types by hand. // 2. A `BackendRig{Mode::Local, ...}` builds its own `ThreadPoolExecutor` // internally (backend_rig.hpp's Mode::Local branch) and hands it @@ -23,7 +23,7 @@ // executor -- there is no seam for a test to substitute a // `DeterministicExecutor` underneath that strand. `DeterministicExecutor` // is therefore not wireable into a `BackendRig`-driven test at all: it is -// a lower-level harness for testing `StrandExecutor` in isolation, not a +// a lower-level harness for testing `ModelStrands` in isolation, not a // knob `BackendRig`/`BoardModel` tests can reach. // // Given that, this test exercises the *real* concurrency guarantee design @@ -79,7 +79,7 @@ // shared-per-project instance semantics are a `Bridge`-level mechanism // (`registerModelShared`), unaffected by how `Bridge`/`LocalBackend` were // constructed. The result: every code path this test exercises is the real -// morph core (Bridge, LocalBackend, StrandExecutor, ThreadPoolExecutor, +// morph core (Bridge, LocalBackend, ModelStrands, ThreadPoolExecutor, // Completion) with zero Qt frames anywhere in the call graph, making this // CI job's own "no Qt/GUI involvement" premise genuinely true rather than // merely claimed. diff --git a/examples/pastebin/gui_lib/paste_presenter.cpp b/examples/pastebin/gui_lib/paste_presenter.cpp index fdd3087ae..d801432d7 100644 --- a/examples/pastebin/gui_lib/paste_presenter.cpp +++ b/examples/pastebin/gui_lib/paste_presenter.cpp @@ -6,7 +6,7 @@ namespace pastebin::gui { PastePresenter::PastePresenter(::morph::bridge::Bridge& bridge, ::morph::exec::IExecutor* executor, QObject* parent) - : Presenter{parent}, _handler{bridge, executor} { + : Presenter{parent}, _executor{executor}, _handler{bridge, executor} { trackBound(_handler.whenBound()); } @@ -37,9 +37,21 @@ void PastePresenter::remove(DeletePaste action) { } void PastePresenter::list(ListPastes action) { - track( - _handler.execute(std::move(action)), [this](ListPastesResult result) { emit listed(std::move(result)); }, - [this](const std::exception_ptr& err) { reportError(err); }); + trackFlow(*_executor, listFlow(QPointer{this}, _handler.execute(std::move(action)))); +} + +core::async::Task PastePresenter::listFlow(QPointer self, + ::morph::async::Completion pending) { + try { + ListPastesResult result = co_await std::move(pending); + if (!self.isNull()) { + emit self->listed(std::move(result)); + } + } catch (...) { + if (!self.isNull()) { + self->reportError(std::current_exception()); + } + } } } // namespace pastebin::gui diff --git a/examples/pastebin/gui_lib/paste_presenter.hpp b/examples/pastebin/gui_lib/paste_presenter.hpp index e24b26513..e13aefca6 100644 --- a/examples/pastebin/gui_lib/paste_presenter.hpp +++ b/examples/pastebin/gui_lib/paste_presenter.hpp @@ -87,6 +87,19 @@ class PastePresenter : public ::morph::ladder::gui::Presenter { /// stays a plain member function, not a template. void reportError(const std::exception_ptr& err); +#ifndef Q_MOC_RUN + /// @brief `list()` as a coroutine: awaits the page and emits `listed`, + /// or `failed`. Run through `trackFlow`, so it resumes on the + /// presenter's executor and counts in `busy()`. + /// @param self The presenter, checked after the await: the flow may + /// outlive it. + /// @param pending The `ListPastes` completion. + /// @return The flow. + static core::async::Task listFlow(QPointer self, + ::morph::async::Completion pending); +#endif + + ::morph::exec::IExecutor* _executor; ::morph::bridge::BridgeHandler _handler; }; diff --git a/examples/polls/README.md b/examples/polls/README.md index ff82bec41..6b873969a 100644 --- a/examples/polls/README.md +++ b/examples/polls/README.md @@ -575,15 +575,13 @@ Known gaps: ships, and it has never been compiled (no Emscripten toolchain here — the `ladder-wasm` CI job is a compile gate). Writing `gui/main.cpp` and running the organizer-plus-participants demo is named follow-up work. -- **`Bridge::setExecuteDeadline` used to be unusable from a browser tab, and - the fix is CI-compile-verified only.** `EventPoller`'s constructor calls it - unconditionally, and it lazily builds a `TimeoutScheduler`, which spawned a - `std::thread` — impossible in the `wasm_singlethread` Qt build these - clients target. `include/morph/core/timeout_scheduler.hpp` now selects a - browser-timer (`emscripten_async_call`) build of itself under - `__EMSCRIPTEN__ && !__EMSCRIPTEN_PTHREADS__`, so deadlines still fire, on - the main thread. Neither the original hazard nor the fix has been observed - on a real Emscripten build; see that header's `@file` comment and +- **`Bridge::setExecuteDeadline` in a browser tab is CI-compile-verified + only.** `EventPoller`'s constructor calls it unconditionally, and it lazily + builds a `TimeoutScheduler`. Under `__EMSCRIPTEN__ && + !__EMSCRIPTEN_PTHREADS__` that scheduler starts no thread: its event loop is + pumped by the browser's own timer, so deadlines fire on the main thread. + The WebAssembly CI jobs compile and link it; nothing here runs it. See + `include/morph/core/timeout_scheduler.hpp`'s `@file` comment and `docs/spec/core/completion.md`. - **No admin-token persistence.** `PollBridge::setAdminToken` installs the token as the shared `Bridge`'s default session for the remainder of the diff --git a/include/morph/core/async.hpp b/include/morph/core/async.hpp index 6c01488fa..7f6be6d6b 100644 --- a/include/morph/core/async.hpp +++ b/include/morph/core/async.hpp @@ -5,7 +5,7 @@ /// @file /// @brief The asynchronous primitives, on their own — the cheap include. /// -/// `Completion`, `IExecutor` and its implementations, `StrandExecutor` and +/// `Completion`, `IExecutor` and its implementations, the strands and /// `CallbackScope` are useful without a model, a registry, a wire envelope or a /// schema, and they are the part of morph that costs almost nothing to compile. /// Nothing here reaches glaze. diff --git a/include/morph/core/backend.hpp b/include/morph/core/backend.hpp index 95b39fc57..26774ef20 100644 --- a/include/morph/core/backend.hpp +++ b/include/morph/core/backend.hpp @@ -96,6 +96,27 @@ struct ActionCall { /// sees it (`Bridge::executeVia`, `morph::forms::recomputeAll`). std::shared_ptr (*localOp)(::morph::model::detail::IModelHolder& holder, void* action) = nullptr; + /// @brief Receives a Task handler's outcome on the local path: the opaque + /// result, or the exception (with a null result). + using LocalDone = std::function, std::exception_ptr)>; + + /// @brief Starts a Task handler against a model holder, on the model's + /// strand, and reports its outcome through the last argument when the + /// Task completes. Set instead of `localOp` for an action whose + /// handler returns `core::async::Task`; see + /// `docs/spec/core/coroutines.md`. + /// + /// Takes the action's owner rather than a borrowed pointer: the handler's + /// frame outlives the call that starts it. + void (*localOpAsync)(::morph::model::detail::IModelHolder& holder, std::shared_ptr action, + const std::shared_ptr<::morph::exec::detail::TaskResumer>& executor, + ::core::async::StopToken token, LocalDone done) = nullptr; + + /// @brief The stop source a Task handler's token comes from, or null for + /// none. `Bridge::executeVia` sets one when an execute deadline is + /// armed, and the deadline requests stop on it. + std::shared_ptr<::core::async::StopSource> stopSource; + /// @brief Session context attached to this call. /// /// Local backends thread it through a thread-local before invoking `localOp`; @@ -783,11 +804,12 @@ struct ClientTimeoutError : std::runtime_error { /// @par Ordering /// Control calls are serialised onto one strand, so the wrapped backend sees /// them one at a time, as it did when the blocking call itself serialised -/// callers. `~SynchronousBackendAdapter` waits for any in-flight control call -/// to finish (`StrandExecutor`'s destructor does), so a reply can never land -/// in a destroyed adapter; the executor must therefore still be running tasks -/// when this adapter is destroyed, on the same terms as `StrandExecutor`'s own -/// `base` (see docs/spec/concurrency_and_lifetimes.md, "Destruction ordering"). +/// callers. `~SynchronousBackendAdapter` waits for every queued and in-flight +/// control call to finish, so a reply can never land in a destroyed adapter; +/// the executor must therefore still be running tasks when this adapter is +/// destroyed (see docs/spec/concurrency_and_lifetimes.md, "Destruction +/// ordering"). The single-threaded WebAssembly build has no thread to wait +/// for: there the control calls still queued are dropped. /// /// @par Reconnect handlers /// A control call issued from a reconnect handler runs on the strand, never on @@ -795,7 +817,6 @@ struct ClientTimeoutError : std::runtime_error { /// be delivered by the thread that is running the reconnect handler does not /// wait on itself. Whether that is enough to settle `SocketBackend`'s /// documented reconnect hazard is not a claim made here. -// NOLINTNEXTLINE(cppcoreguidelines-special-member-functions) class SynchronousBackendAdapter : public detail::IBackend { public: /// @brief Wraps @p inner, running its blocking control calls on @p blockingExec. @@ -815,6 +836,17 @@ class SynchronousBackendAdapter : public detail::IBackend { } } + /// @brief Waits for every queued and in-flight control call; see + /// "Ordering" above. + ~SynchronousBackendAdapter() override { + _control.teardown([] {}); + } + + SynchronousBackendAdapter(const SynchronousBackendAdapter&) = delete; + SynchronousBackendAdapter& operator=(const SynchronousBackendAdapter&) = delete; + SynchronousBackendAdapter(SynchronousBackendAdapter&&) = delete; + SynchronousBackendAdapter& operator=(SynchronousBackendAdapter&&) = delete; + /// @brief The producer side of a `bindModel`/`promoteModel` completion. /// /// Named because `cancelPending` has to hold these weakly; see @@ -1132,15 +1164,15 @@ class SynchronousBackendAdapter : public detail::IBackend { } /// @brief The single strand key every control call shares, so they run one - /// at a time. Not a real model id: this `StrandExecutor` is private - /// to the adapter and shares no key space with any backend's own. + /// at a time. Not a real model id: these strands are private to the + /// adapter and share no key space with any backend's own. static constexpr ::morph::exec::detail::ModelId kControlStrand{1}; /// @brief Smallest size at which `trackPending` sweeps; see `LocalBackend`'s. static constexpr std::size_t kPendingCompactFloor = 32; std::shared_ptr _inner; - ::morph::exec::detail::StrandExecutor _control; + ::morph::exec::detail::ModelStrands _control; mutable std::mutex _pendingMtx; // Every `bindModel`/`promoteModel` record handed to a `_control` task and // not yet settled by it. Weak, so a settled task's record drops out on its @@ -1160,13 +1192,66 @@ class LocalBackend : public detail::IBackend { public: /// @brief Constructs the backend using @p workerPool to run model actions. /// @param workerPool Executor (typically a `ThreadPoolExecutor`) for model - /// work. Borrowed, not owned: it is handed to this - /// backend's `StrandExecutor`, so it must outlive the - /// backend *and* keep running tasks until teardown - /// completes — destroying it first deadlocks (see + /// work. Borrowed, not owned: this backend's strands run + /// on it, so it must outlive the backend *and* keep + /// running tasks until teardown completes — destroying it + /// first deadlocks (see /// `docs/spec/concurrency_and_lifetimes.md`, "Destruction /// ordering"). - explicit LocalBackend(::morph::exec::IExecutor& workerPool MORPH_LIFETIMEBOUND) : _strand{workerPool} {} + explicit LocalBackend(::morph::exec::IExecutor& workerPool MORPH_LIFETIMEBOUND) + : _strands{std::make_shared<::morph::exec::detail::ModelStrands>(workerPool)} {} + + /// @brief Stops the Task handlers still running, lets the strands drain, + /// seals them, drains them again, and only then closes them + /// (`ModelStrands::teardown`). See `docs/spec/core/coroutines.md`, + /// "Teardown". + /// + /// Where threads exist, in that order: + /// 1. Every live Task run's stop is requested. A handler suspended in an + /// awaitable that resumes on the current executor -- morph's own, + /// `core::async::AsyncQueue::pop` -- resumes, cancelled, through its + /// strand, which still admits it. One suspended on a `core::net` socket + /// or timer resumes on that loop instead, and unwinds there; its end is + /// posted to the strand. + /// 2. The strands are drained: the resumptions and ends queued on them, + /// and every queued action -- skipped, if `cancelPending` already failed + /// it. This drain comes before the seal because a handler's end that + /// arrives from a loop thread now is still queued behind its instance's + /// other tasks; sealed, it would run inline on the loop thread while + /// this drain ran one of those tasks on a pool thread, two threads + /// inside the instance's action gate. + /// 3. The strands are sealed: from here on a resumption or a handler's end + /// is refused and runs inline, where it arrives, rather than queued on a + /// strand step 5 would drop. + /// 4. The strands are drained again, for what reached them between steps + /// 2 and 3. It does not wait for a handler still unwinding on another + /// executor. + /// 5. The strands are closed. + /// + /// Must not run on one of this backend's strand threads, whose drain it + /// would wait for; a debug build asserts that. The single-threaded + /// WebAssembly build seals, stops the handlers, then closes: sealed first, + /// each stopped handler unwinds inline in the stop, and the drains wait for + /// nothing, since nothing else could run the strands. + ~LocalBackend() override { + std::vector> runs; + { + std::scoped_lock const lock{_taskRunsMtx}; + runs.swap(_taskRuns); + } + _strands->teardown([&runs] { + for (auto const& weak : runs) { + if (auto const run = weak.lock()) { + run->stopSource->request_stop(); + } + } + }); + } + + LocalBackend(const LocalBackend&) = delete; + LocalBackend& operator=(const LocalBackend&) = delete; + LocalBackend(LocalBackend&&) = delete; + LocalBackend& operator=(LocalBackend&&) = delete; /// @brief Creates a model instance via @p factory and registers it. /// @@ -1297,7 +1382,7 @@ class LocalBackend : public detail::IBackend { } } for (auto& [modelId, holder] : aware) { - _strand.post(modelId, [h = std::move(holder)]() mutable { h->onBackendChanged(); }); + _strands->post(modelId, [held = std::move(holder)]() mutable { held->onBackendChanged(); }); } } @@ -1367,88 +1452,65 @@ class LocalBackend : public detail::IBackend { std::make_exception_ptr(std::runtime_error("model not found: id=" + std::to_string(mid.v)))); return; } - trackPending(sink); - auto* const localOp = call.localOp; + auto const admittedEpoch = trackPending(sink); + LocalRun run; + run.localOp = call.localOp; + run.localOpAsync = call.localOpAsync; + run.stopSource = std::move(call.stopSource); // The action handle travels with `localOp` into the strand task, not // just as far as this function: `ActionCall::localOp` borrows the // action rather than owning it (see that struct), and `call` is gone // long before the task runs. - auto action = std::move(call.action); - auto session = std::move(call.session); - auto const modelTypeId = call.modelTypeId; - auto const actionTypeId = call.actionTypeId; - // Captured by shared_ptr, never by raw `this`: see the Global - // Constraints note on `~StrandExecutor`'s member-destruction-order - // subtlety. A shared_ptr copy has its own lifetime, independent of - // LocalBackend's, so it stays valid even if the backend is torn down - // while this task is still queued or running. `hydration` follows the - // same rule and may be null (a private instance has no entry). - auto inFlightCounter = _inFlight; - auto const inFlightAfterInc = inFlightCounter->fetch_add(1, std::memory_order_relaxed) + 1; + run.action = std::move(call.action); + run.session = std::move(call.session); + run.modelTypeId = call.modelTypeId; + run.actionTypeId = call.actionTypeId; + run.holder = std::move(holder); + run.sink = std::move(sink); + run.hydration = std::move(hydration); + run.strands = _strands; + run.cancels = _cancels; + run.admittedEpoch = admittedEpoch; + run.mid = mid; + // Held by shared_ptr, never by raw `this`. A shared_ptr copy has its + // own lifetime, independent of LocalBackend's, so it stays valid even + // if the backend is torn down while this task is still queued or + // running. `hydration` follows the same rule and may be null (a + // private instance has no entry). + run.inFlightCounter = _inFlight; + auto const inFlightAfterInc = run.inFlightCounter->fetch_add(1, std::memory_order_relaxed) + 1; ::morph::observe::detail::emitMetric(::morph::observe::Metric::executeInFlight, static_cast(inFlightAfterInc)); - _strand.post( - mid, [localOp, holder = std::move(holder), sink = std::move(sink), session = std::move(session), - modelTypeId, actionTypeId, inFlightCounter, hydration, action = std::move(action)]() mutable { - auto const start = std::chrono::steady_clock::now(); - auto const spanId = ::morph::observe::detail::beginSpan(session.requestId, modelTypeId, actionTypeId); - bool succeeded = false; - // Resolve the sink only after every metric and `endSpan` below are - // recorded — nothing synchronizes a `.then()`/`.onError()` callback - // (delivered via `cbExec`, which may run inline/synchronously) with - // anything after `setValue`/`setException` returns, so resolving first - // would let the caller observe completion before these metrics are - // emitted. This is a real race, not just a theoretical one. - std::shared_ptr value; - std::exception_ptr error; - try { - ::morph::session::detail::ScopedContext const scoped{session}; - // Explicit, because `localOp` is a function pointer now and - // a null one is undefined behaviour rather than the - // `std::bad_function_call` an empty `std::function` used to - // raise. Same outcome for the caller -- the completion - // resolves through its error sink -- with a diagnostic that - // names the field instead of the library. - if (localOp == nullptr) { - throw std::runtime_error{"ActionCall::localOp is null: nothing to execute"}; - } - value = localOp(*holder, action.get()); - succeeded = true; - } catch (...) { - error = std::current_exception(); - } - // Settle hydration the moment the first action's outcome is known — - // before `endSpan`, before any metric, and before the `Completion` - // resolves. Each of those hands control to host code that is free - // to attach to this instance's key, and an attacher reaching the - // directory while the outcome is known but unrecorded is handed an - // instance whose first action has already failed — exactly what - // docs/spec/core/shared_instances.md's Failure modes section says - // must not happen. Only the *first* action settles it; `settle` is - // a single compare-exchange and ignores every later call. - if (hydration) { - hydration->settle(succeeded); - } - ::morph::observe::detail::endSpan(spanId, succeeded); - auto const elapsedMs = - std::chrono::duration(std::chrono::steady_clock::now() - start).count(); - std::array, 2> const tags{ - {{"modelType", modelTypeId}, {"actionType", actionTypeId}}}; - ::morph::observe::detail::emitMetric(::morph::observe::Metric::executeLatencyMs, elapsedMs, tags); - if (!succeeded) { - ::morph::observe::detail::emitMetric(::morph::observe::Metric::executeErrors, 1.0, tags); - } - auto const inFlightAfterDec = inFlightCounter->fetch_sub(1, std::memory_order_relaxed) - 1; - ::morph::observe::detail::emitMetric(::morph::observe::Metric::executeInFlight, - static_cast(inFlightAfterDec)); - // Resolve last: the sink is still settled exactly once, only its - // position relative to the now-recorded instrumentation moved. - if (succeeded) { - sink->settleValue(std::move(value)); - } else { - sink->settleException(error); - } - }); + // Through the instance's action gate: an action starts only once the one + // before it has finished, which a Task handler does when its Task + // completes rather than when the strand task that started it returns. + if (run.localOpAsync != nullptr) { + // A Task run is shared: its completion callback outlives the strand + // task, and every Task run can be stopped, deadline or not -- the + // destructor stops the ones still running. + if (!run.stopSource) { + run.stopSource = std::make_shared<::core::async::StopSource>(); + } + auto shared = std::make_shared(std::move(run)); + rememberTaskRun(shared); + _strands->post(mid, + [shared] { shared->holder->actionGate().enter([shared] { startTaskLocal(shared); }); }); + return; + } + // An ordinary run travels by value in the strand task, so a dispatch + // costs the post's one allocation and nothing more: + // `bench.alloc_budget` holds that line. The task keeps it while the + // handler runs, as it kept its captures before there was a gate; only + // a run that has to wait behind a suspended Task handler is moved out, + // into the gate's queue. + _strands->post(mid, [run = std::move(run)]() mutable { + auto& gate = run.holder->actionGate(); + if (gate.tryEnter()) { + startLocal(run); + return; + } + gate.enter([waiting = std::make_shared(std::move(run))] { startLocal(*waiting); }); + }); } /// @brief Resolves every still-pending completion this backend produced with @p exc. @@ -1459,6 +1521,10 @@ class LocalBackend : public detail::IBackend { std::scoped_lock const lock{_pendingMtx}; snapshot.swap(_pending); _compactAt = kPendingCompactFloor; + // Under the same lock as the swap: a run admitted before this + // point is in `snapshot` and is failed below, and one admitted + // after it is not. See `startLocal`. + _cancels->record(exc); } for (auto& weak : snapshot) { if (auto sink = weak.lock()) { @@ -1543,16 +1609,207 @@ class LocalBackend : public detail::IBackend { /// which `cancelPending`'s `weak.lock()` has always skipped. Carrying dead /// entries for longer changes nothing it observes. /// @param sink Settle sink to track until it expires or is cancelled. - void trackPending(const std::shared_ptr<::morph::async::detail::ISettleSink>& sink) { + /// @return The cancel epoch @p sink was admitted in: `cancelPending` has + /// failed the dispatch once the epoch has moved on. + std::uint64_t trackPending(const std::shared_ptr<::morph::async::detail::ISettleSink>& sink) { std::scoped_lock const lock{_pendingMtx}; if (_pending.size() >= _compactAt) { std::erase_if(_pending, [](const auto& weak) { return weak.expired(); }); _compactAt = std::max(kPendingCompactFloor, _pending.size() * 2); } _pending.emplace_back(sink); + return _cancels->epoch(); + } + + /// What `cancelPending` has done so far, shared with every run: how many + /// times it has run, and with what reason the last time. + class CancelRecord { + public: + /// Stores @p reason, then bumps the epoch. Called under `_pendingMtx`. + void record(const std::exception_ptr& reason) { + { + std::scoped_lock const lock{_mtx}; + _reason = reason; + } + _epoch.fetch_add(1); + } + + /// @return How many times `cancelPending` has run. + [[nodiscard]] std::uint64_t epoch() const noexcept { return _epoch.load(); } + + /// @return The reason the last `cancelPending` gave. + [[nodiscard]] std::exception_ptr lastReason() { + std::scoped_lock const lock{_mtx}; + return _reason; + } + + private: + std::atomic _epoch{0}; + std::mutex _mtx; + std::exception_ptr _reason; + }; + + /// Everything one local dispatch carries from `executeInto` to the moment + /// it settles. An ordinary handler's run is held by its strand task, or by + /// the gate's queue while it waits there; a Task handler's is shared, + /// because its completion callback holds it too. + struct LocalRun { + std::shared_ptr (*localOp)(::morph::model::detail::IModelHolder&, void*) = nullptr; + decltype(detail::ActionCall::localOpAsync) localOpAsync = nullptr; + std::shared_ptr<::core::async::StopSource> stopSource; + std::shared_ptr action; + ::morph::session::Context session; + std::string_view modelTypeId; + std::string_view actionTypeId; + std::shared_ptr<::morph::model::detail::IModelHolder> holder; + std::shared_ptr<::morph::async::detail::ISettleSink> sink; + std::shared_ptr hydration; + std::shared_ptr> inFlightCounter; + std::shared_ptr<::morph::exec::detail::ModelStrands> strands; + std::shared_ptr cancels; + std::uint64_t admittedEpoch = 0; + ::morph::exec::detail::ModelId mid{}; + std::chrono::steady_clock::time_point start; + ::morph::observe::SpanId spanId{}; + }; + + /// Stamps a dispatch's start once it holds its instance's action gate, and + /// settles it instead if `cancelPending` failed it while it waited there. + /// @return Whether the handler is to run. + static bool admitLocal(LocalRun& run) { + run.start = std::chrono::steady_clock::now(); + run.spanId = ::morph::observe::detail::beginSpan(run.session.requestId, run.modelTypeId, run.actionTypeId); + if (run.cancels->epoch() == run.admittedEpoch) { + return true; + } + // `cancelPending` failed this call while it waited for the gate; + // the handler does not run for a caller that has been answered. + // The sink is settled here, with the reason `cancelPending` gave, + // because this may be the only place it can be: `cancelPending` + // reaches sinks through `weak_ptr`s, and when the caller has + // dropped its `Completion` this run holds the last reference, so + // the sink is gone by the time `cancelPending` would reach it. + // Where `cancelPending` got there first, this settle is ignored. + finishLocal(run, nullptr, run.cancels->lastReason()); + return false; + } + + /// Runs an ordinary handler's dispatch once it holds its instance's action + /// gate, on the strand, and finishes it. + static void startLocal(LocalRun& run) { + if (!admitLocal(run)) { + return; + } + std::shared_ptr value; + std::exception_ptr error; + try { + ::morph::session::detail::ScopedContext const scoped{run.session}; + // Explicit, because `localOp` is a function pointer now and + // a null one is undefined behaviour rather than the + // `std::bad_function_call` an empty `std::function` used to + // raise. Same outcome for the caller -- the completion + // resolves through its error sink -- with a diagnostic that + // names the field instead of the library. + if (run.localOp == nullptr) { + throw std::runtime_error{"ActionCall::localOp is null: nothing to execute"}; + } + value = run.localOp(*run.holder, run.action.get()); + } catch (...) { + error = std::current_exception(); + } + finishLocal(run, std::move(value), error); + } + + /// Starts a Task handler once its dispatch holds the instance's action + /// gate, on the strand. It finishes when its Task completes, through the + /// callback it is handed. + static void startTaskLocal(const std::shared_ptr& run) { + if (!admitLocal(*run)) { + return; + } + std::shared_ptr<::morph::exec::detail::TaskResumer> executor; + try { + ::morph::session::detail::ScopedContext const scoped{run->session}; + executor = std::make_shared<::morph::exec::detail::TaskResumer>(run->strands, run->mid, run->session); + run->strands->enroll(run->mid, executor); + auto token = run->stopSource->get_token(); + run->localOpAsync(*run->holder, run->action, executor, std::move(token), + [run, resumer = executor.get()](std::shared_ptr value, std::exception_ptr error) { + run->strands->withdraw(run->mid, resumer); + // A handler whose last await resumed on + // another executor -- a `core::net` loop -- + // ends there; what follows its end belongs + // on the strand. + run->strands->runOnStrand(run->mid, + [run, value = std::move(value), error = std::move(error)] { + finishLocal(*run, value, error); + }); + }); + } catch (...) { + run->strands->withdraw(run->mid, executor.get()); + finishLocal(*run, nullptr, std::current_exception()); + } + } + + /// Records a finished dispatch and settles its sink, then leaves the action + /// gate so the next action on the instance can start. On the strand. + static void finishLocal(LocalRun& run, std::shared_ptr value, const std::exception_ptr& error) { + bool const succeeded = error == nullptr; + // Resolve the sink only after every metric and `endSpan` below are + // recorded — nothing synchronizes a `.then()`/`.onError()` callback + // (delivered via `cbExec`, which may run inline/synchronously) with + // anything after `setValue`/`setException` returns, so resolving first + // would let the caller observe completion before these metrics are + // emitted. This is a real race, not just a theoretical one. + // + // Settle hydration the moment the first action's outcome is known — + // before `endSpan`, before any metric, and before the `Completion` + // resolves. Each of those hands control to host code that is free + // to attach to this instance's key, and an attacher reaching the + // directory while the outcome is known but unrecorded is handed an + // instance whose first action has already failed — exactly what + // docs/spec/core/shared_instances.md's Failure modes section says + // must not happen. Only the *first* action settles it; `settle` is + // a single compare-exchange and ignores every later call. + if (run.hydration) { + run.hydration->settle(succeeded); + } + ::morph::observe::detail::endSpan(run.spanId, succeeded); + auto const elapsedMs = + std::chrono::duration(std::chrono::steady_clock::now() - run.start).count(); + std::array, 2> const tags{ + {{"modelType", run.modelTypeId}, {"actionType", run.actionTypeId}}}; + ::morph::observe::detail::emitMetric(::morph::observe::Metric::executeLatencyMs, elapsedMs, tags); + if (!succeeded) { + ::morph::observe::detail::emitMetric(::morph::observe::Metric::executeErrors, 1.0, tags); + } + auto const inFlightAfterDec = run.inFlightCounter->fetch_sub(1, std::memory_order_relaxed) - 1; + ::morph::observe::detail::emitMetric(::morph::observe::Metric::executeInFlight, + static_cast(inFlightAfterDec)); + // Resolve last: the sink is still settled exactly once, only its + // position relative to the now-recorded instrumentation moved. + if (succeeded) { + run.sink->settleValue(std::move(value)); + } else { + run.sink->settleException(error); + } + run.holder->actionGate().leave(); + } + + /// Records @p run among the Task runs the destructor stops, sweeping the + /// ones that have finished amortised, as `trackPending` does. + void rememberTaskRun(const std::shared_ptr& run) { + std::scoped_lock const lock{_taskRunsMtx}; + if (_taskRuns.size() >= _taskRunsCompactAt) { + std::erase_if(_taskRuns, [](const auto& weak) { return weak.expired(); }); + _taskRunsCompactAt = std::max(kPendingCompactFloor, _taskRuns.size() * 2); + } + _taskRuns.emplace_back(run); } - ::morph::exec::detail::StrandExecutor _strand; + // One strand per model instance. Shared with the Task handlers started + // here, whose resumers outlive the backend; closed by the destructor. + std::shared_ptr<::morph::exec::detail::ModelStrands> _strands; std::mutex _regMtx; // Every live instance, private and shared alike, plus the shared-instance // directory over them — holder, attach count, directory key and hydration @@ -1584,10 +1841,17 @@ class LocalBackend : public detail::IBackend { // re-armed at twice the surviving count after each sweep. Guarded by // `_pendingMtx` along with `_pending` itself. See `trackPending`. std::size_t _compactAt = kPendingCompactFloor; + // Recorded by `cancelPending` under `_pendingMtx`, as it takes the pending + // list. Shared with every run, which notes the epoch it was admitted under + // and skips its handler if the epoch has moved on by the time it starts. + std::shared_ptr _cancels = std::make_shared(); + // The Task runs the destructor stops. Weak, so a finished run is not kept. + std::mutex _taskRunsMtx; + std::vector> _taskRuns; + std::size_t _taskRunsCompactAt = kPendingCompactFloor; // Concurrent in-flight executes, for the executeInFlight metric. A // shared_ptr (not a plain atomic member) so strand tasks hold their own - // reference instead of capturing `this` — see execute()'s comment and the - // Global Constraints note on ~StrandExecutor's destruction order. + // reference instead of capturing `this` — see execute()'s comment. std::shared_ptr> _inFlight = std::make_shared>(0); }; diff --git a/include/morph/core/bridge.hpp b/include/morph/core/bridge.hpp index b94dab001..bb99cff61 100644 --- a/include/morph/core/bridge.hpp +++ b/include/morph/core/bridge.hpp @@ -581,7 +581,7 @@ void deliverLate(::morph::exec::IExecutor* exec, Action&& action) { /// documented fire-and-forget sends precisely so that destruction never spins /// a nested event loop. A destructor that blocks on a bounded predicate is /// also the framework's existing idiom for exactly this class of hazard — -/// `~StrandExecutor` blocks until `_inFlight == 0` +/// `~LocalBackend` blocks until its strands have drained /// (docs/spec/concurrency_and_lifetimes.md, "Destruction ordering"). struct BridgeLifetime { /// Held shared by a caller for the whole of its call into the `Bridge`, @@ -807,6 +807,105 @@ class BridgeSink final : public ::morph::async::detail::CompletionState, std::atomic_flag _settled = ATOMIC_FLAG_INIT; }; +/// @brief `ActionCall::localOpAsync` for an action whose handler returns +/// `core::async::Task`: the local-path twin of `localOp`. +/// +/// Recomputes the action's computed fields and enforces its validator exactly +/// as `localOp` does, starts the handler on the model's strand, and -- when its +/// Task completes -- journals the outcome and reports it through @p done. See +/// `docs/spec/core/coroutines.md`. Declared in every build, so `executeVia` can +/// name it; a `MORPH_CLIENT_ONLY` build never instantiates it. +/// @tparam Model Concrete model type. +/// @tparam Action Concrete action type. +/// @param holder The model instance; kept alive by @p done's owner until it has run. +/// @param actionOwner The action, owned: the handler's frame outlives this call. +/// @param executor The handler's resumer, on the model's strand. +/// @param token The stop token the handler observes. +/// @param done Called exactly once, on the strand. +template +// Validation, the handler call, and the journalling of each outcome, in the +// order localOp keeps them; splitting it would put that order in two places. +// NOLINTNEXTLINE(readability-function-cognitive-complexity) +void localTaskOp(::morph::model::detail::IModelHolder& holder, std::shared_ptr actionOwner, + const std::shared_ptr<::morph::exec::detail::TaskResumer>& executor, ::core::async::StopToken token, + ::morph::backend::detail::ActionCall::LocalDone done) { + using R = ::morph::model::ActionTraits::Result; + using Handler = decltype(std::declval().execute(std::declval())); + Action& actionRef = *static_cast(actionOwner.get()); + Handler task; + try { + ::morph::forms::recomputeAll(actionRef); + if (!::morph::model::ActionValidator::ready(actionRef)) { + throw ::morph::model::ValidationError{::morph::model::ModelTraits::typeId(), + ::morph::model::ActionTraits::typeId()}; + } + task = holder.template into().execute(actionRef); + } catch (...) { + done(nullptr, std::current_exception()); + return; + } + ::morph::model::detail::startTaskHandler( + executor, std::move(task), std::move(token), + [&holder, actionOwner = std::move(actionOwner), done = std::move(done)](std::optional result, + const std::exception_ptr& error) { + const Action& action = *static_cast(actionOwner.get()); + if (error) { + // The handler failed, so the action was rejected: recorded + // Outcome::Failed, as localOp records a throw from + // Model::execute. A journal write that throws here fails the + // call with the handler's own exception still. + try { + if constexpr (::morph::model::detail::actionLoggable() == ::morph::model::Loggable::Yes) { + if (holder.hasActionLog()) { + try { + std::rethrow_exception(error); + } catch (const std::exception& exc) { + ::morph::model::detail::recordActionFailure( + holder, std::string{::morph::model::ModelTraits::typeId()}, + std::string{::morph::model::ActionTraits::typeId()}, + ::morph::model::ActionTraits::toJson(action), + ::morph::model::detail::actionPayloadSchema(), exc.what()); + } catch (...) { // NOLINT(bugprone-empty-catch): as localOp, below + // Not a std::exception: recorded nowhere, as localOp's + // rethrow leaves such a throw unrecorded. + } + } + } + } catch (...) { // NOLINT(bugprone-empty-catch): the handler's exception is reported + } + done(nullptr, error); + return; + } + // The Task completed, so the model's mutation has committed: as in + // localOp, failing to serialise the result or to record it is an + // ActionRecordingError, never a rejection. + std::shared_ptr value; + std::string resultJson; + try { + auto typed = std::make_shared(std::move(*result)); + if constexpr (::morph::model::detail::actionLoggable() == ::morph::model::Loggable::Yes) { + if (holder.hasActionLog()) { + resultJson = ::morph::model::ActionTraits::resultToJson(*typed); + ::morph::model::detail::recordActionSuccess( + holder, std::string{::morph::model::ModelTraits::typeId()}, + std::string{::morph::model::ActionTraits::typeId()}, + ::morph::model::ActionTraits::toJson(action), + ::morph::model::detail::actionPayloadSchema(), resultJson); + } + } + value = std::move(typed); + } catch (const std::exception& exc) { + done(nullptr, + std::make_exception_ptr(::morph::model::ActionRecordingError{std::move(resultJson), exc.what()})); + return; + } catch (...) { + done(nullptr, std::current_exception()); + return; + } + done(std::move(value), nullptr); + }); +} + } // namespace detail /// @brief Central dispatcher that routes typed actions to an `IBackend`. @@ -2007,9 +2106,25 @@ class Bridge { // construction: while any copy of it is alive, ~TimeoutScheduler() // cannot run at all. std::shared_ptr<::morph::async::detail::TimeoutScheduler> schedulerRef; +#ifndef MORPH_CLIENT_ONLY + constexpr bool taskHandler = + ::morph::model::isTaskHandler().execute(std::declval()))>; +#else + // A client-only build never runs a handler locally (see localOp below). + constexpr bool taskHandler = false; +#endif + // A Task handler's stop source, when a deadline is armed: the deadline + // requests stop on it as well as rejecting the caller's completion, and + // LocalBackend hands its token to the handler, so a suspended handler + // unwinds at its next co_await. See docs/spec/core/coroutines.md, + // "Execute deadlines". + std::shared_ptr<::core::async::StopSource> stopSource; { std::scoped_lock const lock{_executeDeadlineMtx}; if (_executeDeadline.count() > 0 && _timeoutScheduler) { + if constexpr (taskHandler) { + stopSource = std::make_shared<::core::async::StopSource>(); + } schedulerRef = _timeoutScheduler; // The callback captures the sink alone -- never `this` -- so // it stays safe to fire even while ~Bridge() is running, and @@ -2027,8 +2142,11 @@ class Bridge { // that fires is not one of the two mutually-exclusive // resolution paths and must not decrement `_pendingCalls`, // which stays inflated until the real reply lands. - auto const handle = schedulerRef->schedule(_executeDeadline, [sink] { + auto const handle = schedulerRef->schedule(_executeDeadline, [sink, stopSource] { sink->setException(std::make_exception_ptr(::morph::backend::ClientTimeoutError{})); + if (stopSource) { + stopSource->request_stop(); + } }); // Handed to the sink here, before the dispatch below, so the // write is sequenced before anything that could settle it on @@ -2056,61 +2174,65 @@ class Bridge { call.deserializeResult = [](std::string_view jsonStr) -> std::shared_ptr { return std::make_shared(::morph::model::ActionTraits::resultFromJson(jsonStr)); }; - call.localOp = [](::morph::model::detail::IModelHolder& holder, void* actionPtr) -> std::shared_ptr { - // The action `ActionCall::action` owns, handed back typed. The - // backend that invokes this keeps that handle alive across the - // call (LocalBackend carries it onto the strand with `localOp`). - Action& actionRef = *static_cast(actionPtr); - // Enforce the action's validator on the local execution path too, so - // a caller that constructs an Action by hand and calls - // BridgeHandler::execute() directly is rejected the - // same way a hand-built wire envelope is rejected by - // ActionDispatcher::registerAction's runner (registry.hpp). No JSON is - // involved on this path, so there is no declared-precision - // reconciliation step here (that only applies to decoded wire - // payloads); the Quantity fields carry whatever precision the caller - // constructed them with. ActionValidator::ready defaults to - // `true` for actions with no validator, so this is a no-op for - // unvalidated actions (zero behavior change). The thrown exception is - // caught by LocalBackend::execute's strand task (backend.hpp) and - // resolves this Completion through onError. - // - // Overwrite any computed fields from their declared inputs before the - // validator runs and the model ever sees the action -- the same - // authoritative recompute ActionDispatcher::registerAction's runner - // performs for remote topologies (registry.hpp), applied here for the - // in-process LocalBackend path (every execute()/executeJson - // call). Recompute must run before the validator check so a validator - // inspecting a computed field sees the authoritative value, not - // whatever the caller constructed the action with. No-op for actions - // with no computedFields. See docs/spec/forms/forms.md. - ::morph::forms::recomputeAll(actionRef); - if (!::morph::model::ActionValidator::ready(actionRef)) { - throw ::morph::model::ValidationError{::morph::model::ModelTraits::typeId(), - ::morph::model::ActionTraits::typeId()}; - } + if constexpr (taskHandler) { + call.localOpAsync = &detail::localTaskOp; + call.stopSource = std::move(stopSource); + } else { + call.localOp = [](::morph::model::detail::IModelHolder& holder, void* actionPtr) -> std::shared_ptr { + // The action `ActionCall::action` owns, handed back typed. The + // backend that invokes this keeps that handle alive across the + // call (LocalBackend carries it onto the strand with `localOp`). + Action& actionRef = *static_cast(actionPtr); + // Enforce the action's validator on the local execution path too, so + // a caller that constructs an Action by hand and calls + // BridgeHandler::execute() directly is rejected the + // same way a hand-built wire envelope is rejected by + // ActionDispatcher::registerAction's runner (registry.hpp). No JSON is + // involved on this path, so there is no declared-precision + // reconciliation step here (that only applies to decoded wire + // payloads); the Quantity fields carry whatever precision the caller + // constructed them with. ActionValidator::ready defaults to + // `true` for actions with no validator, so this is a no-op for + // unvalidated actions (zero behavior change). The thrown exception is + // caught by LocalBackend::execute's strand task (backend.hpp) and + // resolves this Completion through onError. + // + // Overwrite any computed fields from their declared inputs before the + // validator runs and the model ever sees the action -- the same + // authoritative recompute ActionDispatcher::registerAction's runner + // performs for remote topologies (registry.hpp), applied here for the + // in-process LocalBackend path (every execute()/executeJson + // call). Recompute must run before the validator check so a validator + // inspecting a computed field sees the authoritative value, not + // whatever the caller constructed the action with. No-op for actions + // with no computedFields. See docs/spec/forms/forms.md. + ::morph::forms::recomputeAll(actionRef); + if (!::morph::model::ActionValidator::ready(actionRef)) { + throw ::morph::model::ValidationError{::morph::model::ModelTraits::typeId(), + ::morph::model::ActionTraits::typeId()}; + } #ifdef MORPH_CLIENT_ONLY - // A MORPH_CLIENT_ONLY build never links Model::execute's definition - // (see docs/spec/core/registry.md, "MORPH_CLIENT_ONLY") -- this - // #ifdef, not just the registration macros, is what actually makes - // that true: ActionCall::localOp is constructed unconditionally - // here regardless of which backend ends up installed, so the - // `model.execute(...)` call below would otherwise still force the - // linker to resolve it even for a build that only ever installs a - // remote backend. LocalBackend must not be used in such a build; - // reaching this point means it was anyway. - static_cast(holder); - throw std::logic_error( - "Bridge::executeVia: localOp invoked in a MORPH_CLIENT_ONLY build -- LocalBackend must not be " - "used"); + // A MORPH_CLIENT_ONLY build never links Model::execute's definition + // (see docs/spec/core/registry.md, "MORPH_CLIENT_ONLY") -- this + // #ifdef, not just the registration macros, is what actually makes + // that true: ActionCall::localOp is constructed unconditionally + // here regardless of which backend ends up installed, so the + // `model.execute(...)` call below would otherwise still force the + // linker to resolve it even for a build that only ever installs a + // remote backend. LocalBackend must not be used in such a build; + // reaching this point means it was anyway. + static_cast(holder); + throw std::logic_error( + "Bridge::executeVia: localOp invoked in a MORPH_CLIENT_ONLY build -- LocalBackend must not be " + "used"); #else - auto& model = holder.template into(); - // Local mode has no client/server split, so this is the same execution - // site `ActionDispatcher::registerAction`'s runner is for remote modes - // (registry.hpp) — see that overload's doc comment for the full story, - // including why a rejected/throwing execute must not leave the audit - // trail silent, and why `Model::execute` is the only call inside the - // try that records Outcome::Failed. + auto& model = holder.template into(); + // Local mode has no client/server split, so this is the same execution + // site `ActionDispatcher::registerAction`'s runner is for remote modes + // (registry.hpp) — see that overload's doc comment for the full story, + // including why a rejected/throwing execute must not leave the audit + // trail silent, and why `Model::execute` is the only call inside the + // try that records Outcome::Failed. // MSVC's C4702 fires on the `return` below for any action whose handler never // returns -- a test double whose body is a bare `throw`, for instance. The // warning is correct for that instantiation and wrong as a verdict on this @@ -2121,53 +2243,55 @@ class Bridge { #pragma warning(push) #pragma warning(disable : 4702) #endif - auto result = [&] { - try { - return std::make_shared(model.execute(actionRef)); - } catch (const std::exception& exc [[maybe_unused]]) { - if constexpr (::morph::model::detail::actionLoggable() == ::morph::model::Loggable::Yes) { - if (holder.hasActionLog()) { - ::morph::model::detail::recordActionFailure( - holder, std::string{::morph::model::ModelTraits::typeId()}, - std::string{::morph::model::ActionTraits::typeId()}, - ::morph::model::ActionTraits::toJson(actionRef), - ::morph::model::detail::actionPayloadSchema(), exc.what()); + auto result = [&] { + try { + return std::make_shared(model.execute(actionRef)); + } catch (const std::exception& exc [[maybe_unused]]) { + if constexpr (::morph::model::detail::actionLoggable() == + ::morph::model::Loggable::Yes) { + if (holder.hasActionLog()) { + ::morph::model::detail::recordActionFailure( + holder, std::string{::morph::model::ModelTraits::typeId()}, + std::string{::morph::model::ActionTraits::typeId()}, + ::morph::model::ActionTraits::toJson(actionRef), + ::morph::model::detail::actionPayloadSchema(), exc.what()); + } } + throw; } - throw; - } - }(); + }(); #ifdef _MSC_VER #pragma warning(pop) #endif - // Past this point the model's mutation has committed, so neither - // serialising the result nor appending the entry may be reported as - // an execution failure: both throw (ParseError; a sink that could - // not reach its backend), and inside the try above that throw would - // reject this call's Completion as if the model had refused the - // action and file an Outcome::Failed entry blaming the action for an - // infrastructure fault. ActionRecordingError says what is true - // instead -- the action ran, the recording of it did not -- and - // carries the result JSON the audit trail never received. - if constexpr (::morph::model::detail::actionLoggable() == ::morph::model::Loggable::Yes) { - if (holder.hasActionLog()) { - std::string resultJson; - try { - resultJson = ::morph::model::ActionTraits::resultToJson(*result); - // entityKey/principal/timestampMs are filled in by recordIfAttached. - ::morph::model::detail::recordActionSuccess( - holder, std::string{::morph::model::ModelTraits::typeId()}, - std::string{::morph::model::ActionTraits::typeId()}, - ::morph::model::ActionTraits::toJson(actionRef), - ::morph::model::detail::actionPayloadSchema(), resultJson); - } catch (const std::exception& exc) { - throw ::morph::model::ActionRecordingError{std::move(resultJson), exc.what()}; + // Past this point the model's mutation has committed, so neither + // serialising the result nor appending the entry may be reported as + // an execution failure: both throw (ParseError; a sink that could + // not reach its backend), and inside the try above that throw would + // reject this call's Completion as if the model had refused the + // action and file an Outcome::Failed entry blaming the action for an + // infrastructure fault. ActionRecordingError says what is true + // instead -- the action ran, the recording of it did not -- and + // carries the result JSON the audit trail never received. + if constexpr (::morph::model::detail::actionLoggable() == ::morph::model::Loggable::Yes) { + if (holder.hasActionLog()) { + std::string resultJson; + try { + resultJson = ::morph::model::ActionTraits::resultToJson(*result); + // entityKey/principal/timestampMs are filled in by recordIfAttached. + ::morph::model::detail::recordActionSuccess( + holder, std::string{::morph::model::ModelTraits::typeId()}, + std::string{::morph::model::ActionTraits::typeId()}, + ::morph::model::ActionTraits::toJson(actionRef), + ::morph::model::detail::actionPayloadSchema(), resultJson); + } catch (const std::exception& exc) { + throw ::morph::model::ActionRecordingError{std::move(resultJson), exc.what()}; + } } } - } - return result; + return result; #endif - }; + }; + } { std::scoped_lock const lock{_sessionMtx}; call.session = _defaultSession; diff --git a/include/morph/core/completion.hpp b/include/morph/core/completion.hpp index 7adc66726..ebaaa45c0 100644 --- a/include/morph/core/completion.hpp +++ b/include/morph/core/completion.hpp @@ -14,6 +14,7 @@ #include "../attributes.hpp" #include "callback_scope.hpp" +#include "detail/completion_awaiter.hpp" #include "executor.hpp" #include "logger.hpp" @@ -196,7 +197,16 @@ struct CompletionState : std::enable_shared_from_this> { // capturing that local *by copy* before moving it into the // handler costs two copies where the handler asked for at most // one, so a late attacher would pay 2 rather than 1. - fireNow = [self = this->shared_from_this(), handler = std::move(handler)]() { handler(*self->value); }; + fireNow = [self = this->shared_from_this(), handler = std::move(handler)]() { + // Isolated as the settle-time fan-out isolates each + // handler: a late attacher's throw is logged here rather + // than escaping into whatever runs the executor. + try { + handler(*self->value); + } catch (...) { + ::morph::log::logError("[completion] then handler threw"); + } + }; } else if (!ready) { onOk.push_back(std::move(handler)); } @@ -215,7 +225,14 @@ struct CompletionState : std::enable_shared_from_this> { onErrAttached = (cbExec != nullptr); if (ready && error) { auto savedErr = error; - fireNow = [handler = std::move(handler), savedErr]() mutable { handler(savedErr); }; + fireNow = [handler = std::move(handler), savedErr]() mutable { + // Isolated as in attachThen. + try { + handler(savedErr); + } catch (...) { + ::morph::log::logError("[completion] onError handler threw"); + } + }; } else if (!ready) { onErr.push_back(std::move(handler)); } @@ -514,6 +531,25 @@ class Completion { return onError(std::move(handler)); } + /// @brief Awaits this completion from a coroutine, consuming it. + /// + /// `co_await std::move(completion)` yields a copy of the settled value or + /// rethrows the stored exception. The coroutine resumes in the resumption + /// context it suspended in -- a `spawn`ed task's executor, a Task handler's + /// strand -- or, with none, on this completion's executor, where `then()` + /// handlers run. A stop requested on the awaiting coroutine's token + /// withdraws the await and resumes it with `core::async::OperationCancelled`. + /// Rvalue only: awaiting moves the state out, so an lvalue `co_await` would + /// hide that the completion is empty afterwards. See + /// `docs/spec/core/coroutines.md`. + /// @return The awaiter; not for direct use. + [[nodiscard]] detail::CompletionAwaiter operator co_await() && { + static_assert(std::copy_constructible, + "co_await on a morph::async::Completion copies the settled value out of the shared state, " + "which other handlers may still read, so T must be copy-constructible."); + return detail::CompletionAwaiter{std::move(_state)}; + } + /// @brief Returns the underlying shared state (for advanced / internal use). /// @return Shared pointer to the completion state, or `nullptr` for empty completions. [[nodiscard]] std::shared_ptr> state() const { return _state; } diff --git a/include/morph/core/coroutine.hpp b/include/morph/core/coroutine.hpp new file mode 100644 index 000000000..ad78603cf --- /dev/null +++ b/include/morph/core/coroutine.hpp @@ -0,0 +1,282 @@ +// SPDX-License-Identifier: Apache-2.0 + +#pragma once +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "completion.hpp" +#include "detail/completion_awaiter.hpp" +#include "detail/task_handler.hpp" +#include "executor.hpp" +#include "logger.hpp" +#include "timeout_scheduler.hpp" + +/// @file +/// @brief Coroutines on `core::async::Task`: awaiting a `Completion`, starting +/// a detached flow on a morph executor, and waiting on a timer. +/// +/// `Completion::operator co_await` itself lives in `completion.hpp`, so a +/// completion is awaitable wherever it is visible; this header adds `spawn` and +/// `delay`, and brings in what drives a Task-returning model handler. Specified +/// in `docs/spec/core/coroutines.md`. + +namespace morph::async { + +namespace detail { + +/// @brief Resumes coroutines through a `morph::exec::IExecutor`, as the +/// current executor of each resumption. +class ExecutorResumer final : public ::core::async::IExecutor, public std::enable_shared_from_this { +public: + /// @param executor Where every resumption is posted; must outlive them all. + explicit ExecutorResumer(::morph::exec::IExecutor& executor MORPH_LIFETIMEBOUND) : _executor{executor} {} + + using ::core::async::IExecutor::submit; + + /// @brief Posts @p handle's resumption to the executor. + /// @param handle The coroutine to resume; borrowed. + void submit(std::coroutine_handle<> handle) override { + _executor.post([self = shared_from_this(), handle] { + std::shared_ptr const keep = self; + ::core::async::ExecutorScope const scope{*self, &keep, nullptr}; + handle.resume(); + }); + } + + /// @brief Posts @p work's resumption to the executor. + /// @param work The coroutine to resume. The spawned task's driver owns its + /// frame, so the abandon claim is not taken. + void submit(::core::async::ParkedWork work) override { submit(work.resume); } + +private: + ::morph::exec::IExecutor& _executor; +}; + +/// @brief Suspends and resumes through @p target: how a spawned task's first +/// step reaches its executor. +struct HopTo { + ::core::async::IExecutor* target; + + // Called through the awaiter by the compiler, so not static. + // NOLINTNEXTLINE(readability-convert-member-functions-to-static) + [[nodiscard]] constexpr bool await_ready() const noexcept { return false; } + void await_suspend(std::coroutine_handle<> awaiting) const { target->submit(awaiting); } + void await_resume() const noexcept {} +}; + +/// @brief The detached coroutine `spawn` starts: hops onto the executor, runs +/// the task there and logs what it lets escape. +struct SpawnedTask { + // The compiler calls every member of a promise through the object, so none + // is made static. + // NOLINTBEGIN(readability-convert-member-functions-to-static) + struct promise_type { + [[nodiscard]] SpawnedTask get_return_object() const noexcept { return {}; } + [[nodiscard]] std::suspend_never initial_suspend() const noexcept { return {}; } + [[nodiscard]] std::suspend_never final_suspend() const noexcept { return {}; } + void return_void() const noexcept {} + // Only the hop can throw past the body's catch: an executor that cannot + // accept one closure, before anything has run. + [[noreturn]] void unhandled_exception() const noexcept { std::terminate(); } + }; + // NOLINTEND(readability-convert-member-functions-to-static) +}; + +/// @brief The body of `spawn`. +/// @param resumer The executor adapter; owned here for the task's lifetime. +/// @param task The task to run. +/// @return Nothing to hold: the coroutine frees itself when it finishes. +inline SpawnedTask runSpawned(std::shared_ptr resumer, ::core::async::Task task) { + co_await HopTo{resumer.get()}; + try { + co_await std::move(task); + } catch (const std::exception& exc) { + ::morph::log::logError("[spawn] task threw: {}", exc.what()); + } catch (...) { + ::morph::log::logError("[spawn] task threw unknown exception"); + } +} + +} // namespace detail + +/// @brief Starts @p task detached, with every resumption -- its first step +/// included -- posted to @p executor. +/// +/// The entry point from code that is not itself a coroutine: with a +/// `QtExecutor`, a GUI flow written as a coroutine runs every step on the GUI +/// thread. Awaits inside @p task resume on @p executor, whichever thread the +/// awaited operation completed on. An exception @p task lets escape is logged +/// through `morph::log` and swallowed. Its stop token is never stopped. +/// @param executor Where the task runs; must outlive it. +/// @param task The task to run. +inline void spawn(::morph::exec::IExecutor& executor, ::core::async::Task task) { + detail::runSpawned(std::make_shared(executor), std::move(task)); +} + +/// @brief The awaiter `delay()` returns: suspends until a `TimeoutScheduler` +/// entry fires, or until a stop withdraws it. +/// +/// Resumes on the executor the awaiting coroutine was running on (core-cpp's +/// current executor), or on the scheduler's thread if there was none. With a +/// stoppable token on the awaiting promise, a stop cancels the scheduler entry +/// -- releasing its capture at once -- and resumes the coroutine with +/// `core::async::OperationCancelled` on the same executor, or, with none, +/// inline on the thread that requested the stop. Exactly one of the timer and the stop resumes it. +class DelayAwaiter { + enum class Outcome : std::uint8_t { Pending, Fired, Cancelled }; + + struct Shared; + + struct OnStop { + Shared* shared; + void operator()() const noexcept { shared->onStop(); } + }; + + struct Shared : std::enable_shared_from_this { + std::atomic outcome{Outcome::Pending}; + std::atomic armed{false}; + std::atomic claimed{false}; + std::atomic timer{0}; + std::coroutine_handle<> continuation; + ::core::async::ResumeTarget context; + detail::TimeoutScheduler* scheduler = nullptr; + std::optional<::core::async::StopCallback> stopCallback; + + // seq_cst, as `armed` is: whoever decides and then reads `armed` must + // not miss the other side's store (a store-buffering pattern). + bool decide(Outcome outcome_) noexcept { + auto expected = Outcome::Pending; + return outcome.compare_exchange_strong(expected, outcome_, std::memory_order_seq_cst); + } + + void resume() const { + if (context) { + context.submit(::core::async::ParkedWork{.resume = continuation}); + } else { + continuation.resume(); + } + } + + void onStop() noexcept { + if (!decide(Outcome::Cancelled)) { + return; + } + try { + // The coroutine may be resumed below and free the awaiter; + // this keeps the state alive until the callback has returned. + auto const keep = shared_from_this(); + scheduler->cancel(timer.load()); + if (armed.load(std::memory_order_seq_cst) && !claimed.exchange(true, std::memory_order_acq_rel)) { + resume(); + } + } catch (...) { // NOLINT(bugprone-empty-catch): a stop callback must not throw + // No executor to resume through: the coroutine stays + // suspended until its owner destroys it. + } + } + }; + +public: + /// @param scheduler The scheduler whose entry times the wait; must outlive it. + /// @param duration How long to wait at least. + DelayAwaiter(detail::TimeoutScheduler& scheduler MORPH_LIFETIMEBOUND, std::chrono::milliseconds duration) + : _shared{std::make_shared()}, _duration{duration} { + _shared->scheduler = &scheduler; + } + + /// @brief Withdraws the wait if the frame is destroyed while suspended. + ~DelayAwaiter() { + _shared->stopCallback.reset(); + if (_shared->decide(Outcome::Cancelled)) { + _shared->scheduler->cancel(_shared->timer.load()); + } + } + + DelayAwaiter(const DelayAwaiter&) = delete; + DelayAwaiter& operator=(const DelayAwaiter&) = delete; + DelayAwaiter(DelayAwaiter&&) = delete; + DelayAwaiter& operator=(DelayAwaiter&&) = delete; + + /// @return Always false: every decision is `await_suspend`'s. + // Called through the awaiter by the compiler, so not static. + // NOLINTNEXTLINE(readability-convert-member-functions-to-static) + [[nodiscard]] constexpr bool await_ready() const noexcept { return false; } + + /// @tparam Promise The awaiting coroutine's promise type. + /// @param awaiting The coroutine performing the `co_await`. + /// @return False to continue at once, when a stop was already requested; + /// true once the timer is armed. + template + bool await_suspend(std::coroutine_handle awaiting) { + // Copied before `armed` is published: from then on a stop may resume + // the coroutine and free this awaiter with its frame. + auto shared = _shared; + auto const duration = _duration; + shared->continuation = awaiting; + shared->context = ::core::async::ResumeTarget::current(); + if constexpr (::core::async::HasStopToken) { + ::core::async::StopToken const token = awaiting.promise().stopToken(); + if (token.stop_requested()) { + shared->outcome.store(Outcome::Cancelled); + return false; + } + if (token.stop_possible()) { + shared->stopCallback.emplace(token, OnStop{shared.get()}); + } + } + shared->armed.store(true, std::memory_order_seq_cst); + if (shared->outcome.load(std::memory_order_seq_cst) == Outcome::Cancelled) { + return shared->claimed.exchange(true, std::memory_order_acq_rel); + } + auto const handle = shared->scheduler->schedule(duration, [shared] { + if (shared->decide(Outcome::Fired)) { + shared->resume(); + } + }); + shared->timer.store(handle); + // A stop that won between arming and here found no timer to cancel. + if (shared->outcome.load() == Outcome::Cancelled) { + shared->scheduler->cancel(handle); + } + return true; + } + + /// @throws core::async::OperationCancelled if a stop withdrew the wait. + void await_resume() { + _shared->stopCallback.reset(); + if (_shared->outcome.load() == Outcome::Cancelled) { + throw ::core::async::OperationCancelled{}; + } + } + +private: + std::shared_ptr _shared; + std::chrono::milliseconds _duration; +}; + +/// @brief Suspends the awaiting coroutine for at least @p duration. +/// +/// `co_await morph::async::delay(scheduler, 50ms)`. Stop-aware; see +/// `DelayAwaiter`. +/// @param scheduler The scheduler whose entry times the wait; must outlive it. +/// @param duration How long to wait at least. +/// @return The awaiter. +[[nodiscard]] inline DelayAwaiter delay(detail::TimeoutScheduler& scheduler MORPH_LIFETIMEBOUND, + std::chrono::milliseconds duration) { + return DelayAwaiter{scheduler, duration}; +} + +} // namespace morph::async diff --git a/include/morph/core/detail/completion_awaiter.hpp b/include/morph/core/detail/completion_awaiter.hpp new file mode 100644 index 000000000..051032293 --- /dev/null +++ b/include/morph/core/detail/completion_awaiter.hpp @@ -0,0 +1,238 @@ +// SPDX-License-Identifier: Apache-2.0 + +#pragma once +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "../callback_scope.hpp" +#include "../executor.hpp" + +/// @file +/// @brief The awaiter behind `Completion::operator co_await`. +/// +/// A fragment of `completion.hpp` rather than of `coroutine.hpp`, so a +/// `Completion` is awaitable wherever it is visible. Specified in +/// `docs/spec/core/coroutines.md`. + +namespace morph::async::detail { + +template +struct CompletionState; + +/// @brief The awaiter `Completion::operator co_await() &&` returns. +/// +/// The await is one more `then`/`onError` pair on the completion, holding only +/// a heap `Shared` -- never the coroutine frame -- and gated by a +/// `CallbackToken`. With a stoppable token on the awaiting promise it also +/// registers a stop callback. Exactly one of *the completion's handler* and +/// *the stop callback* moves `Shared::outcome` off `Pending`, and only that one +/// resumes the coroutine. +/// +/// `await_suspend` touches only local copies of the shared pointers once it has +/// published anything another thread could act on: from then on the coroutine +/// may already have been resumed elsewhere, and the awaiter with its frame +/// destroyed. +/// @tparam T The completion's value type; copied out of the settled state. +template +class CompletionAwaiter { + enum class Outcome : std::uint8_t { Pending, Settled, Cancelled }; + + struct Shared; + + /// The stop callback's callable: forwards to `Shared::onStop`. + struct OnStop { + Shared* shared; + void operator()() const noexcept { shared->onStop(); } + }; + + struct Shared { + std::atomic outcome{Outcome::Pending}; + /// Set once `await_suspend` has finished registering; before that a + /// stop only records itself and `await_suspend` answers it. + std::atomic armed{false}; + /// Taken by whichever of `await_suspend` and `onStop` answers a stop. + std::atomic claimed{false}; + std::optional value; + std::exception_ptr error; + std::coroutine_handle<> continuation; + /// Where the coroutine was running when it suspended: resumed there. + /// Empty outside every executor's task. + ::core::async::ResumeTarget context; + ::morph::exec::IExecutor* fallback = nullptr; + CallbackScope scope; + std::optional<::core::async::StopCallback> stopCallback; + + // seq_cst, as `armed` is: whoever decides and then reads `armed` must + // not miss the other side's store (a store-buffering pattern). + bool decide(Outcome outcome_) noexcept { + auto expected = Outcome::Pending; + return outcome.compare_exchange_strong(expected, outcome_, std::memory_order_seq_cst); + } + + /// A settled completion, on the completion's executor: resumes in the + /// suspending context, or right here. + void resumeSettled() { + if (context) { + context.submit(::core::async::ParkedWork{.resume = continuation}); + } else { + continuation.resume(); + } + } + + /// A stop, on whichever thread requested it: resumes through the + /// suspending context, or on the completion's executor. Not inline + /// here, because the requesting thread is not the coroutine's; the + /// context itself may resume inline (a Task handler's resumer whose + /// strands are closed does). + void resumeCancelled() { + if (context) { + context.submit(::core::async::ParkedWork{.resume = continuation}); + } else { + fallback->post([handle = continuation] { handle.resume(); }); + } + } + + void onStop() noexcept { + if (!decide(Outcome::Cancelled)) { + return; + } + scope.requestStop(); + if (armed.load(std::memory_order_seq_cst) && !claimed.exchange(true, std::memory_order_acq_rel)) { + try { + resumeCancelled(); + } catch (...) { // NOLINT(bugprone-empty-catch): a stop callback must not throw + // A queue that cannot accept one more closure leaves nothing to + // resume through; the coroutine stays suspended until its + // owner destroys it. + } + } + } + }; + +public: + /// @param state The completion's state; consumed. Null for an empty completion. + explicit CompletionAwaiter(std::shared_ptr> state) + : _state{std::move(state)}, _shared{std::make_shared()} {} + + /// Withdraws the await if the frame is destroyed while suspended, so neither + /// a later settlement nor a later stop resumes a frame that is gone. + ~CompletionAwaiter() { + _shared->stopCallback.reset(); + _shared->scope.requestStop(); + } + + CompletionAwaiter(const CompletionAwaiter&) = delete; + CompletionAwaiter& operator=(const CompletionAwaiter&) = delete; + CompletionAwaiter(CompletionAwaiter&&) = delete; + CompletionAwaiter& operator=(CompletionAwaiter&&) = delete; + + /// @return Always false: every decision is `await_suspend`'s, so this stays + /// a constant (see core-cpp's `awaitReadyIsConstantFalse`). + [[nodiscard]] constexpr bool await_ready() const noexcept { return false; } + + /// @tparam Promise The awaiting coroutine's promise type. + /// @param awaiting The coroutine performing the `co_await`. + /// @return False to continue at once (an unusable completion, or a stop + /// already requested); true once the await is attached. + template + bool await_suspend(std::coroutine_handle awaiting) { + auto shared = _shared; + auto state = _state; + if (state == nullptr || state->cbExec == nullptr) { + shared->error = std::make_exception_ptr(std::logic_error{ + state == nullptr ? "co_await on an empty morph::async::Completion (default-constructed or moved-from)" + : "co_await on a morph::async::Completion with no callback executor to resume on"}); + shared->outcome.store(Outcome::Settled); + return false; + } + shared->continuation = awaiting; + shared->context = ::core::async::ResumeTarget::current(); + shared->fallback = state->cbExec; + + if constexpr (::core::async::HasStopToken) { + ::core::async::StopToken const token = awaiting.promise().stopToken(); + if (token.stop_requested()) { + shared->outcome.store(Outcome::Cancelled); + return false; + } + if (token.stop_possible()) { + shared->stopCallback.emplace(token, OnStop{shared.get()}); + shared->armed.store(true, std::memory_order_seq_cst); + if (shared->outcome.load(std::memory_order_seq_cst) == Outcome::Cancelled) { + // A stop landed while registering. Whoever claims answers + // it; if onStop claimed first it is resuming us already. + return shared->claimed.exchange(true, std::memory_order_acq_rel); + } + } + } + + auto const token = shared->scope.token(); + try { + state->attachThen([token, shared](const T& settled) { + if (token.active() && shared->decide(Outcome::Settled)) { + shared->value.emplace(settled); + shared->resumeSettled(); + } + }); + state->attachOnError([token, shared](std::exception_ptr error) { + if (token.active() && shared->decide(Outcome::Settled)) { + shared->error = std::move(error); + shared->resumeSettled(); + } + }); + } catch (...) { + // The throw resumes the coroutine, so nothing else may. If a stop + // has claimed the await it is resuming the coroutine already, and + // the frame is no longer this call's to throw into: that stop's + // OperationCancelled is what the coroutine sees. + if (shared->claimed.exchange(true, std::memory_order_acq_rel)) { + shared->scope.requestStop(); + return true; + } + // Likewise if the handler attached before the throw has already + // settled the await: it resumed the coroutine. + if (!shared->decide(Outcome::Settled) && shared->outcome.load() == Outcome::Settled) { + shared->scope.requestStop(); + return true; + } + // Still suspended, and decided: withdraw the stop callback (waiting + // out one that is running) and the handler attached before the throw. + shared->stopCallback.reset(); + shared->scope.requestStop(); + throw; + } + return true; + } + + /// @return The settled value. + /// @throws core::async::OperationCancelled if a stop withdrew the await. + /// @throws Whatever the completion was rejected with. + /// @throws std::logic_error for an empty completion or one with no executor. + T await_resume() { + _shared->stopCallback.reset(); + if (_shared->outcome.load() == Outcome::Cancelled) { + throw ::core::async::OperationCancelled{}; + } + if (_shared->error) { + std::rethrow_exception(_shared->error); + } + return std::move(*_shared->value); + } + +private: + std::shared_ptr> _state; + std::shared_ptr _shared; +}; + +} // namespace morph::async::detail diff --git a/include/morph/core/detail/task_handler.hpp b/include/morph/core/detail/task_handler.hpp new file mode 100644 index 000000000..81fe8f7cc --- /dev/null +++ b/include/morph/core/detail/task_handler.hpp @@ -0,0 +1,266 @@ +// SPDX-License-Identifier: Apache-2.0 + +#pragma once +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "../strand.hpp" + +/// @file +/// @brief What drives a model's Task handler: the driver coroutine that starts +/// and finishes it, the per-instance gate that keeps actions from +/// overlapping, and the trait that tells a Task handler from an ordinary +/// one. The resumer its resumptions go through is `strand.hpp`'s. +/// +/// Specified in `docs/spec/core/coroutines.md`, "Model side". + +namespace morph::model { + +/// @brief The result an action handler's return type stands for: `R` for a +/// `core::async::Task` handler, the type itself for any other. +/// @tparam T The handler's return type. +template +struct HandlerResult { + /// @brief The action's result type. + using type = T; + /// @brief Whether the handler is a coroutine returning `core::async::Task`. + static constexpr bool isTask = false; +}; + +/// @brief `HandlerResult` for a Task handler. +/// @tparam R The value the Task produces. +template +struct HandlerResult<::core::async::Task> { + /// @brief The action's result type: what the Task produces. + using type = R; + /// @brief Whether the handler is a coroutine returning `core::async::Task`. + static constexpr bool isTask = true; +}; + +/// @brief The action result a handler returning @p T produces. +/// @tparam T The handler's return type. +template +using HandlerResultT = HandlerResult::type; + +/// @brief Whether a handler returning @p T is a Task handler. +/// @tparam T The handler's return type. +template +inline constexpr bool isTaskHandler = HandlerResult::isTask; + +namespace detail { + +/// @brief Keeps a model instance's actions from overlapping, across a Task +/// handler's suspensions. +/// +/// A strand serialises the tasks posted to it, and a suspended Task handler is +/// not one: while it waits the strand is free. Every action therefore enters +/// this gate on the strand before it runs and leaves it when it is finished -- +/// for a Task handler, when the Task completes. An action that finds the gate +/// held is queued, in arrival order, and started by the `leave()` that frees +/// it. Touched only on the model's strand, so it takes no lock. +class ActionGate { +public: + /// @brief Starts @p start now, or queues it behind the action holding the gate. + /// + /// The started action holds the gate until it calls `leave()`. + /// + /// A template rather than a `std::function` parameter: an action that + /// starts at once is called as it is, and only one that has to wait is + /// type-erased into the queue. libstdc++'s `std::function` heap-allocates + /// any callable that is not trivially copyable, a lambda holding a + /// `shared_ptr` included, and an action on a free gate need not pay that. + /// @param start The action; must not throw. Copyable, as the queue's + /// `std::function` requires. + template + void enter(Start&& start) { + Occupancy const occupancy{*this}; + if (_held || (_waiting != nullptr && !_waiting->empty())) { + if (_waiting == nullptr) { + _waiting = std::make_unique>>(); + } + _waiting->emplace_back(std::forward(start)); + return; + } + _held = true; + start(); + } + + /// @brief Takes the gate if it is free, for an action its caller then runs + /// itself; otherwise leaves it to `enter` to queue the action. + /// + /// For a caller whose action stays owned where it is while it runs: a + /// backend's strand task, which holds an ordinary run by value, so an + /// action on a free gate costs no allocation beyond the post. + /// @return True if taken: the caller runs its action now, and the action + /// holds the gate until it calls `leave()`. False if the action has + /// to wait, through `enter`. + [[nodiscard]] bool tryEnter() { + Occupancy const occupancy{*this}; + if (_held || (_waiting != nullptr && !_waiting->empty())) { + return false; + } + _held = true; + return true; + } + + /// @brief Releases the gate and starts the actions queued behind it, oldest + /// first, for as long as each one finishes without suspending. + /// + /// A loop rather than a recursion: a queue of ordinary handlers would + /// otherwise nest one stack frame per queued action. + void leave() { + Occupancy const occupancy{*this}; + _held = false; + if (_draining) { + return; + } + _draining = true; + while (!_held && _waiting != nullptr && !_waiting->empty()) { + auto next = std::move(_waiting->front()); + _waiting->pop_front(); + _held = true; + next(); + } + _draining = false; + } + + /// @brief How many times, process-wide, two threads have been inside + /// `enter` or `leave` of one gate at once. + /// + /// Always zero when the gate is used as specified: a gate is touched only + /// on its model's strand. A diagnostic for tests, which assert that it + /// does not change; the check that counts is one atomic compare-exchange + /// per call. + /// @return The count so far. + [[nodiscard]] static std::size_t overlapsObserved() noexcept { return overlapCounter().load(); } + +private: + static std::atomic& overlapCounter() noexcept { + static std::atomic counter{0}; + return counter; + } + + /// Marks the calling thread as inside the gate for the scope's lifetime, + /// and counts it if another thread already is. Reentry on the same thread, + /// which `leave()` starting the next action does, is not an overlap. + class Occupancy { + public: + explicit Occupancy(ActionGate& gate) noexcept : _gate{&gate} { + auto expected = std::thread::id{}; + auto const self = std::this_thread::get_id(); + _owner = _gate->_occupant.compare_exchange_strong(expected, self); + if (!_owner && expected != self) { + overlapCounter().fetch_add(1); + } + } + Occupancy(const Occupancy&) = delete; + Occupancy& operator=(const Occupancy&) = delete; + Occupancy(Occupancy&&) = delete; + Occupancy& operator=(Occupancy&&) = delete; + ~Occupancy() { + if (_owner) { + _gate->_occupant.store(std::thread::id{}); + } + } + + private: + ActionGate* _gate; + bool _owner = false; + }; + + std::atomic _occupant; + bool _held = false; + bool _draining = false; + /// Allocated the first time an action has to wait, which only a suspended + /// Task handler causes: every model instance carries a gate, and one that + /// never queues should cost a model holder nothing but a pointer. + std::unique_ptr>> _waiting; +}; + +/// @brief The coroutine that runs one Task handler to completion. +/// +/// Created suspended so its promise's stop token can be set before the first +/// step; `startTaskHandler` resumes it. It frees itself at the end +/// (`final_suspend` never suspends). Its promise answers `stopToken()`, which is +/// how the token reaches the handler: `Task`'s awaiter copies the awaiting +/// promise's token into the handler's. +struct TaskHandlerDriver { + // The compiler calls every member of a promise through the object, so none + // is made static. + // NOLINTBEGIN(readability-convert-member-functions-to-static) + struct promise_type { + ::core::async::StopToken token; + + TaskHandlerDriver get_return_object() noexcept { + return TaskHandlerDriver{std::coroutine_handle::from_promise(*this)}; + } + [[nodiscard]] std::suspend_always initial_suspend() const noexcept { return {}; } + [[nodiscard]] std::suspend_never final_suspend() const noexcept { return {}; } + void return_void() const noexcept {} + // Every exception is caught in the driver's body, which settles the call + // with it; one escaping here would mean the settle itself threw. + [[noreturn]] void unhandled_exception() const noexcept { std::terminate(); } + [[nodiscard]] ::core::async::StopToken stopToken() const noexcept { return token; } + }; + // NOLINTEND(readability-convert-member-functions-to-static) + + std::coroutine_handle handle; +}; + +/// @brief The driver body: awaits @p task and hands its outcome to @p done. +/// @tparam R The handler's result type. +/// @param executor The handler's resumer. Held in the frame, so it lives until +/// the handler has finished whatever holds it besides. +/// @param task The handler's Task, not yet started. +/// @param done Called once, on the strand, with the result or the exception. +/// @return The suspended driver. +template +TaskHandlerDriver driveTaskHandler(std::shared_ptr<::morph::exec::detail::TaskResumer> executor, + ::core::async::Task task, + std::function, std::exception_ptr)> done) { + static_cast(executor); + std::optional result; + std::exception_ptr error; + try { + // The await is its own statement: MSVC cannot tail-call a call that + // shares a full-expression with a `co_await` (C4737). + R value = co_await std::move(task); + result.emplace(std::move(value)); + } catch (...) { + error = std::current_exception(); + } + done(std::move(result), error); +} + +/// @brief Starts a Task handler on the model's strand, in the calling strand task. +/// +/// The handler's first step runs here, inside @p executor -- the current +/// executor, with the action's session -- and with the stop token set; every +/// later step is resumed through @p executor, on the same strand. +/// @tparam R The handler's result type. +/// @param executor The handler's resumer. +/// @param task The handler's Task, as the handler call returned it. +/// @param token The stop token the handler observes. +/// @param done Called once, on the strand, with the result or the exception. +template +void startTaskHandler(const std::shared_ptr<::morph::exec::detail::TaskResumer>& executor, ::core::async::Task task, + ::core::async::StopToken token, std::function, std::exception_ptr)> done) { + auto driver = driveTaskHandler(executor, std::move(task), std::move(done)); + driver.handle.promise().token = std::move(token); + executor->resumeHere(driver.handle); +} + +} // namespace detail + +} // namespace morph::model diff --git a/include/morph/core/model.hpp b/include/morph/core/model.hpp index 38fe46762..b91e340ef 100644 --- a/include/morph/core/model.hpp +++ b/include/morph/core/model.hpp @@ -16,6 +16,7 @@ // masking; this file uses std::same_as and `concept` without including it. #include "../journal/action_log.hpp" #include "../session/session.hpp" +#include "detail/task_handler.hpp" #include "strand.hpp" namespace morph::model::detail { @@ -132,6 +133,15 @@ struct IModelHolder { /// virtual — no `dynamic_cast`, no RTTI dependency on this path. virtual void onBackendChanged() {} + /// @brief The gate every action on this instance enters before it runs. + /// + /// Held from an action's start to its end -- for a Task handler, until its + /// Task completes -- so the next action does not start while a handler is + /// suspended. Touched only on this instance's strand. See `ActionGate` and + /// `docs/spec/core/coroutines.md`, "Not re-entrant: the action gate". + /// @return This instance's gate. + [[nodiscard]] ActionGate& actionGate() noexcept { return _actionGate; } + /// @brief Down-casts to a concrete `Model` reference. /// /// @tparam Model The expected concrete type. @@ -284,6 +294,7 @@ struct IModelHolder { virtual void onIdentityAttached(const std::string& primaryKey) { (void)primaryKey; } private: + ActionGate _actionGate; std::shared_ptr<::morph::journal::IActionLog> _actionLog; std::string _contextKey; bool _outboxManaged{false}; diff --git a/include/morph/core/observability.hpp b/include/morph/core/observability.hpp index 0d931a052..68c6c35d0 100644 --- a/include/morph/core/observability.hpp +++ b/include/morph/core/observability.hpp @@ -125,7 +125,7 @@ inline ObserveState& observeState() { /// them, precisely so a completion callback cannot observe the dispatch as /// finished before its metrics land. An exception thrown out of instrumentation /// would therefore skip `setValue`/`setException` entirely and be swallowed by -/// `StrandExecutor`'s catch-and-log, leaving that `Completion` unsettled +/// the strand's catch-and-log (`LoggedTask`), leaving that `Completion` unsettled /// forever — a hung caller with neither a value nor an error, caused by a bug /// in a metrics callback. /// diff --git a/include/morph/core/registry.hpp b/include/morph/core/registry.hpp index 9afbfb3c6..ed9c779c7 100644 --- a/include/morph/core/registry.hpp +++ b/include/morph/core/registry.hpp @@ -652,6 +652,17 @@ class ActionDispatcher { /// @brief Type-erased action runner: deserialises, executes, and serialises the result. using Runner = std::function; + /// @brief Receives the outcome of an asynchronous dispatch: the JSON result, + /// or the exception (with an empty result). + using DispatchDone = std::function; + + /// @brief Type-erased runner for a Task handler: deserialises, starts the + /// handler on the model's strand, and hands the serialised result or + /// the exception to its last argument when the Task completes. + using AsyncRunner = void (*)(IModelHolder&, std::string_view, + const std::shared_ptr<::morph::exec::detail::TaskResumer>&, ::core::async::StopToken, + DispatchDone); + /// @brief Registers a runner for `(Model, Action)` under the given string ids. /// /// This is the single execution site used by `RemoteServer` (every remote and @@ -693,53 +704,95 @@ class ActionDispatcher { template void registerAction(std::string_view modelId, std::string_view actionId) { ActionEntry& entry = _actions[Key{std::string{modelId}, std::string{actionId}}]; - entry.runner = [](IModelHolder& holder, std::string_view payloadJson) { - auto action = ActionTraits::fromJson(payloadJson); - // Retag any Quantity fields to their declared precision so a - // hand-built wire payload matches the schema's advertised - // x-decimalPlaces, exactly as the client bridge dispatch path - // (ActionExecuteRegistry::registerAction, bridge.hpp) already does. - // No-op for actions with no Quantity members. See - // docs/spec/forms/forms.md. - ::morph::forms::reconcileDeclaredPrecision(action); - // Pre-decode wire validation seam: reject a Quantity field whose - // engaged value falls outside its unit's declared bounds - // (UnitTraits::bounds), before the action's own validate() - // (a business-rule check) ever runs. No-op for actions with no - // Quantity members, or whose units declare no bounds(). See - // docs/spec/forms/forms.md, "Pre-decode wire validation". - ::morph::forms::enforceQuantityBounds(action); - // Overwrite any computed fields from their declared inputs. This is - // the true server-side execution site for every remote and Qt - // WebSocket topology (RemoteServer -> ActionDispatcher::dispatch) -- - // the one path a hand-built wire envelope reaches directly, - // bypassing every client-side gate. A tampered computed value on - // the wire is discarded here, before the validator check and - // Model::execute run. No-op for actions with no computedFields. See - // docs/spec/forms/forms.md. - ::morph::forms::recomputeAll(action); - // Enforce the action's validator on the server dispatch path — the - // one path an untrusted remote client can drive directly with a - // hand-built envelope, bypassing the client-side gates - // (morph::flows::FlowSession::set<> and - // ActionExecuteRegistry::registerAction). ActionValidator::ready - // auto-detects a `bool validate() const` member and defaults to - // `true` for actions with no validator, so this is a no-op for - // unvalidated actions (zero behavior change) and a hard gate for - // validated ones. The exception propagates out of this lambda to - // ActionDispatcher::dispatch's caller (RemoteServer::dispatchExecute's - // strand catch turns it into an `err` reply). - if (!ActionValidator::ready(action)) { - throw ValidationError{ModelTraits::typeId(), ActionTraits::typeId()}; - } - auto& model = holder.template into(); - // Model::execute is the only call inside this try, because it is - // the only one whose failure means the action was rejected. A - // rejected or throwing execution -- a validation failure, a lost - // connection, a rejected write -- records Outcome::Failed (when a - // log is attached and Action is loggable) so it leaves an audit - // trail rather than silence, and the exception is rethrown - // unchanged. See docs/spec/journal/journal.md, "Outcome". + if constexpr (isTaskHandler().execute(std::declval()))>) { + entry.asyncRunner = &runTaskHandler; + entry.runner = [](IModelHolder&, std::string_view) -> std::string { + throw std::logic_error{ + "ActionDispatcher::dispatch: this action's handler returns core::async::Task and cannot complete " + "synchronously; dispatch it with dispatchAsync"}; + }; + } else { + entry.runner = &runHandler; + } + entry.coalesce = ActionLogPolicy::coalesce; + entry.schema = detail::actionPayloadSchema(); + // Deliberately a thunk, not the description itself: registration runs + // at static-init time, where `buildActionDescription`'s throw path (an + // action with self-contradicting `formRules`) would abort the process + // before `main` rather than surface as an `err` reply. The thunk defers + // the whole computation to the first caller who asks. + entry.describe = []() -> const ActionDescription& { return actionDescription(); }; + } + +private: + /// @brief Decodes @p payloadJson into an `Action` and applies every gate + /// that must run before any handler sees it. + /// @tparam Model Concrete model type. + /// @tparam Action Concrete action type. + /// @param payloadJson The action's JSON body. + /// @return The decoded, reconciled, recomputed and validated action. + /// @throws ValidationError if the action fails `ActionValidator::ready`. + template + static Action prepareAction(std::string_view payloadJson) { + auto action = ActionTraits::fromJson(payloadJson); + // Retag any Quantity fields to their declared precision so a + // hand-built wire payload matches the schema's advertised + // x-decimalPlaces, exactly as the client bridge dispatch path + // (ActionExecuteRegistry::registerAction, bridge.hpp) already does. + // No-op for actions with no Quantity members. See + // docs/spec/forms/forms.md. + ::morph::forms::reconcileDeclaredPrecision(action); + // Pre-decode wire validation seam: reject a Quantity field whose + // engaged value falls outside its unit's declared bounds + // (UnitTraits::bounds), before the action's own validate() + // (a business-rule check) ever runs. No-op for actions with no + // Quantity members, or whose units declare no bounds(). See + // docs/spec/forms/forms.md, "Pre-decode wire validation". + ::morph::forms::enforceQuantityBounds(action); + // Overwrite any computed fields from their declared inputs. This is + // the true server-side execution site for every remote and Qt + // WebSocket topology (RemoteServer -> ActionDispatcher::dispatch) -- + // the one path a hand-built wire envelope reaches directly, + // bypassing every client-side gate. A tampered computed value on + // the wire is discarded here, before the validator check and + // Model::execute run. No-op for actions with no computedFields. See + // docs/spec/forms/forms.md. + ::morph::forms::recomputeAll(action); + // Enforce the action's validator on the server dispatch path — the + // one path an untrusted remote client can drive directly with a + // hand-built envelope, bypassing the client-side gates + // (morph::flows::FlowSession::set<> and + // ActionExecuteRegistry::registerAction). ActionValidator::ready + // auto-detects a `bool validate() const` member and defaults to + // `true` for actions with no validator, so this is a no-op for + // unvalidated actions (zero behavior change) and a hard gate for + // validated ones. The exception propagates out of this lambda to + // ActionDispatcher::dispatch's caller (RemoteServer::dispatchExecute's + // strand catch turns it into an `err` reply). + if (!ActionValidator::ready(action)) { + throw ValidationError{ModelTraits::typeId(), ActionTraits::typeId()}; + } + return action; + } + + /// @brief The runner of an ordinary handler: prepares the action, runs + /// `Model::execute`, journals the outcome and returns the JSON result. + /// @tparam Model Concrete model type. + /// @tparam Action Concrete action type. + /// @param holder The model instance. + /// @param payloadJson The action's JSON body. + /// @return The JSON-encoded result. + template + static std::string runHandler(IModelHolder& holder, std::string_view payloadJson) { + auto action = prepareAction(payloadJson); + auto& model = holder.template into(); + // Model::execute is the only call inside this try, because it is + // the only one whose failure means the action was rejected. A + // rejected or throwing execution -- a validation failure, a lost + // connection, a rejected write -- records Outcome::Failed (when a + // log is attached and Action is loggable) so it leaves an audit + // trail rather than silence, and the exception is rethrown + // unchanged. See docs/spec/journal/journal.md, "Outcome". // MSVC's C4702 fires on the `return` below for any action whose handler never // returns -- a test double whose body is a bare `throw`, for instance. The // warning is correct for that instantiation and wrong as a verdict on this @@ -750,62 +803,138 @@ class ActionDispatcher { #pragma warning(push) #pragma warning(disable : 4702) #endif - auto result = [&] { - try { - return model.execute(action); - } catch (const std::exception& exc [[maybe_unused]]) { - if constexpr (detail::actionLoggable() == Loggable::Yes) { - if (holder.hasActionLog()) { - detail::recordActionFailure(holder, std::string{ModelTraits::typeId()}, - std::string{ActionTraits::typeId()}, - std::string{payloadJson}, - detail::actionPayloadSchema(), exc.what()); - } - } - throw; - } - }(); -#ifdef _MSC_VER -#pragma warning(pop) -#endif - // Past this point the model's mutation has committed, so neither - // step below may be reported as an execution failure. Both can - // still throw -- resultToJson raises ParseError, and a sink that - // could not reach its backend is required to throw (see - // journal/action_log.hpp) -- and inside the try above that throw - // would tell the caller a durable write was rejected and file an - // Outcome::Failed entry naming the infrastructure fault as the - // action's own error. ActionRecordingError instead says what is - // true: the action ran, the reporting of it did not. A throw from - // resultToJson leaves no entry at all, because a Succeeded entry - // carries the result and there is none to carry. - std::string resultJson; + auto result = [&] { try { - resultJson = ActionTraits::resultToJson(result); + return model.execute(action); + } catch (const std::exception& exc [[maybe_unused]]) { if constexpr (detail::actionLoggable() == Loggable::Yes) { if (holder.hasActionLog()) { - // entityKey/principal/timestampMs are filled in by recordIfAttached. - detail::recordActionSuccess(holder, std::string{ModelTraits::typeId()}, + detail::recordActionFailure(holder, std::string{ModelTraits::typeId()}, std::string{ActionTraits::typeId()}, std::string{payloadJson}, detail::actionPayloadSchema(), - resultJson); + exc.what()); } } - } catch (const std::exception& exc) { - throw ActionRecordingError{std::move(resultJson), exc.what()}; + throw; } - return resultJson; - }; - entry.coalesce = ActionLogPolicy::coalesce; - entry.schema = detail::actionPayloadSchema(); - // Deliberately a thunk, not the description itself: registration runs - // at static-init time, where `buildActionDescription`'s throw path (an - // action with self-contradicting `formRules`) would abort the process - // before `main` rather than surface as an `err` reply. The thunk defers - // the whole computation to the first caller who asks. - entry.describe = []() -> const ActionDescription& { return actionDescription(); }; + }(); +#ifdef _MSC_VER +#pragma warning(pop) +#endif + // Past this point the model's mutation has committed, so neither + // step below may be reported as an execution failure. Both can + // still throw -- resultToJson raises ParseError, and a sink that + // could not reach its backend is required to throw (see + // journal/action_log.hpp) -- and inside the try above that throw + // would tell the caller a durable write was rejected and file an + // Outcome::Failed entry naming the infrastructure fault as the + // action's own error. ActionRecordingError instead says what is + // true: the action ran, the reporting of it did not. A throw from + // resultToJson leaves no entry at all, because a Succeeded entry + // carries the result and there is none to carry. + std::string resultJson; + try { + resultJson = ActionTraits::resultToJson(result); + if constexpr (detail::actionLoggable() == Loggable::Yes) { + if (holder.hasActionLog()) { + // entityKey/principal/timestampMs are filled in by recordIfAttached. + detail::recordActionSuccess(holder, std::string{ModelTraits::typeId()}, + std::string{ActionTraits::typeId()}, std::string{payloadJson}, + detail::actionPayloadSchema(), resultJson); + } + } + } catch (const std::exception& exc) { + throw ActionRecordingError{std::move(resultJson), exc.what()}; + } + return resultJson; } + /// @brief The runner of a Task handler: prepares the action, starts the + /// handler on the model's strand, and -- when its Task completes -- + /// journals the outcome and hands the JSON result or the exception to + /// @p done. See `docs/spec/core/coroutines.md`. + /// @tparam Model Concrete model type. + /// @tparam Action Concrete action type. + /// @param holder The model instance; kept alive by @p done's owner until + /// @p done has run. + /// @param payloadJson The action's JSON body. + /// @param executor The handler's resumer, on the model's strand. + /// @param token The stop token the handler observes. + /// @param done Called exactly once, on the strand. + template + // runHandler's decode, validation and journalling, around a Task instead of + // a call; kept in one piece for the same reason runHandler is. + // NOLINTNEXTLINE(readability-function-cognitive-complexity) + static void runTaskHandler(IModelHolder& holder, std::string_view payloadJson, + const std::shared_ptr<::morph::exec::detail::TaskResumer>& executor, + ::core::async::StopToken token, DispatchDone done) { + using R = HandlerResultT().execute(std::declval()))>; + // Owned here and kept by the completion callback: the handler may take + // the action by reference, and its frame outlives this call. + std::shared_ptr action; + ::core::async::Task task; + try { + action = std::make_shared(prepareAction(payloadJson)); + task = holder.template into().execute(*action); + } catch (...) { + done(std::string{}, std::current_exception()); + return; + } + detail::startTaskHandler( + executor, std::move(task), std::move(token), + [&holder, action, payload = std::string{payloadJson}, done = std::move(done)]( + std::optional result, const std::exception_ptr& error) mutable { + if (error) { + // The handler failed, so the action was rejected: recorded + // Outcome::Failed, as runHandler records a throw from + // Model::execute. A journal write that throws here fails the + // call with the handler's own exception still. + try { + if constexpr (detail::actionLoggable() == Loggable::Yes) { + if (holder.hasActionLog()) { + try { + std::rethrow_exception(error); + } catch (const std::exception& exc) { + detail::recordActionFailure(holder, std::string{ModelTraits::typeId()}, + std::string{ActionTraits::typeId()}, payload, + detail::actionPayloadSchema(), exc.what()); + } catch (...) { // NOLINT(bugprone-empty-catch): as runHandler + // Not a std::exception: recorded nowhere, as runHandler's + // rethrow leaves such a throw unrecorded. + } + } + } + } catch (...) { // NOLINT(bugprone-empty-catch): the handler's exception is reported + } + done(std::string{}, error); + return; + } + // The Task completed, so the model's mutation has committed: as in + // runHandler, failing to serialise the result or to record it is an + // ActionRecordingError, never a rejection. + std::string resultJson; + try { + resultJson = ActionTraits::resultToJson(*result); + if constexpr (detail::actionLoggable() == Loggable::Yes) { + if (holder.hasActionLog()) { + detail::recordActionSuccess(holder, std::string{ModelTraits::typeId()}, + std::string{ActionTraits::typeId()}, payload, + detail::actionPayloadSchema(), resultJson); + } + } + } catch (const std::exception& exc) { + done(std::string{}, + std::make_exception_ptr(ActionRecordingError{std::move(resultJson), exc.what()})); + return; + } catch (...) { + done(std::string{}, std::current_exception()); + return; + } + done(std::move(resultJson), nullptr); + }); + } + +public: /// @brief Dispatches an action against @p holder and returns the JSON-encoded result. std::string dispatch(std::string_view modelId, std::string_view actionId, IModelHolder& holder, std::string_view payload) { @@ -827,6 +956,61 @@ class ActionDispatcher { return iter->second.runner(holder, payload); } + /// @brief Dispatches an action against @p holder and hands its JSON result, + /// or its exception, to @p done -- at once for an ordinary handler, + /// when its Task completes for a Task handler. + /// + /// Called on the model's strand. A Task handler is started there and + /// resumed through @p executor; see `docs/spec/core/coroutines.md`. + /// @param modelId Model type-id. + /// @param actionId Action type-id. + /// @param holder The model instance; must stay alive until @p done has run. + /// @param payload The action's JSON body. + /// @param executor The handler's resumer, on the model's strand. + /// @param token The stop token a Task handler observes. + /// @param done Called exactly once, with the result or the exception. + void dispatchAsync(std::string_view modelId, std::string_view actionId, IModelHolder& holder, + std::string_view payload, const std::shared_ptr<::morph::exec::detail::TaskResumer>& executor, + ::core::async::StopToken token, DispatchDone done) { + const ActionEntry* entry = nullptr; + try { + detail::noteRegistryRead(this == &defaultDispatcher()); + auto iter = _actions.find(detail::PairKeyView{modelId, actionId}); + if (iter == _actions.end()) { + throw std::runtime_error("unknown action: " + std::string{modelId} + "/" + std::string{actionId}); + } + entry = &iter->second; + } catch (...) { + done(std::string{}, std::current_exception()); + return; + } + if (entry->asyncRunner != nullptr) { + entry->asyncRunner(holder, payload, executor, std::move(token), std::move(done)); + return; + } + std::string result; + try { + result = entry->runner(holder, payload); + } catch (...) { + done(std::string{}, std::current_exception()); + return; + } + done(std::move(result), nullptr); + } + + /// @brief Returns whether `(modelId, actionId)` has a Task handler, which + /// only `dispatchAsync` can run, rather than an ordinary one, which + /// `dispatch` runs too. + /// + /// Lets a caller that already holds everything an ordinary dispatch needs + /// call `dispatch` directly, and pay for what a Task handler needs -- a + /// resumer, shared state for its callback -- only when there is one. + /// @return False for an unknown pair, which `dispatch` then reports. + [[nodiscard]] bool dispatchesAsync(std::string_view modelId, std::string_view actionId) const { + auto iter = _actions.find(detail::PairKeyView{modelId, actionId}); + return iter != _actions.end() && iter->second.asyncRunner != nullptr; + } + /// @brief Returns whether `(modelId, actionId)` was registered with /// `ActionLogPolicy::coalesce == true`. /// @@ -957,6 +1141,8 @@ class ActionDispatcher { struct ActionEntry { /// @brief Type-erased decode/execute/encode runner. Runner runner; + /// @brief The Task handler runner, or null for an ordinary handler. + AsyncRunner asyncRunner = nullptr; /// @brief `ActionLogPolicy::coalesce` as registered. bool coalesce = false; /// @brief `payloadFingerprint()` as registered. @@ -1459,8 +1645,10 @@ bool registerActionExecutorOnce(std::string_view modelId, std::string_view actio #define BRIDGE_REGISTER_ACTION_3(M, A, NAME) BRIDGE_REGISTER_ACTION_4(M, A, NAME, ::morph::model::Loggable::Yes) // clang-format off -- public macro surface; see CONTRIBUTING.md, "Formatting/linting". -#define BRIDGE_REGISTER_ACTION_4(M, A, NAME, LOGGABLE) \ - BRIDGE_DETAIL_ACTION_TRAITS_BODY(M, A, decltype(std::declval().execute(std::declval())), NAME, LOGGABLE) +#define BRIDGE_REGISTER_ACTION_4(M, A, NAME, LOGGABLE) \ + BRIDGE_DETAIL_ACTION_TRAITS_BODY( \ + M, A, ::morph::model::HandlerResultT().execute(std::declval()))>, NAME, \ + LOGGABLE) // clang-format on /// @endcond diff --git a/include/morph/core/remote.hpp b/include/morph/core/remote.hpp index 249f23fbe..9945be633 100644 --- a/include/morph/core/remote.hpp +++ b/include/morph/core/remote.hpp @@ -5,6 +5,7 @@ #include #include #include +#include #include #include #include @@ -43,8 +44,11 @@ struct LimitPolicy { /// sends an `err "timeout"` reply and discards the eventual strand /// result. `0` = no timeout (today's behavior). /// - /// The model action itself is never interrupted — it keeps running to - /// completion on its strand. This bounds the *caller's wait*, not the model. + /// An ordinary handler is never interrupted — it keeps running to + /// completion on its strand, so this bounds the *caller's wait*, not the + /// model. A handler returning `core::async::Task` is also asked to stop: its + /// stop token is requested, and it unwinds at its next stop-aware + /// `co_await` (see `docs/spec/core/coroutines.md`). std::chrono::milliseconds executeTimeout{0}; /// @brief Max models this `RemoteServer` will hold live at once, across all @@ -207,8 +211,8 @@ class RemoteServer : public std::enable_shared_from_this { /// /// @param workerPool Pool used to process messages asynchronously. Borrowed, /// not owned: it must outlive this server — and, because - /// the server's `StrandExecutor` is built on it, keep - /// running until teardown completes (see + /// the server's strands run on it, keep running until + /// teardown completes (see /// `docs/spec/concurrency_and_lifetimes.md`, "Destruction /// ordering"). /// @param dispatcher Action dispatcher; defaults to the process-level @@ -221,7 +225,7 @@ class RemoteServer : public std::enable_shared_from_this { ::morph::model::detail::ModelRegistryFactory& registry MORPH_LIFETIMEBOUND = ::morph::model::detail::defaultRegistry()) : _pool{workerPool}, - _strand{workerPool}, + _strands{std::make_shared<::morph::exec::detail::ModelStrands>(workerPool)}, _dispatcher{dispatcher}, _registry{registry}, _authorizer{::morph::session::allowAllAuthorizer()} {} @@ -242,7 +246,7 @@ class RemoteServer : public std::enable_shared_from_this { ::morph::model::detail::ModelRegistryFactory& registry MORPH_LIFETIMEBOUND = ::morph::model::detail::defaultRegistry()) : _pool{workerPool}, - _strand{workerPool}, + _strands{std::make_shared<::morph::exec::detail::ModelStrands>(workerPool)}, _dispatcher{dispatcher}, _registry{registry}, _authorizer{std::move(authorizer)} { @@ -1274,7 +1278,7 @@ class RemoteServer : public std::enable_shared_from_this { // the rejection branches below do it explicitly, through // `rejectAndRelease`, so it is freed before their reply goes out; the one // path that reaches the strand releases it immediately after - // `_strand.post(mid, ...)` (that post is the entire point of taking a + // `_strands->post(mid, ...)` (that post is the entire point of taking a // ticket, so this path brackets it with `awaitTurn()` rather than // releasing up front); and every *implicit* exit — an exception out of // `authorize`/`authenticate`/`authorizeInstance`/`missingRequiredFields`, @@ -1486,9 +1490,14 @@ class RemoteServer : public std::enable_shared_from_this { }; ::morph::async::detail::TimeoutScheduler::Handle timeoutHandle{}; + // Requested by the timeout alongside its reply, so a Task handler still + // suspended when the caller is answered unwinds and leaves the action + // gate rather than holding the model until its await completes. + std::shared_ptr<::core::async::StopSource> stopSource; if (limits.executeTimeout.count() > 0) { std::scoped_lock const lock{_limitsMtx}; if (_timeoutScheduler) { + stopSource = std::make_shared<::core::async::StopSource>(); // The literal, not wire::kExecuteTimeoutMessage, on purpose: // a scenario-coverage script (since removed) statically scanned this // file for a string literal passed directly to makeErr to @@ -1508,20 +1517,22 @@ class RemoteServer : public std::enable_shared_from_this { // mid-flight when the dispatch finishes therefore still calls // `complete`, and `complete`'s reply-exactly-once flag drops // it rather than double-answering the call. - timeoutHandle = _timeoutScheduler->schedule(limits.executeTimeout, [complete, callId]() mutable { - complete(::morph::wire::encode(::morph::wire::makeErr("timeout", callId))); - }); + timeoutHandle = + _timeoutScheduler->schedule(limits.executeTimeout, [complete, callId, stopSource]() mutable { + complete(::morph::wire::encode(::morph::wire::makeErr("timeout", callId))); + stopSource->request_stop(); + }); } } // Where per-model ordering is enforced: block (on this pool thread — // never the strand itself, and never any other model's strand) until // every execute for `mid` that the transport sent before this one has - // already made its own `_strand.post(mid, ...)` call below. Every + // already made its own `_strands->post(mid, ...)` call below. Every // early-return above this point released its ticket immediately without // ever waiting here, so a model-not-found/unauthorized/busy rejection // for a *different* ticket can never be the thing this wait is stuck - // behind — only a ticket that is also headed for `_strand.post` can + // behind — only a ticket that is also headed for `_strands->post` can // hold this one up, and it can only hold it up for as long as *its own* // pre-strand work (identical in kind to this one's) takes, not for the // duration of whatever the model's strand does with it afterward. @@ -1533,79 +1544,42 @@ class RemoteServer : public std::enable_shared_from_this { // would let a rejection skip past this wait's ticket and park it here // for good. ticketGuard.awaitTurn(); - _strand.post(mid, [self, env = std::move(env), holder = std::move(holder), hydration = std::move(hydration), - complete, timeoutHandle]() mutable { - auto const start = std::chrono::steady_clock::now(); - auto const spanId = - ::morph::observe::detail::beginSpan(env.session.requestId, env.modelType, env.actionType); - // Metrics and endSpan are recorded before `complete(...)` runs (below) - // so a caller observing completion — via handle()'s reply or the - // timeout path racing it — can never see the reply before this - // dispatch's own instrumentation is recorded. This mirrors the - // reply-exactly-once contract `complete` already provides: whichever - // path wins the race, the metrics for *this* strand task are always - // emitted here, exactly once, regardless of which path's reply the - // caller actually receives. - try { - ::morph::session::detail::ScopedContext const scoped{env.session}; - // `dispatch` (registry.hpp, ActionDispatcher::registerAction's runner) - // now throws morph::model::ValidationError when the decoded action - // fails ActionValidator::ready(...), before Model::execute - // runs. No special-casing is needed here: ValidationError derives - // from std::runtime_error, so it is caught by the handler below and - // turned into an ordinary `err` reply carrying its message and - // callId, exactly like any other dispatch failure. See - // docs/spec/core/registry.md. - auto result = self->_dispatcher.dispatch(env.modelType, env.actionType, *holder, env.body); - { - std::scoped_lock const lock{self->_limitsMtx}; - if (self->_timeoutScheduler) { - self->_timeoutScheduler->cancel(timeoutHandle); - } - } - // Settle hydration before `endSpan`, before the metrics and - // before `complete` -- each hands control to host code that is - // free to attach to this instance's key, and an attacher - // reaching the directory while the outcome is known but - // unrecorded is handed an instance whose first action has - // already failed. See shared_instances.md's Failure modes. - if (hydration) { - hydration->settle(true); - } - ::morph::observe::detail::endSpan(spanId, true); - auto const elapsedMs = - std::chrono::duration(std::chrono::steady_clock::now() - start).count(); - std::array, 2> const tags{ - {{"modelType", env.modelType}, {"actionType", env.actionType}}}; - ::morph::observe::detail::emitMetric(::morph::observe::Metric::executeLatencyMs, elapsedMs, tags); - complete(::morph::wire::encode(::morph::wire::makeOk(env.callId, std::move(result)))); - } catch (const std::exception& exc) { - { - std::scoped_lock const lock{self->_limitsMtx}; - if (self->_timeoutScheduler) { - self->_timeoutScheduler->cancel(timeoutHandle); - } - } - // Settled before `endSpan` for the reason given in the success - // branch above. - if (hydration) { - hydration->settle(false); + RemoteRun run; + run.self = self; + run.env = std::move(env); + run.holder = std::move(holder); + run.hydration = std::move(hydration); + run.complete = complete; + run.timeoutHandle = timeoutHandle; + run.stopSource = std::move(stopSource); + run.mid = mid; + // Through the instance's action gate: an action starts only once the one + // before it has finished, which a Task handler does when its Task + // completes rather than when the strand task that started it returns. + if (_dispatcher.dispatchesAsync(run.env.modelType, run.env.actionType)) { + // A Task run is shared: its completion callback outlives the strand + // task. + auto shared = std::make_shared(std::move(run)); + _strands->post(mid, + [shared] { shared->holder->actionGate().enter([shared] { startTaskRemote(shared); }); }); + } else { + // An ordinary run travels by value in the strand task, so an + // execute costs the post's one allocation, as LocalBackend's does. + // See `ActionGate::tryEnter`. + _strands->post(mid, [run = std::move(run)]() mutable { + auto& gate = run.holder->actionGate(); + if (gate.tryEnter()) { + startRemote(run); + return; } - ::morph::observe::detail::endSpan(spanId, false); - auto const elapsedMs = - std::chrono::duration(std::chrono::steady_clock::now() - start).count(); - std::array, 2> const tags{ - {{"modelType", env.modelType}, {"actionType", env.actionType}}}; - ::morph::observe::detail::emitMetric(::morph::observe::Metric::executeLatencyMs, elapsedMs, tags); - ::morph::observe::detail::emitMetric(::morph::observe::Metric::executeErrors, 1.0, tags); - complete(::morph::wire::encode(::morph::wire::makeErr(exc.what(), env.callId))); - } - }); - // The ticket's whole job was ordering *this* `_strand.post()` call + gate.enter([waiting = std::make_shared(std::move(run))] { startRemote(*waiting); }); + }); + } + // The ticket's whole job was ordering *this* `_strands->post()` call // relative to any other in-flight execute for `mid` — that call has // now happened, in its correct turn, so the next ticket (if any) may // proceed immediately. Not tied to the strand task's own completion: - // StrandExecutor already serializes everything from here on (that is + // the strand already serializes everything from here on (that is // its entire job), so holding this ticket any longer would only // delay a *different* execute's own pre-strand work for no ordering // benefit. @@ -1635,8 +1609,140 @@ class RemoteServer : public std::enable_shared_from_this { return ::morph::exec::detail::ModelId{id}; } + /// Everything one dispatched execute carries from `dispatchExecute` to its + /// reply. An ordinary handler's run is held by its strand task, or by the + /// gate's queue while it waits there; a Task handler's is shared, because + /// its completion callback holds it too. Holding `self` is what keeps the server, and with + /// it `_dispatcher`, alive until the reply is delivered. + struct RemoteRun { + std::shared_ptr self; + ::morph::wire::Envelope env; + std::shared_ptr<::morph::model::detail::IModelHolder> holder; + std::shared_ptr hydration; + std::function complete; + ::morph::async::detail::TimeoutScheduler::Handle timeoutHandle{}; + /// Null without an `executeTimeout`. + std::shared_ptr<::core::async::StopSource> stopSource; + ::morph::exec::detail::ModelId mid{}; + std::chrono::steady_clock::time_point start; + ::morph::observe::SpanId spanId{}; + }; + + /// Stamps an execute's start once it holds its instance's action gate. + /// + /// Metrics and endSpan are recorded before `complete(...)` runs (in + /// finishRemote) so a caller observing completion — via handle()'s reply + /// or the timeout path racing it — can never see the reply before this + /// dispatch's own instrumentation is recorded. This mirrors the + /// reply-exactly-once contract `complete` already provides: whichever + /// path wins the race, the metrics for *this* dispatch are always + /// emitted, exactly once, regardless of which path's reply the caller + /// actually receives. + /// + /// A handler whose decoded action fails ActionValidator::ready(...) + /// throws morph::model::ValidationError before Model::execute runs. No + /// special-casing is needed: it becomes an ordinary `err` reply carrying its + /// message and callId, exactly like any other dispatch failure. See + /// docs/spec/core/registry.md. + static void admitRemote(RemoteRun& run) { + run.start = std::chrono::steady_clock::now(); + run.spanId = + ::morph::observe::detail::beginSpan(run.env.session.requestId, run.env.modelType, run.env.actionType); + } + + /// Runs an ordinary handler's execute once it holds its instance's action + /// gate, on the strand, and replies. + static void startRemote(RemoteRun& run) { + admitRemote(run); + std::string result; + std::exception_ptr error; + try { + ::morph::session::detail::ScopedContext const scoped{run.env.session}; + result = run.self->_dispatcher.dispatch(run.env.modelType, run.env.actionType, *run.holder, run.env.body); + } catch (...) { + error = std::current_exception(); + } + finishRemote(run, std::move(result), error); + } + + /// Starts a Task handler's execute once it holds its instance's action + /// gate, on the strand. It replies when its Task completes. + static void startTaskRemote(const std::shared_ptr& run) { + admitRemote(*run); + std::shared_ptr<::morph::exec::detail::TaskResumer> executor; + try { + ::morph::session::detail::ScopedContext const scoped{run->env.session}; + auto const& strands = run->self->_strands; + executor = std::make_shared<::morph::exec::detail::TaskResumer>(strands, run->mid, run->env.session); + strands->enroll(run->mid, executor); + auto token = run->stopSource ? run->stopSource->get_token() : ::core::async::StopToken{}; + run->self->_dispatcher.dispatchAsync( + run->env.modelType, run->env.actionType, *run->holder, run->env.body, executor, std::move(token), + [run, resumer = executor.get()](std::string result, std::exception_ptr error) { + // As on LocalBackend: a handler can end on another + // executor, and leaving the gate belongs on the strand. + // The strands are held here, not reached through the run: + // the finish may release the last reference to this server. + auto const held = run->self->_strands; + held->withdraw(run->mid, resumer); + held->runOnStrand(run->mid, [run, result = std::move(result), error = std::move(error)] { + finishRemote(*run, result, error); + }); + }); + } catch (...) { + run->self->_strands->withdraw(run->mid, executor.get()); + finishRemote(*run, std::string{}, std::current_exception()); + } + } + + /// Records a finished execute, replies, and leaves the action gate so the + /// next execute on the instance can start. On the strand. + static void finishRemote(RemoteRun& run, std::string result, const std::exception_ptr& error) { + auto& self = *run.self; + { + std::scoped_lock const lock{self._limitsMtx}; + if (self._timeoutScheduler) { + self._timeoutScheduler->cancel(run.timeoutHandle); + } + } + bool const succeeded = error == nullptr; + // Settle hydration before `endSpan`, before the metrics and before + // `complete` -- each hands control to host code that is free to attach + // to this instance's key, and an attacher reaching the directory while + // the outcome is known but unrecorded is handed an instance whose first + // action has already failed. See shared_instances.md's Failure modes. + if (run.hydration) { + run.hydration->settle(succeeded); + } + ::morph::observe::detail::endSpan(run.spanId, succeeded); + auto const elapsedMs = + std::chrono::duration(std::chrono::steady_clock::now() - run.start).count(); + std::array, 2> const tags{ + {{"modelType", run.env.modelType}, {"actionType", run.env.actionType}}}; + ::morph::observe::detail::emitMetric(::morph::observe::Metric::executeLatencyMs, elapsedMs, tags); + if (succeeded) { + run.complete(::morph::wire::encode(::morph::wire::makeOk(run.env.callId, std::move(result)))); + } else { + ::morph::observe::detail::emitMetric(::morph::observe::Metric::executeErrors, 1.0, tags); + std::string message = "unknown exception"; + try { + std::rethrow_exception(error); + } catch (const std::exception& exc) { + message = exc.what(); + } catch (...) { // NOLINT(bugprone-empty-catch): the message stays "unknown exception" + // Keeps "unknown exception": the reply still goes out, so the + // caller's completion does not wait for its deadline. + } + run.complete(::morph::wire::encode(::morph::wire::makeErr(message, run.env.callId))); + } + run.holder->actionGate().leave(); + } + ::morph::exec::IExecutor& _pool; - ::morph::exec::detail::StrandExecutor _strand; + // One strand per model instance, shared with the Task handlers' resumers. + // Never closed before its last owner goes: a suspended handler's driver + // frame holds its `RemoteRun`, and with it this server. + std::shared_ptr<::morph::exec::detail::ModelStrands> _strands; ::morph::model::detail::ActionDispatcher& _dispatcher; ::morph::model::detail::ModelRegistryFactory& _registry; std::shared_ptr<::morph::session::IAuthorizer> _authorizer; @@ -1649,7 +1755,7 @@ class RemoteServer : public std::enable_shared_from_this { // posted back-to-back, can have their pre-strand work (decode, authorize, // authenticate, registry lookup) finish on two different pool threads in // either order -- so without this gate, whichever one finishes first - // reaches `_strand.post(mid, ...)` first, even if the client sent the + // reaches `_strands->post(mid, ...)` first, even if the client sent the // other one first (`tests/test_remote_execute_ordering.cpp` reproduces // this deterministically). A first attempt strand-routed the *entire* // dispatch pipeline for a known `modelId`, which closed the race but @@ -1659,10 +1765,10 @@ class RemoteServer : public std::enable_shared_from_this { // some other, still-blocked model's strand, and moving the whole // pipeline onto the strand collapsed that fast-reject path into the same // queue as the slow model's in-flight work. The ticket gate below fixes - // only the ordering of the `_strand.post()` call itself, leaving the + // only the ordering of the `_strands->post()` call itself, leaving the // fast-reject path exactly as fast as it always was. // - // The gate orders only the *moment of the `_strand.post()` call itself*, + // The gate orders only the *moment of the `_strands->post()` call itself*, // not the pipeline before it: a ticket is handed out and the dispatch // work is handed to `_pool` in one atomic step, synchronously in // `handleImpl` (called directly from `handle()`, which runs on @@ -1672,7 +1778,7 @@ class RemoteServer : public std::enable_shared_from_this { // to `_pool` as two separate, unlocked steps would let two concurrent // transport threads' ticket order and enqueue order diverge). // `dispatchExecute` waits for its ticket's - // turn only immediately before the pre-existing `_strand.post(mid, ...)` + // turn only immediately before the pre-existing `_strands->post(mid, ...)` // call, and releases the next ticket's turn either right after posting // (live model) or immediately on a "model not found"/other early-return // rejection (dead model, unauthorized, over limit, etc. -- none of these @@ -1709,7 +1815,7 @@ class RemoteServer : public std::enable_shared_from_this { // is purely the wiring: `handleImpl` calls `_executeGate.takeAndPost(mid, // ...)`, which hands out the ticket and posts to `_pool` inside one // critical section; `dispatchExecute` calls `ticketGuard.awaitTurn()` - // immediately before `_strand.post(mid, ...)`; every exit path releases + // immediately before `_strands->post(mid, ...)`; every exit path releases // through `ExecuteTicketGuard`, explicitly or via its destructor. ::morph::backend::detail::ExecuteOrderGate _executeGate; // mutable: health() is const and must still be able to lock this to read diff --git a/include/morph/core/strand.hpp b/include/morph/core/strand.hpp index ac73fe409..11291642b 100644 --- a/include/morph/core/strand.hpp +++ b/include/morph/core/strand.hpp @@ -1,21 +1,36 @@ // SPDX-License-Identifier: Apache-2.0 #pragma once -#include +#include +#include +#include +#include +#include +#include +#include +#include #include -#include #include #include #include #include +#include #include +#include #include #include #include "../attributes.hpp" +#include "../session/session.hpp" #include "executor.hpp" #include "logger.hpp" +/// @file +/// @brief morph's strands: one per model instance, from core-cpp's +/// `core::async::KeyedStrands`, over a morph executor. +/// +/// Specified in `docs/spec/core/executor.md`, "Strands". + namespace morph::exec::detail { /// @brief Opaque identifier for a model instance inside a backend. @@ -36,366 +51,392 @@ struct ModelIdHash { std::size_t operator()(ModelId mid) const noexcept { return std::hash{}(mid.v); } }; -/// @brief Per-key serialising executor built on top of an arbitrary `IExecutor`. +/// @brief A `core::async::IExecutor` over a morph executor: how a core-cpp +/// strand's pump reaches a thread pool, a main-thread pump or Qt. /// -/// Tasks posted with the same `ModelId` key are always executed in FIFO order -/// with no overlap, even when the underlying executor is a thread pool. Tasks -/// with different keys may run concurrently. +/// A strand hands its base one bare coroutine handle per turn, which this +/// posts as a lambda holding nothing else: trivially copyable, so it fits a +/// `std::function`'s small buffer and a turn costs no allocation. The lambda +/// does not refer to this object, so it may be destroyed while a pump it +/// posted is still queued: the pump finds its strand closed and ends. +class CoreExecutorOver final : public ::core::async::IExecutor { +public: + /// @param executor Where every resumption is posted. Borrowed: it must + /// outlive every strand over this object and run what it queued. + explicit CoreExecutorOver(::morph::exec::IExecutor& executor MORPH_LIFETIMEBOUND) : _executor{&executor} {} + + using ::core::async::IExecutor::submit; + + /// @brief Posts @p handle's resumption. + /// @param handle The coroutine to resume; borrowed. + void submit(std::coroutine_handle<> handle) override { + _executor->post([handle] { handle.resume(); }); + } + + /// @brief Posts @p work's resumption. An executor that drops the task + /// unrun releases its claim, which frees a chain nobody owns. + /// @param work The coroutine to resume, and its claim. + void submit(::core::async::ParkedWork work) override { + _executor->post([work] { + work.abandon.disarm(); + work.resume.resume(); + }); + } + +private: + ::morph::exec::IExecutor* _executor; +}; + +/// @brief A callable posted to a strand, with its throw logged rather than +/// propagated. /// -/// This removes the need for per-model mutexes: the model's `execute()` method -/// is always called from exactly one task at a time. -// NOLINTNEXTLINE(cppcoreguidelines-special-member-functions) -class StrandExecutor { +/// A core-cpp strand lets a task's throw propagate to whoever resumed its +/// pump, and under MSVC's `cl` ends the process instead. morph's strands have +/// always logged a throwing task and gone on with the next; this keeps that +/// policy in morph, inside the one allocation the post costs. +/// @tparam F The callable's type. +template +struct LoggedTask { + F fn; + + void operator()() { + try { + fn(); + } catch (const std::exception& exc) { + ::morph::log::logError("[strand] task threw: " + std::string{exc.what()}); + } catch (...) { + ::morph::log::logError("[strand] task threw unknown exception"); + } + } +}; + +class TaskResumer; + +/// @brief In which order `ModelStrands::teardown` stops the Task handlers and +/// seals the strands. +enum class TeardownOrder : std::uint8_t { + /// Stop, drain, then seal: a stopped handler unwinds on its strand, which + /// still admits its resumption, and the drain before the seal lets + /// everything the stop set moving finish there. For a build with threads, + /// where the strands keep running on the pool while the owner waits: an + /// arrival refused by the seal runs inline on its own thread, and the drain + /// first is what keeps a task of its key from running beside it. + StopThenSeal, + /// Seal, then stop: a stopped handler's resumption is refused and runs + /// inline, in the stop. For the single-threaded build, where nothing runs + /// the strands while the owner tears them down. + SealThenStop, +}; + +/// @brief The teardown order for this build: `StopThenSeal` where threads +/// exist, `SealThenStop` where they do not. +inline constexpr TeardownOrder buildTeardownOrder = + CORE_CPP_ASYNC_HAS_THREADS ? TeardownOrder::StopThenSeal : TeardownOrder::SealThenStop; + +/// @brief One strand per model instance over a morph executor: work for one +/// `ModelId` runs serially and in order, and work for different ids runs +/// concurrently where the executor has the threads. +/// +/// `core::async::KeyedStrands`, with what morph adds to it: +/// - the base adapter (`CoreExecutorOver`), owned here; +/// - posted callables that log a throw (`LoggedTask`); +/// - the action's session, and the Task handler's resumer as the current +/// executor, around every coroutine resumed on a model instance whose Task +/// handler has started and not finished (`enroll`). That is the keyed +/// around-task hook's job: a handler that comes back to its strand through +/// any awaitable finds both installed, and a callable posted to the same +/// strand does not. +/// +/// Held by `std::shared_ptr`: a `TaskResumer` shares it, so a suspended +/// handler can still ask whether it is closed after its backend is gone. +class ModelStrands final { public: - /// @brief Constructs the strand executor wrapping @p base. - /// @param base Underlying executor that actually runs the tasks. Borrowed, - /// not owned: it must outlive this `StrandExecutor` *and* keep - /// running tasks until the destructor's wait has completed — - /// destroying it first deadlocks (see - /// `docs/spec/concurrency_and_lifetimes.md`, "Destruction - /// ordering"). - explicit StrandExecutor(IExecutor& base MORPH_LIFETIMEBOUND) : _base{&base} {} - - /// @brief Blocks until all in-flight tasks have completed, then destroys the executor. - /// - /// Waits for all in-flight lambdas to complete before destroying the map. - /// Without this, a pool thread running scheduleNext can access _strands - /// after it has been destroyed (TSan: data race on destructor vs erase). - ~StrandExecutor() { - std::unique_lock lock{_mapMtx}; - _cv.wait(lock, [this] { return _inFlight == 0; }); + /// @param base Where every strand's pump runs. Borrowed: it must outlive + /// this object and keep running tasks until `drain` has + /// returned. + /// @param options How each strand shares @p base; its `aroundTask` must be + /// unset, since these strands install their own. + explicit ModelStrands(::morph::exec::IExecutor& base MORPH_LIFETIMEBOUND, + ::core::async::StrandOptions options = {}) + : _base{base}, _strands{_base, options, ::core::async::KeyedAroundTask::of(_hook)} {} + + ModelStrands(const ModelStrands&) = delete; + ModelStrands& operator=(const ModelStrands&) = delete; + ModelStrands(ModelStrands&&) = delete; + ModelStrands& operator=(ModelStrands&&) = delete; + ~ModelStrands() = default; + + /// @brief Queues @p task on @p key's strand, after everything queued there + /// before it. Dropped once the strands are closed. + /// @param key The model instance. + /// @param task The callable; held by value in one allocation, and its throw + /// is logged. + template + void post(ModelId key, F&& task) { + _strands.post(key, LoggedTask>{std::forward(task)}); } - /// @brief Posts @p task to the strand associated with @p key. + /// @brief Runs @p task here if the calling thread is on @p key's strand, + /// posts it there otherwise, and runs it here once the strands are + /// closed. /// - /// The task is guaranteed to run after all previously posted tasks for the - /// same key have completed. Tasks for different keys may interleave freely. - /// Thread-safe. - /// @param key Model identifier that selects the strand. - /// @param task Callable to execute. - void post(ModelId key, std::function task) { - std::shared_ptr strand; - bool schedule = false; - { - // Hold _mapMtx across the whole slot-lookup + push + re-arm - // decision, and take strand->mtx *while still holding _mapMtx*. The - // drain-and-erase step in scheduleNext makes its "keep-running vs. - // erase" decision under the same {_mapMtx, strand->mtx} pair, so the - // two are mutually exclusive. - // - // Doing the lookup and the re-arm under separate locks once opened a - // window: a post() could capture a strand, release _mapMtx, then - // re-arm it under strand->mtx *after* a concurrent drain had already - // erased it from the map — orphaning a live strand and letting a - // later post(key) create a second strand for the same key that ran - // concurrently. Because the map lookup and the re-arm now share - // _mapMtx with the erase, the strand we push into is always the map's - // current entry for this key: a strand that becomes `running` here is - // guaranteed to still be the map entry, and it cannot be erased - // out from under us (the erase needs _mapMtx too, and never fires - // while pending is non-empty). - // - // Lock order is _mapMtx → strand->mtx, matching scheduleNext — - // which acquires the two sequentially rather than with one - // scoped_lock over both, precisely to keep this order (see its own - // comment). A freshly created strand's mtx - // is uncontended; an existing strand's mtx can only be held - // elsewhere under the same _mapMtx-first order, so no deadlock. - std::scoped_lock const mapLock{_mapMtx}; - auto slotIter = _strands.find(key); - if (slotIter == _strands.end()) { - slotIter = installStrand(key); - } - strand = slotIter->second; - std::scoped_lock const strandLock{strand->mtx}; - strand->pending.push(std::move(task)); - if (!strand->running) { - strand->running = true; - schedule = true; - // Account for the strand lambda we are about to dispatch *while - // still holding _mapMtx*, before releasing it. If we deferred the - // ++_inFlight into scheduleNext (which re-takes _mapMtx), a window - // opened between releasing _mapMtx here and re-acquiring it there - // in which ~StrandExecutor could observe _inFlight == 0, destroy - // _strands, and let scheduleNext touch freed state. Incrementing - // under this lock makes "decided to schedule" and "counted as - // in-flight" atomic, so the destructor never sees a zero it should - // not. - ++_inFlight; - } + /// How a Task handler's end reaches its strand when the handler finished + /// off it. Once the strands are closed their backend has drained them, so + /// no strand task is left to race @p task. + /// @param key The model instance. + /// @param task The callable. + template + void runOnStrand(ModelId key, F task) { + if (runningHere(key)) { + task(); + return; } - if (schedule) { - scheduleNext(std::move(strand), key); + LoggedTask logged{std::move(task)}; + if (!_strands.tryPost(key, logged)) { + logged(); } } -private: - /// @brief FIFO of tasks queued on one strand, with the head task held inline. + /// @brief Queues @p handle on @p key's strand, unless the strands are closed. + /// @param key The model instance. + /// @param handle The coroutine to resume there; borrowed. + /// @return Whether it was queued. + [[nodiscard]] bool trySubmit(ModelId key, std::coroutine_handle<> handle) { + return _strands.trySubmit(key, handle); + } + + /// @brief Queues @p work on @p key's strand, holding its claim until it + /// runs, unless the strands are closed. + /// @param key The model instance. + /// @param work The coroutine and its claim; moved from only where this + /// returns true. + /// @return Whether it was queued. + [[nodiscard]] bool trySubmit(ModelId key, ::core::async::ParkedWork& work) { + return _strands.trySubmit(key, work); + } + + /// @brief Whether the calling thread is inside a task of @p key's strand. + /// @param key The model instance. + /// @return True inside that strand's task, at any depth. + [[nodiscard]] bool runningHere(ModelId key) const noexcept { return _strands.runningHere(key); } + + /// @brief Whether the calling thread is inside a task of any of these strands. + /// @return True inside any of their tasks. + [[nodiscard]] bool runningAnyHere() const noexcept { return _strands.runningAnyHere(); } + + /// @brief Whether nothing is queued or running on any strand. + /// @return True when idle. Racy by nature where other threads post. + [[nodiscard]] bool idle() const { return _strands.idle(); } + + /// @brief Blocks until nothing is queued or running on any strand, + /// including work posted while it waits, where the build has + /// threads. /// - /// Behaviourally a `std::queue>` restricted to the - /// three operations the strand uses, and used under exactly the same - /// discipline: every call happens with the owning `Strand::mtx` held, so - /// this type does no locking of its own. + /// Not from one of these strands' own tasks, which would wait for itself: a + /// debug build asserts that. The single-threaded WebAssembly build has no + /// other thread to finish the work and allows no blocking wait, so there + /// this returns at once and `close` drops what is queued; a host that wants + /// it run pumps its executor until `idle()` first. + void drain() { +#if CORE_CPP_ASYNC_HAS_THREADS + _strands.waitIdle(); +#endif + } + + /// @brief Closes every strand: queued work is dropped, a task running on + /// another thread is waited for, and later posts are dropped too. + /// Idempotent. + void close() { _strands.close(); } + + /// @brief Refuses the try-forms and keeps running what is queued: + /// `trySubmit` and `runOnStrand`'s post are refused, so their callers + /// run the work inline; a plain `post` is still queued until + /// `close`. Idempotent. + void seal() { _strands.seal(); } + + /// @brief Takes the strands down without losing work: stops the Task + /// handlers, seals, drains and closes, with the stop and the seal in + /// @p order. /// - /// It exists because of what the *container* cost, not what the strand - /// did with it. The drain-and-erase step in `scheduleNext` destroys the - /// whole `Strand` as soon as the queue empties, so a workload that - /// dispatches one action at a time against a model builds a fresh queue on - /// every call and puts exactly one task in it. libstdc++'s `std::deque` - /// allocates its node map *and* a first 512-byte buffer in its default - /// constructor, which comes to 576 bytes of the 760 a deque-only strand - /// costs per local dispatch. Holding the head task in the strand makes - /// that case allocation-free; the overflow deque is constructed only when - /// a second task is genuinely queued behind a running one, after which the - /// cost is the deque's as before. + /// Whatever arrives once the strands are sealed -- a resumption, a + /// handler's end -- is refused and runs inline, so nothing reaches a strand + /// that the close would drop: not between the drain and the close, and not + /// on the single-threaded build, where the drain waits for nothing. /// - /// This changes no lifetime or locking rule: the erase still happens when - /// `empty()` becomes true, still under the `{_mapMtx, strand->mtx}` pair. - class PendingQueue { - public: - /// @brief Reports whether the queue holds no task. - /// @return `true` when nothing is queued. - [[nodiscard]] bool empty() const noexcept { return !_hasHead; } - - /// @brief Appends @p task to the back of the queue. - /// @param task Callable to queue. An *empty* `std::function` is queued - /// and later dispatched like any other: occupancy is - /// tracked by a separate flag rather than by testing the - /// callable, so this type never silently drops one. - void push(std::function&& task) { - if (!_hasHead) { - _head = std::move(task); - _hasHead = true; - return; - } - if (!_overflow) { - _overflow = std::make_unique>>(); - } - _overflow->push_back(std::move(task)); + /// With threads, the stop is drained before the seal: until the strands go + /// idle, a handler's resumption or end arriving from another thread -- a + /// socket's loop -- is still queued, behind its key's other tasks, rather + /// than run inline beside one of them on a pool thread, which would enter + /// and leave the model's action gate on two threads at once. What arrives + /// once the first drain has returned is the stopped handlers' own last steps. + /// @param stopHandlers Requests stop on every Task handler still running. + /// @param order Which of stopping and sealing comes first. + template + void teardown(Stop&& stopHandlers, TeardownOrder order = buildTeardownOrder) { + if (order == TeardownOrder::SealThenStop) { + seal(); + std::forward(stopHandlers)(); + } else { + std::forward(stopHandlers)(); + drain(); + seal(); } + drain(); + close(); + } - /// @brief Removes the task at the front of the queue and returns it. - /// @return The front task. - /// @pre `!empty()`. - std::function pop() { - std::function task = std::move(_head); - if (_overflow && !_overflow->empty()) { - _head = std::move(_overflow->front()); - _overflow->pop_front(); - } else { - // A moved-from std::function is valid but unspecified; clear it - // explicitly so the slot holds no captured state while idle. - _head = nullptr; - _hasHead = false; - } - return task; + /// @brief Installs @p resumer's session and executor around every coroutine + /// resumed on @p key's strand, until `withdraw(key, resumer)`; a + /// posted callable runs without them. + /// + /// Called on the strand, when a Task handler starts. The action gate lets + /// one action at a time run on a model instance, so a key has at most one + /// suspended handler to resume. Held weakly: the handler's driver owns the + /// resumer. + /// @param key The model instance. + /// @param resumer The handler's resumer. + void enroll(ModelId key, const std::shared_ptr& resumer) { + std::unique_lock const lock{_enrolledMtx}; + _enrolled.insert_or_assign(key, resumer); + _enrolledCount.store(_enrolled.size(), std::memory_order_release); + } + + /// @brief Ends `enroll(key, resumer)`. Called when the handler has + /// finished; a key enrolled for another resumer since is left alone. + /// @param key The model instance. + /// @param resumer The resumer that was enrolled for it. + void withdraw(ModelId key, const TaskResumer* resumer) { + std::unique_lock const lock{_enrolledMtx}; + if (auto const found = _enrolled.find(key); + found != _enrolled.end() && found->second.lock().get() == resumer) { + _enrolled.erase(found); } + _enrolledCount.store(_enrolled.size(), std::memory_order_release); + } - private: - std::function _head; - std::unique_ptr>> _overflow; - bool _hasHead = false; +private: + /// The keyed around-task hook: runs a coroutine resumption inside its model + /// instance's enrolled resumer, if it has one. A posted callable -- + /// `onBackendChanged`, an action queued behind the handler, the handler's + /// end -- is not the handler and runs bare. Touches nothing of this object + /// after the task has run, which may have released the last reference to + /// it. + struct AroundTask { + ModelStrands* self; + void operator()(const ModelId& key, ::core::async::RunTask run) const; }; - struct Strand { - IExecutor* base = nullptr; - std::mutex mtx; - PendingQueue pending; - bool running = false; - }; + CoreExecutorOver _base; + /// Shared by the hook, which only reads: tasks of different keys do not + /// serialise on it while handlers are enrolled. + std::shared_mutex _enrolledMtx; + /// How many keys are enrolled: the hook's one atomic load when none is. + std::atomic _enrolledCount{0}; + std::unordered_map, ModelIdHash> _enrolled; + AroundTask _hook{this}; + /// Last, so it is destroyed first: its strands reference the base and the + /// hook. + ::core::async::KeyedStrands _strands; +}; - /// @brief The `ModelId` → strand map. Named so the recycled node type can be. - using StrandMap = std::unordered_map, ModelIdHash>; +/// @brief Resumes one Task handler's coroutines on its model instance's strand. +/// +/// The current executor wherever the handler runs, so every awaitable that +/// resumes on the current executor -- morph's `Completion` and `delay`, +/// core-cpp's `AsyncQueue::pop` -- brings the handler back here, and `submit` +/// queues it on the strand. Each resumption runs with the action's session +/// installed: through the strand's around-task hook (`ModelStrands::enroll`), +/// or here. +/// +/// Once the strands are closed, a resumption runs inline, on the thread that +/// submitted it, with the same context installed. The call it belongs to has +/// already failed then, and the model instance is reachable only through the +/// handler's own frame, so there is nothing left for the strand to serialise it +/// against. +/// +/// Always held by `std::shared_ptr`. +class TaskResumer final : public ::core::async::IExecutor, public std::enable_shared_from_this { +public: + /// @param strands The strands the model's actions run on. + /// @param key The model instance whose strand resumptions are queued on. + /// @param session The action's session context, installed for each resumption. + TaskResumer(std::shared_ptr strands, ModelId key, ::morph::session::Context session) + : _strands{std::move(strands)}, _key{key}, _session{std::move(session)} {} - /// @brief Returns an iterator to the strand for @p key, creating the entry. - /// - /// **Precondition:** the caller holds `_mapMtx` and has already - /// established that `key` has no entry. - /// - /// This is a pure allocation optimisation and changes no lifetime or - /// locking rule. The drain step in `scheduleNext` removes the whole map - /// entry as soon as the queue empties, so a workload that dispatches one - /// action at a time against a model would otherwise pay for a fresh map - /// node *and* a fresh `make_shared` on every call — 2 allocations - /// and 152 bytes on top of the queue's own share. - /// Rather than keep the slot alive across the drain (which would need a - /// deregistration hook and would trade this churn for a per-model entry - /// nothing reclaims), the drain `extract`s the node instead of erasing it - /// and parks it in `_spare`, and this re-keys and re-inserts that one - /// node. The entry still leaves the map at the same point under the same - /// locks, so the map is bounded exactly as before; `_spare` holds at most - /// one node and is freed with the executor. - /// - /// Reusing the parked node's `Strand` as well is guarded by sole - /// ownership. `use_count() == 1` means the recycled node holds the only - /// reference, so nothing else can reach the object and reusing it is - /// indistinguishable from constructing a new one. That is the whole - /// argument, and it is deliberately not "the previous owner makes no - /// further access": a strand lambda that is still finishing does hold a - /// reference, and when it does, this constructs a fresh `Strand` exactly - /// as before and recycles only the node. - /// - /// The parked strand needs no reset. It is only ever extracted from a - /// strand observed `!running` with an empty `pending` under - /// `{_mapMtx, strand->mtx}`, which is the state a fresh one is in. A - /// runtime re-check of that here would be an arm nothing can take, so it - /// is written down rather than branched on. - /// @param key Model identifier to install a strand for. - /// @return Iterator to the entry for @p key. - StrandMap::iterator installStrand(ModelId key) { - if (!_spare) { - auto const iter = _strands.emplace(key, std::make_shared()).first; - iter->second->base = _base; - return iter; - } - _spare.key() = key; - auto& reused = _spare.mapped(); - if (reused.use_count() != 1) { - reused = std::make_shared(); + using ::core::async::IExecutor::submit; + + /// @brief Queues @p handle's resumption on the model's strand, or resumes + /// it here once the strands are closed. + /// @param handle The coroutine to resume; borrowed, as every handler frame + /// is owned by the driver that started it. + void submit(std::coroutine_handle<> handle) override { + if (!_strands->trySubmit(_key, handle)) { + resumeHere(handle); } - reused->base = _base; - // `insert` consumes the node. Were the precondition ever violated it - // would instead hand the node back inside the returned object, which - // frees it — the same fate the old `erase` gave it — and `position` - // would name the existing entry, so the caller is right either way - // and there is nothing to branch on. - return _strands.insert(std::move(_spare)).position; } - /// @brief Dispatches one strand lambda onto the base executor. + /// @brief Queues @p work's resumption on the model's strand, with its + /// claim, or resumes it here once the strands are closed. /// - /// **Precondition:** the caller must have already incremented `_inFlight` - /// (under `_mapMtx`) to account for this dispatch. `post()` does so in the - /// same critical section that decides to schedule, and the re-entrant call - /// below does so under the `_mapMtx` it already holds. Keeping the increment - /// with the *decision* (rather than here) closes the window where - /// `~StrandExecutor` could observe `_inFlight == 0` between the decision and - /// this dispatch and destroy `_strands` out from under us. - void scheduleNext(std::shared_ptr strand, ModelId key) { - // Read `base` out before the capture list moves `strand` into the - // lambda: `strand->base` and the lambda's construction are - // unsequenced within one call expression, so reading through the - // moved-from pointer would be a real hazard rather than a stylistic - // one. The lambda's capture is non-const (hence `mutable` and the - // by-value parameter) so the drain below can release it early. - IExecutor* const base = strand->base; - base->post([this, strand = std::move(strand), key]() mutable { - std::function task; - { - std::scoped_lock const lock{strand->mtx}; - task = strand->pending.pop(); - } - try { - task(); - } catch (const std::exception& exc) { - // The strand is where Model::execute() actually runs; a throw - // here must not stall the strand or vanish — log and continue so - // the next queued task for this model still runs. - ::morph::log::logError("[strand] task threw: " + std::string{exc.what()}); - } catch (...) { - ::morph::log::logError("[strand] task threw unknown exception"); - } - // Decide "keep running vs. drain-and-erase" atomically across the - // map slot and the strand's pending queue. Doing it in two steps - // (flip running under strand->mtx, then erase under _mapMtx) opened - // a window where another post() could re-arm this strand after we - // unlocked strand->mtx but before we erased the map entry. The - // subsequent erase then orphaned a live strand: a later post(key) - // would create a *new* strand for the same key, and the two - // strands could run model tasks concurrently → data race. - bool more = false; - { - // Same lock order as post(): _mapMtx first, then strand->mtx. - // Acquiring them sequentially (rather than via a single - // scoped_lock over both, whose std::lock back-off can grab them - // in address order) keeps a single, consistent ordering across - // every site that holds both, so there is no lock-ordering - // deadlock. This is the point where "drain-and-erase" is decided - // atomically w.r.t. a concurrent post(): a post() re-arming this - // strand and this block erasing it cannot interleave, because - // both hold _mapMtx across the whole decision. - std::scoped_lock const mapLock{_mapMtx}; - std::scoped_lock const strandLock{strand->mtx}; - more = !strand->pending.empty(); - if (!more) { - strand->running = false; - auto iter = _strands.find(key); - if (iter != _strands.end() && iter->second == strand) { - // `extract`, not `erase`: same removal, same moment, - // same locks — the entry leaves the map here exactly as - // before, and every reason the erase had to happen - // under {_mapMtx, strand->mtx} still applies unchanged. - // The difference is only that the detached node's - // memory is parked for `installStrand` to re-key - // instead of being returned to the allocator. Any node - // already parked is freed by this assignment, so at - // most one is ever held. Freeing it runs no user code - // under these locks: a parked strand was parked - // because its pending queue was empty, so there is no - // captured task left to destroy. - _spare = _strands.extract(iter); - } - } else { - // Account for the re-armed dispatch *before* releasing - // _mapMtx (scheduleNext's precondition). Because this run's - // own decrement below has not happened yet, _inFlight is - // briefly 2 here and never dips to 0 across the handoff, so - // ~StrandExecutor cannot slip in and destroy _strands between - // the two runs. - ++_inFlight; - } - } - if (more) { - scheduleNext(strand, key); - } else { - // Drop this run's co-ownership here rather than leaving it to - // the lambda's destruction a few lines below. Nothing after - // this point touches the strand, and releasing it early is - // what lets `installStrand` see `use_count() == 1` on the - // node just parked in `_spare`: the next post() for this key - // is typically already blocked on _mapMtx when the block above - // releases it, so a reference held until the lambda dies would - // usually still be there when that post looks. This only - // affects *whether the object is recycled*, never whether the - // recycling is safe — a post that looks too early simply sees - // two owners and constructs a fresh Strand. - // - // Safe to be the last owner here: no lock is held (the block - // above released both), so this never destroys a mutex it is - // standing on. If the extract above did run, `_spare` owns the - // strand and this merely decrements. - strand.reset(); - } - // Decrement after all map access is done; wake destructor if it is waiting. - { - std::scoped_lock const lock{_mapMtx}; - if (--_inFlight == 0) { - // Inside the `== 0` branch, so a handoff does not signal at - // all: the re-arm above has already incremented for the - // next dispatch, so the count does not reach zero until the - // strand is quiescent. `~StrandExecutor` is the only waiter - // on this variable, so `notify_all` wakes at most one - // thread and is equivalent to `notify_one` here -- there is - // no herd to wake, and no predicate but `_inFlight == 0` - // for a wakeup to land on and be lost. - _cv.notify_all(); - } - } - }); + /// The claim matters for a chain nobody owns: a `core::async::DetachedTask` + /// started inside the handler that parks on an awaitable resuming on the + /// current executor reaches here with its claim armed. Dropping the claim + /// would free that frame while its handle is still queued. + /// @param work The coroutine to resume, and its claim on the chain root. + void submit(::core::async::ParkedWork work) override { + if (_strands->trySubmit(_key, work)) { + return; + } + // Resumed, so the chain goes back to its owner: disarmed first, as + // core-cpp's own executors do before they resume a parked entry. + work.abandon.disarm(); + resumeHere(work.resume); + } + + /// @brief Resumes @p handle on the calling thread, inside this resumer. + /// @param handle The coroutine to resume. + void resumeHere(std::coroutine_handle<> handle) { + within([handle] { handle.resume(); }); } - IExecutor* _base; - std::mutex _mapMtx; - std::condition_variable _cv; - int _inFlight{0}; - StrandMap _strands; - /// @brief The one detached map node kept for reuse. Guarded by `_mapMtx`. + /// @brief Calls @p body with the action's session installed and this + /// resumer as the current executor. /// - /// Declared after `_strands` so it is destroyed first: the node owns - /// storage obtained from the map's allocator, and returning it before the - /// container goes away keeps that ordering obvious even though the default - /// allocator is stateless. - StrandMap::node_type _spare; + /// Touches no member after @p body returns: the body may release the last + /// owner but the one this call holds. + /// @param body The work, called once. + template + void within(Body&& body) { + std::shared_ptr const keep = shared_from_this(); + ::morph::session::detail::ScopedContext const scoped{_session}; + ::core::async::ExecutorScope const scope{*this, &keep, nullptr}; + std::forward(body)(); + } + +private: + std::shared_ptr _strands; + ModelId _key; + ::morph::session::Context _session; }; +inline void ModelStrands::AroundTask::operator()(const ModelId& key, ::core::async::RunTask run) const { + if (run.kind() != ::core::async::TaskKind::Resumption || + self->_enrolledCount.load(std::memory_order_acquire) == 0) { + run(); + return; + } + std::shared_ptr resumer; + { + std::shared_lock const lock{self->_enrolledMtx}; + if (auto const found = self->_enrolled.find(key); found != self->_enrolled.end()) { + resumer = found->second.lock(); + } + } + if (!resumer) { + run(); + return; + } + resumer->within(run); +} + } // namespace morph::exec::detail diff --git a/include/morph/core/timeout_scheduler.hpp b/include/morph/core/timeout_scheduler.hpp index ecbb47360..e7959b831 100644 --- a/include/morph/core/timeout_scheduler.hpp +++ b/include/morph/core/timeout_scheduler.hpp @@ -2,154 +2,159 @@ #pragma once #include +#include +#include +#include #include #include #include -#include +#include +#include #include - -/// @file -/// `TimeoutScheduler` — "run this callback once, in N milliseconds, unless -/// cancelled first" — in two builds of the same public API. -/// -/// @par Why two builds -/// The ordinary build owns a dedicated `std::thread`. A **single-threaded -/// Emscripten** build cannot: Qt for WebAssembly is installed here as -/// `wasm_singlethread` (`.github/workflows/wasm-ladder.yml`) and -/// `cmake/morph_add_rung.cmake` passes no `-pthread`, so Emscripten's -/// non-pthread `pthread_create` stub fails and `std::thread`'s constructor -/// throws `std::system_error` ("thread constructor failed") — from inside -/// whatever completion callback happened to enable the deadline. Every WASM -/// client in this repository is Qt-event-loop driven and would hit this the -/// moment it called `Bridge::setExecuteDeadline` (which -/// `examples/common/gui/event_poller.hpp`'s constructor does -/// unconditionally, on every poll open). -/// -/// So under `__EMSCRIPTEN__` without `__EMSCRIPTEN_PTHREADS__` this class is -/// built on `emscripten_async_call` — the browser's own `setTimeout` — and -/// fires its callbacks on the single main thread, i.e. on the same thread the -/// Qt event loop and every `QtExecutor`-posted completion callback already -/// run on. Deadlines still fire; nothing is silently disabled. -/// -/// @par What differs between the two builds -/// - **Callback thread.** Threaded build: a private background thread, so a -/// callback must be prepared to run concurrently with the caller (the one -/// real callback in this codebase, `executeVia`'s, only touches a -/// `CompletionState`, which is itself mutex-guarded). Browser build: the -/// main thread, never concurrently with anything. -/// - **Cancellation.** Threaded build: the entry, its callback and everything -/// the callback captured are erased immediately. Browser build: identical -/// for the callback and its captures (the map entry is erased at once), but -/// the underlying browser timer is not itself cleared — it still fires at -/// its original deadline and finds nothing to do. Only a small ticket -/// allocation outlives `cancel()`, until that point. -/// - **Cancelling a callback that has *already started*.** Threaded build: -/// `cancel()` cannot stop it. `run()` erases the entry before invoking the -/// callback and drops `_mtx` across the invocation, so a `cancel()` racing a -/// firing callback takes the same not-found branch as one for a handle that -/// already finished, and returns while that callback is still executing on -/// the scheduler thread. Browser build: the case cannot arise — the timer -/// callback and `cancel()` run on the same single thread, so "no callback -/// will start after `cancel()` returns" holds there and only there. See -/// `cancel()`'s own comment for what this asks of a caller. -/// - **Destruction.** Threaded build: the destructor joins its thread, so no -/// callback can be in flight afterwards. Browser build: nothing to join; -/// pending browser timers observe an expired `std::weak_ptr` to the -/// scheduler's state and return without invoking anything. -/// -/// @warning The browser build has never been compiled or run in this -/// repository — no Emscripten toolchain is available where it was written. -/// Its only verification is the `ladder-wasm` CI compile gate. Stated plainly -/// here rather than smoothed over, exactly like `examples/TESTING.md`'s note -/// on the WASM clients themselves. +#include #if defined(__EMSCRIPTEN__) && !defined(__EMSCRIPTEN_PTHREADS__) -#define MORPH_TIMEOUT_SCHEDULER_BROWSER_TIMERS 1 -#include - -#include -#include -#include +#define MORPH_TIMEOUT_SCHEDULER_HOST_DRIVEN 1 #else -#include -#include -#include #include +#include #endif #include "logger.hpp" -namespace morph::async::detail { +/// @file +/// `TimeoutScheduler` — "run this callback once, in N milliseconds, unless +/// cancelled first" — over core-cpp's event-loop timers. +/// +/// @par One class, two ways of driving its loop +/// The deadlines live in a `core::net::PlatformLoop`, and the one thing that +/// differs between builds is who turns it: +/// - **Native:** the scheduler owns a `std::thread` that runs the loop. +/// `schedule()` and `cancel()` may be called from any thread; they record +/// the request under a mutex and hand the loop a batch to arm or disarm. +/// Callbacks run on that thread, so a callback must be prepared to run +/// concurrently with the caller. +/// - **Single-threaded WebAssembly** (`__EMSCRIPTEN__` without +/// `__EMSCRIPTEN_PTHREADS__`): there is no thread to start — Qt for +/// WebAssembly is `wasm_singlethread` here and no `-pthread` is passed, so +/// `std::thread`'s constructor would throw. The loop is host-driven: the +/// browser's own timer pumps it, and `schedule()` and `cancel()` arm and +/// retire the timer directly. Callbacks run on the main thread, the one the +/// Qt event loop and every `QtExecutor`-posted completion already run on. +/// +/// @par What the two builds share +/// - `cancel()` releases the callback, and everything it captured, before it +/// returns — the entry lives in this class's own `pending` map, not in the +/// loop — and retires the loop's timer as well. +/// - The destructor drops every pending callback without firing it. +/// - A callback that throws is logged through `morph::log` and swallowed. +/// +/// @par What differs +/// - **Cancelling a callback that has already started.** Native: `cancel()` +/// cannot stop it and does not wait for it — the entry is taken out of +/// `pending` before the callback is invoked, so a racing `cancel()` finds +/// nothing and returns while the callback is still running. WebAssembly: +/// the case cannot arise, because the callback and `cancel()` run on the +/// same thread. A caller that must work in both builds cannot rely on the +/// second. +/// - **Destruction.** Native: the destructor stops the loop and joins its +/// thread, so no callback is in flight afterwards. WebAssembly: nothing to +/// join; the timers are retired and the loop is destroyed. A browser timer +/// already scheduled to pump it finds it gone and runs nothing: from +/// core-cpp 0.3.0 each pump carries a weak reference to the loop, where +/// 0.2.1's wrote into the freed loop. -#ifndef MORPH_TIMEOUT_SCHEDULER_BROWSER_TIMERS +namespace morph::async::detail { -/// @brief Background scheduler that invokes a callback once after a delay, unless cancelled first. +/// @brief Invokes a callback once after a delay, unless cancelled first. /// /// Neither `Bridge` nor `RemoteServer` is bound to a specific `IExecutor` -/// with a delayed-post primitive, so a single dedicated thread per instance -/// tracks pending deadlines and fires callbacks when they elapse. Used by +/// with a delayed-post primitive, so each owns one of these. Used by /// `RemoteServer` to enforce `LimitPolicy::executeTimeout` (server-side — /// see `docs/spec/core/backend.md`) and by `Bridge::setExecuteDeadline` -/// (client-side — see `docs/spec/core/completion.md`). See this file's `@file` -/// comment for the single-threaded-WASM build of the same API. +/// (client-side — see `docs/spec/core/completion.md`). See this file's +/// `@file` comment for how the native and single-threaded WebAssembly builds +/// drive it. class TimeoutScheduler { public: /// @brief Opaque identifier for one scheduled callback. using Handle = std::uint64_t; - /// @brief Starts the background thread. - TimeoutScheduler() : _thread{[this] { run(); }} {} +#ifndef MORPH_TIMEOUT_SCHEDULER_HOST_DRIVEN + /// @brief Starts the thread that runs the scheduler's event loop. + TimeoutScheduler() : _thread{[this] { _loop.run(); }} {} - /// @brief Stops the background thread and joins it. + /// @brief Drops every pending callback without firing it, stops the loop + /// and joins its thread. + /// + /// The timers are retired on the loop's own thread, before it stops, so + /// the loop is destroyed with nothing armed. ~TimeoutScheduler() { - { - std::scoped_lock const lock{_mtx}; - _stop = true; - } - _cv.notify_all(); + auto dropped = takePending(); + _loop.post([this] { + disarmAll(); + _loop.stop(); + }); _thread.join(); + // `dropped` is destroyed here, with the loop's thread gone and every + // member still alive: a capture whose destructor calls back into this + // scheduler finds it whole. } +#else + /// @brief Creates the scheduler. Starts no thread: the browser's timer + /// pumps the loop. + TimeoutScheduler() = default; + + /// @brief Drops every pending callback without firing it and retires the + /// loop's timers. + ~TimeoutScheduler() { + auto dropped = takePending(); + disarmAll(); + // `dropped` is destroyed here, with every member still alive. + } +#endif TimeoutScheduler(const TimeoutScheduler&) = delete; TimeoutScheduler& operator=(const TimeoutScheduler&) = delete; TimeoutScheduler(TimeoutScheduler&&) = delete; TimeoutScheduler& operator=(TimeoutScheduler&&) = delete; - /// @brief Schedules @p callback to run after @p delay on the scheduler's - /// background thread, unless cancelled first via `cancel()`. + /// @brief Schedules @p callback to run after @p delay, unless cancelled + /// first via `cancel()`. /// @param delay Time to wait before firing. - /// @param callback Invoked on the scheduler thread if not cancelled in time. + /// @param callback Invoked on the loop's thread (the main thread under + /// single-threaded WebAssembly) if not cancelled in time. /// Exceptions it throws are logged and swallowed. /// @return Handle usable with `cancel()`. Handle schedule(std::chrono::milliseconds delay, std::function callback) { - auto const deadline = std::chrono::steady_clock::now() + delay; - std::scoped_lock const lock{_mtx}; - Handle const handle = ++_nextHandle; - auto iter = _entries.emplace(deadline, Entry{handle, std::move(callback)}); - _index[handle] = iter; - _cv.notify_all(); + auto const deadline = _loop.clock().now() + delay; + Handle handle{}; + { + std::scoped_lock const lock{_mtx}; + handle = ++_nextHandle; + _pending.emplace(handle, std::move(callback)); + } + submit(Request{.handle = handle, .deadline = deadline}); return handle; } - /// @brief Cancels a previously scheduled callback: stops one that has not - /// started, and returns without waiting for one that has. + /// @brief Cancels a previously scheduled callback: releases one that has + /// not started, and returns without waiting for one that has. /// /// Two cases, and telling them apart is the caller's business because the /// scheduler cannot: /// - /// - **@p handle has not started.** Its entry — and anything its callback - /// captured — is erased right away, the callback never runs, and the - /// caller does not have to wait for the original deadline for that memory - /// to be released. - /// - **@p handle is already running.** `run()` erases the entry *before* it - /// invokes the callback, so this call finds nothing, takes the same - /// no-op branch as a handle that already finished, and **returns while - /// the callback is still executing** on the scheduler thread. The - /// callback is neither interrupted nor waited for. + /// - **@p handle has not started.** Its callback — and anything it + /// captured — is released before this returns, the callback never runs, + /// and the loop's timer is retired. + /// - **@p handle is already running** (native build only). The callback + /// was taken out of `pending` before it was invoked, so this call finds + /// nothing and **returns while the callback is still executing** on the + /// loop's thread. It is neither interrupted nor waited for. /// /// So `cancel()` returning does **not** mean "no callback is in flight". /// The only thing in this class that means that is `~TimeoutScheduler`, - /// which joins the scheduler thread. A caller must therefore keep every + /// which joins the loop's thread. A caller must therefore keep every /// scheduled callback safe to run *after* its `cancel()`: both callbacks in /// this repository (`Bridge::executeVia`'s deadline and `RemoteServer`'s /// `LimitPolicy::executeTimeout`) capture a `shared_ptr` to the state they @@ -165,175 +170,136 @@ class TimeoutScheduler { /// A no-op if @p handle already fired, is firing, or was already cancelled. /// @param handle Handle returned by a prior `schedule()` call. void cancel(Handle handle) { - std::scoped_lock const lock{_mtx}; - auto found = _index.find(handle); - if (found == _index.end()) { - return; + // Moved out under the lock and destroyed after it, so a capture whose + // destructor calls back into this scheduler cannot deadlock on _mtx. + std::function released; + { + std::scoped_lock const lock{_mtx}; + auto found = _pending.find(handle); + if (found == _pending.end()) { + return; + } + released = std::move(found->second); + _pending.erase(found); } - _entries.erase(found->second); - _index.erase(found); + submit(Request{.handle = handle, .deadline = std::nullopt}); } private: - struct Entry { + /// One change for the loop to apply: arm @p handle's timer at a deadline, + /// or, with none, retire it. + struct Request { Handle handle; - std::function callback; + std::optional<::core::platform::SteadyTimePoint> deadline; }; - void run() { - std::unique_lock lock{_mtx}; - while (!_stop) { - if (_entries.empty()) { - _cv.wait(lock); - continue; - } - auto const nextDeadline = _entries.begin()->first; - _cv.wait_until(lock, nextDeadline); - if (_stop) { - break; - } - auto now = std::chrono::steady_clock::now(); - while (!_entries.empty() && _entries.begin()->first <= now) { - auto iter = _entries.begin(); - Entry entry = std::move(iter->second); - _index.erase(entry.handle); - _entries.erase(iter); - lock.unlock(); - try { - entry.callback(); - } catch (const std::exception& exc) { - ::morph::log::logError("[timeout-scheduler] callback threw: {}", exc.what()); - } catch (...) { - ::morph::log::logError("[timeout-scheduler] callback threw unknown exception"); - } - lock.lock(); - now = std::chrono::steady_clock::now(); - } - } - } - - std::mutex _mtx; - std::condition_variable _cv; - std::multimap _entries; - std::unordered_map::iterator> _index; - Handle _nextHandle{0}; - bool _stop{false}; - std::thread _thread; -}; + /// The state an armed loop timer hands back to `fire()`. Held in + /// `_timers`, whose nodes do not move, so its address is stable for as + /// long as the timer is armed. + struct Timer { + TimeoutScheduler* owner; + Handle handle; + ::core::net::TimerId id; + }; + /// Applies @p request on the loop's thread. Natively that means queueing + /// it and waking the loop once per batch rather than once per request — + /// `Bridge` schedules and cancels one deadline per call. + void submit(Request request) { +#ifndef MORPH_TIMEOUT_SCHEDULER_HOST_DRIVEN + bool wake = false; + { + std::scoped_lock const lock{_mtx}; + wake = _requests.empty(); + _requests.push_back(request); + } + if (wake) { + _loop.post([this] { applyRequests(); }); + } #else - -/// @brief Single-threaded-Emscripten build of the same API, backed by the -/// browser's `setTimeout` (`emscripten_async_call`) instead of a -/// thread. See this file's `@file` comment for why it exists and -/// exactly how its behaviour differs. -class TimeoutScheduler { -public: - /// @brief Opaque identifier for one scheduled callback. - using Handle = std::uint64_t; - - /// @brief Creates the scheduler. Starts no thread — there is none to start. - TimeoutScheduler() = default; - - /// @brief Drops every still-pending callback without firing it. - /// - /// Browser timers already queued outlive this object; each holds only a - /// `std::weak_ptr` to `_state` and returns immediately once it expires, - /// which is precisely at this destructor. Matches the threaded build's - /// "`~TimeoutScheduler` drops pending entries without firing them". - ~TimeoutScheduler() = default; - - TimeoutScheduler(const TimeoutScheduler&) = delete; - TimeoutScheduler& operator=(const TimeoutScheduler&) = delete; - TimeoutScheduler(TimeoutScheduler&&) = delete; - TimeoutScheduler& operator=(TimeoutScheduler&&) = delete; - - /// @brief Schedules @p callback to run after @p delay on the main - /// (browser) thread, unless cancelled first via `cancel()`. - /// @param delay Time to wait before firing. - /// @param callback Invoked on the main thread if not cancelled in time. - /// Exceptions it throws are logged and swallowed. - /// @return Handle usable with `cancel()`. - Handle schedule(std::chrono::milliseconds delay, std::function callback) { - Handle const handle = ++_state->nextHandle; - _state->pending.emplace(handle, std::move(callback)); - // Owned by the browser timer, deleted by `fire` below whether or not - // the entry is still live by then. A raw `new` rather than a - // `unique_ptr` because the ownership genuinely crosses a C callback - // boundary that cannot carry a smart pointer. - auto* ticket = new Ticket{_state, handle}; - ::emscripten_async_call(&TimeoutScheduler::fire, ticket, clampMillis(delay)); - return handle; + apply(request); +#endif } - /// @brief Cancels a previously scheduled callback immediately. - /// - /// If @p handle has not fired yet, its callback (and anything that - /// callback captured) is released right away, exactly like the threaded - /// build. The browser timer itself is left to elapse and find nothing — - /// see the `@file` comment. A no-op if @p handle already fired or was - /// already cancelled. - /// - /// Unlike the threaded build, "already fired" here can only mean - /// *finished*: `fire()` and this function run on the same single thread, so - /// a callback cannot be mid-flight while `cancel()` is called. This build - /// therefore does give the guarantee the threaded one does not — no - /// callback runs after `cancel()` returns — and a caller that must work in - /// both builds still cannot rely on it. - /// @param handle Handle returned by a prior `schedule()` call. - void cancel(Handle handle) { _state->pending.erase(handle); } - -private: - struct State { - std::unordered_map > pending; - Handle nextHandle{0}; - }; - - struct Ticket { - std::weak_ptr state; - Handle handle; - }; +#ifndef MORPH_TIMEOUT_SCHEDULER_HOST_DRIVEN + /// Loop thread: applies every request queued since the last batch. + void applyRequests() { + std::vector batch; + { + std::scoped_lock const lock{_mtx}; + batch.swap(_requests); + } + for (auto const& request : batch) { + apply(request); + } + } +#endif - /// @brief @p delay as the `int` milliseconds `emscripten_async_call` - /// takes, saturating rather than wrapping (a `std::chrono` - /// duration can hold far more than an `int` can). - /// - /// @note This is a real, documented behavioural asymmetry from the - /// threaded build, which honours the full `std::chrono::milliseconds` - /// range unconditionally: a delay beyond `INT_MAX` ms (~24.85 days) fires - /// at ~24.85 days here instead of at its true, much later requested time. - /// `emscripten_async_call`'s `int` parameter is a hard platform - /// constraint with no larger-range alternative to fall back to, so this - /// is accepted rather than worked around. No caller in this codebase - /// currently requests a deadline anywhere near that range. - /// @param delay The requested delay. - /// @return A non-negative millisecond count that fits in an `int`. - [[nodiscard]] static int clampMillis(std::chrono::milliseconds delay) noexcept { - auto const count = delay.count(); - if (count <= 0) { - return 0; + /// Loop thread: arms or retires one timer. + void apply(Request const& request) { + if (!request.deadline) { + disarm(request.handle); + return; } - if (count > static_cast(std::numeric_limits::max())) { - return std::numeric_limits::max(); + { + // Cancelled before the loop got to it: nothing to arm. + std::scoped_lock const lock{_mtx}; + if (!_pending.contains(request.handle)) { + return; + } + } + auto [slot, inserted] = + _timers.try_emplace(request.handle, Timer{.owner = this, .handle = request.handle, .id = {}}); + if (inserted) { + slot->second.id = _loop.addTimer(*request.deadline, &TimeoutScheduler::fire, &slot->second); } - return static_cast(count); } - /// @brief The C callback the browser timer invokes. - /// @param arg The `Ticket*` handed to `emscripten_async_call`; always - /// deleted here, whether or not its entry is still live. - static void fire(void* arg) { - std::unique_ptr const ticket{static_cast(arg)}; - auto state = ticket->state.lock(); - if (!state) { + /// Loop thread: retires @p handle's timer, if it is still armed. + void disarm(Handle handle) { + auto found = _timers.find(handle); + if (found == _timers.end()) { return; } - auto found = state->pending.find(ticket->handle); - if (found == state->pending.end()) { - return; // cancelled before this timer elapsed + static_cast(_loop.cancelTimer(found->second.id)); + _timers.erase(found); + } + + /// Empties `_pending` under the lock, so no timer can fire what it held, + /// and hands its callbacks to the destructor to release outside it. + std::unordered_map> takePending() { + std::unordered_map> taken; + std::scoped_lock const lock{_mtx}; + taken.swap(_pending); + return taken; + } + + /// Loop thread: retires every armed timer. + void disarmAll() { + for (auto const& entry : _timers) { + static_cast(_loop.cancelTimer(entry.second.id)); + } + _timers.clear(); + } + + /// The loop's timer callback, on the loop's thread. + /// @param state The `Timer` this timer was armed with. + static void fire(void* state) { + auto const& timer = *static_cast(state); + TimeoutScheduler& self = *timer.owner; + Handle const handle = timer.handle; + self._timers.erase(handle); // `timer` is gone from here on + + std::function callback; + { + std::scoped_lock const lock{self._mtx}; + auto found = self._pending.find(handle); + if (found == self._pending.end()) { + return; // cancelled after the timer came due + } + callback = std::move(found->second); + self._pending.erase(found); } - std::function callback = std::move(found->second); - state->pending.erase(found); try { callback(); } catch (const std::exception& exc) { @@ -343,13 +309,21 @@ class TimeoutScheduler { } } - /// @brief Held by `shared_ptr` so a browser timer that outlives this - /// object detects that fact instead of writing to freed storage — - /// the same weak-token pattern as `morph::bridge::Bridge`'s - /// `_callbacks` `CallbackScope` (exposed as `Bridge::liveness()`). - std::shared_ptr _state{std::make_shared()}; -}; - + /// Guards `_pending`, `_nextHandle` and, natively, `_requests`. + std::mutex _mtx; + std::unordered_map> _pending; + Handle _nextHandle{0}; +#ifndef MORPH_TIMEOUT_SCHEDULER_HOST_DRIVEN + std::vector _requests; #endif + /// Loop thread only. + std::unordered_map _timers; + /// Declared after everything its timers point into, so it is destroyed + /// first. + ::core::net::PlatformLoop _loop; +#ifndef MORPH_TIMEOUT_SCHEDULER_HOST_DRIVEN + std::thread _thread; +#endif +}; } // namespace morph::async::detail diff --git a/include/morph/net/detail/base64.hpp b/include/morph/net/detail/base64.hpp deleted file mode 100644 index c2071fdb9..000000000 --- a/include/morph/net/detail/base64.hpp +++ /dev/null @@ -1,48 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -#pragma once -#include -#include -#include -#include -#include - -namespace morph::net::detail { - -/// @brief Encodes @p bytes as standard (RFC 4648) base64 with `=` padding. -/// @param bytes Input byte span to encode. -/// @return The base64-encoded string. -inline std::string base64Encode(std::span bytes) { - static constexpr std::string_view kAlphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; - std::string out; - out.reserve(((bytes.size() + 2) / 3) * 4); - std::size_t i = 0; - while (i + 3 <= bytes.size()) { - std::uint32_t const chunk = (static_cast(bytes[i]) << 16) | - (static_cast(bytes[i + 1]) << 8) | - static_cast(bytes[i + 2]); - out.push_back(kAlphabet[(chunk >> 18) & 0x3Fu]); - out.push_back(kAlphabet[(chunk >> 12) & 0x3Fu]); - out.push_back(kAlphabet[(chunk >> 6) & 0x3Fu]); - out.push_back(kAlphabet[chunk & 0x3Fu]); - i += 3; - } - std::size_t const remaining = bytes.size() - i; - if (remaining == 1) { - std::uint32_t const chunk = static_cast(bytes[i]) << 16; - out.push_back(kAlphabet[(chunk >> 18) & 0x3Fu]); - out.push_back(kAlphabet[(chunk >> 12) & 0x3Fu]); - out.push_back('='); - out.push_back('='); - } else if (remaining == 2) { - std::uint32_t const chunk = - (static_cast(bytes[i]) << 16) | (static_cast(bytes[i + 1]) << 8); - out.push_back(kAlphabet[(chunk >> 18) & 0x3Fu]); - out.push_back(kAlphabet[(chunk >> 12) & 0x3Fu]); - out.push_back(kAlphabet[(chunk >> 6) & 0x3Fu]); - out.push_back('='); - } - return out; -} - -} // namespace morph::net::detail diff --git a/include/morph/net/detail/ws_handshake.hpp b/include/morph/net/detail/ws_handshake.hpp index d7c33465b..9c9ce64df 100644 --- a/include/morph/net/detail/ws_handshake.hpp +++ b/include/morph/net/detail/ws_handshake.hpp @@ -2,6 +2,7 @@ #pragma once #include +#include #include #include #include @@ -9,7 +10,6 @@ #include #include -#include "base64.hpp" #include "sha1.hpp" #include "tcp_socket.hpp" @@ -25,7 +25,7 @@ inline std::string computeAcceptKey(std::string_view clientKey) { std::string concatenated{clientKey}; concatenated += kWebSocketGuid; auto digest = sha1Digest(concatenated); - return base64Encode(digest); + return ::core::base64::encode(digest.begin(), digest.end()); } /// @brief Generates a random 16-byte `Sec-WebSocket-Key`, base64-encoded. @@ -37,7 +37,7 @@ inline std::string generateClientKey() { for (auto& b : raw) { b = static_cast(dist(gen)); } - return base64Encode(raw); + return ::core::base64::encode(raw.begin(), raw.end()); } /// @brief The pieces of a `ws://` URL relevant to opening a socket + handshake. diff --git a/include/morph/net/socket_server.hpp b/include/morph/net/socket_server.hpp index 2464ed67d..f7f1b49f0 100644 --- a/include/morph/net/socket_server.hpp +++ b/include/morph/net/socket_server.hpp @@ -1,14 +1,14 @@ // SPDX-License-Identifier: Apache-2.0 #pragma once -#include #include -#include #include #include #include +#include #include +#include #include #include #include @@ -72,12 +72,12 @@ class SocketServer { /// @brief Starts listening for incoming WebSocket connections. /// - /// Fails closed: the accept loop's only way to stop waiting is the wakeup - /// pipe (see `acceptLoop()`), so if the pipe cannot be created — `pipe(2)` - /// answering `EMFILE`/`ENFILE` — this reports failure and spawns no thread - /// rather than starting a loop nothing could ever interrupt. + /// Fails closed: the accept loop's only way to stop waiting is its wakeup + /// (see `acceptLoop()`), so if that cannot be created — the kernel out of + /// descriptors — this reports failure and spawns no thread rather than + /// starting a loop nothing could ever interrupt. /// @return `true` if the server bound the requested port *and* armed its - /// wakeup pipe. + /// wakeup. bool listen() { try { _listenSocket = ::morph::net::detail::TcpSocket::listen(_requestedPort, _cfg.backlog); @@ -87,7 +87,7 @@ class SocketServer { // Non-blocking before the loop can ever reach ::accept -- see // TcpSocket::tryAccept()'s doc comment for why a poll() readiness // report is not a promise that accept() will not park. - if (!_listenSocket.setNonBlocking() || !_wakeup.open()) { + if (!_listenSocket.setNonBlocking() || !openWakeup()) { _listenSocket = ::morph::net::detail::TcpSocket{}; return false; } @@ -143,13 +143,16 @@ class SocketServer { // because Linux happens to kick a parked accept(2) when the listening // socket is shut down, and macOS/BSD do not, so the join below would // never return there. - // The loop now parks in poll() on this pipe as well as on the listener, - // so one byte here ends it on every platform. + // The loop parks in poll() on the wakeup as well as on the listener, + // so signalling it ends the loop on every platform. // // Runs at most once per listen()/close() cycle and never blocks: the // guard above returns before reaching it on a second call, and the - // write end is non-blocking regardless. - _wakeup.signal(); + // wakeup is non-blocking regardless. Absent only when listen() never + // armed one, in which case there is no accept thread to wake either. + if (_wakeup) { + _wakeup->signal(); + } if (_acceptThread.joinable()) { _acceptThread.join(); } @@ -236,13 +239,19 @@ class SocketServer { }; void acceptLoop() { + // listen() opens the wakeup before it starts this loop, and nothing + // resets it while the loop runs. + if (!_wakeup.has_value()) { + return; + } + auto const wakeupHandle = _wakeup->nativeHandle(); for (;;) { std::array fds{}; pollfd& listenPfd = fds.front(); pollfd& wakeupPfd = fds.back(); listenPfd.fd = _listenSocket.nativeHandle(); listenPfd.events = POLLIN; - wakeupPfd.fd = _wakeup.readFd(); + wakeupPfd.fd = wakeupHandle; wakeupPfd.events = POLLIN; // No timeout: the loop has an explicit wakeup now, so it has no // reason to surface periodically and re-check anything. @@ -253,7 +262,7 @@ class SocketServer { return; // poll() itself is broken; there is nothing left to wait on } if (wakeupPfd.revents != 0) { - return; // close() rang the wakeup pipe + return; // close() signalled the wakeup } if (listenPfd.revents == 0) { continue; @@ -459,89 +468,21 @@ class SocketServer { } } - /// RAII owner of the self-pipe `acceptLoop()` polls alongside the listener - /// and `close()` writes one byte to. - /// - /// A pipe rather than an `eventfd`: `eventfd` is Linux-only, and the whole - /// point of this mechanism is that it is the *same* mechanism on every - /// platform `morph::net` supports. A `#ifdef`-selected wakeup would be a - /// macOS-only code path that CI -- which is Linux-only -- could never - /// execute, guarding a bug CI could never observe. - class WakeupPipe { - public: - WakeupPipe() = default; - ~WakeupPipe() { reset(); } - WakeupPipe(const WakeupPipe&) = delete; - WakeupPipe& operator=(const WakeupPipe&) = delete; - WakeupPipe(WakeupPipe&&) = delete; - WakeupPipe& operator=(WakeupPipe&&) = delete; - - /// Creates a fresh pipe, discarding any previous one -- and with it any - /// byte an earlier close() left undrained, which would otherwise make - /// the next accept loop exit the moment it started. - /// @return `true` if the pipe was created. - bool open() { - reset(); - std::array fds{-1, -1}; - if (::pipe(fds.data()) != 0) { - return false; - } - _readFd = fds.front(); - _writeFd = fds.back(); - configure(_readFd); - configure(_writeFd); + /// Arms a fresh wakeup for the accept loop about to start. + /// @return `false` if the kernel refused one (out of descriptors or + /// kernel memory), which `listen()` turns into a refusal to listen. + bool openWakeup() noexcept { + try { + _wakeup.emplace(); return true; + } catch (const std::exception&) { + _wakeup.reset(); + return false; } + } - /// Closes both ends if open. - void reset() noexcept { - closeFd(_readFd); - closeFd(_writeFd); - } - - /// @return The read end, for the accept loop's `poll()` set (`-1` when closed). - [[nodiscard]] int readFd() const noexcept { return _readFd; } - - /// Makes the read end readable, waking a parked `poll()`. - /// - /// Best effort by construction, and never blocking: the pipe carries no - /// information beyond "readable", so a write that fails because the - /// buffer is already full has already achieved what it was for. - // NOLINTNEXTLINE(readability-make-member-function-const) — mutates the pipe this object owns - void signal() noexcept { - if (_writeFd < 0) { - return; - } - char const byte = 1; - ssize_t const written = ::write(_writeFd, &byte, 1); - static_cast(written); // a bare (void) cast does not silence GCC's warn_unused_result - } - - private: - // Return values deliberately unchecked: neither call can fail on a - // descriptor pipe() has just handed back, and a branch per fcntl would - // add arms to this header that no test could drive. ::fcntl is - // variadic by POSIX's design. - // NOLINTBEGIN(cppcoreguidelines-pro-type-vararg) - static void configure(int pipeFd) noexcept { - ::fcntl(pipeFd, F_SETFL, ::fcntl(pipeFd, F_GETFL, 0) | O_NONBLOCK); - ::fcntl(pipeFd, F_SETFD, FD_CLOEXEC); - } - // NOLINTEND(cppcoreguidelines-pro-type-vararg) - - static void closeFd(int& pipeFd) noexcept { - if (pipeFd >= 0) { - ::close(pipeFd); - pipeFd = -1; - } - } - - int _readFd{-1}; - int _writeFd{-1}; - }; - - /// Size of `acceptLoop()`'s poll set: the listening socket and the wakeup - /// pipe's read end. Typed `nfds_t` rather than converted at the call site + /// Size of `acceptLoop()`'s poll set: the listening socket and the + /// wakeup's descriptor. Typed `nfds_t` rather than converted at the call site /// because neither spelling of the conversion is portable: `nfds_t` is /// `unsigned long` on Linux, where an explicit cast trips GCC's /// `-Wuseless-cast`, and `unsigned int` on macOS, where an implicit one @@ -552,10 +493,13 @@ class SocketServer { std::uint16_t _requestedPort; Config _cfg; ::morph::net::detail::TcpSocket _listenSocket; - /// Written by `close()`, polled by `acceptLoop()`. Created by `listen()` - /// before the accept thread starts and released by `close()` after it has - /// joined, so the two never touch it concurrently. - WakeupPipe _wakeup; + /// Signalled by `close()`, polled by `acceptLoop()`: core-cpp's wakeup + /// primitive, an eventfd on Linux and a self-pipe elsewhere. Created by + /// `listen()` before the accept thread starts and released by `close()` + /// after it has joined, so the two never touch it concurrently. A fresh + /// one per `listen()`, so a signal an earlier `close()` left undrained + /// cannot end the next accept loop the moment it starts. + std::optional<::core::platform::Wakeup> _wakeup; /// Serializes `close()` against itself so only one caller ever reaches /// `_acceptThread.join()`. Not taken anywhere else. std::mutex _closeMtx; diff --git a/scripts/branch_partial_allowlist.json b/scripts/branch_partial_allowlist.json index d54bfc85f..394bfcfa9 100644 --- a/scripts/branch_partial_allowlist.json +++ b/scripts/branch_partial_allowlist.json @@ -95,15 +95,9 @@ "source": "if (!detail::addOverflows(whole, step)) {", "reason": "The false arm (the overflow-would-occur case, declining to step) is unreachable, contrary to Task 2's initial classification of this line as testable -- verified both mathematically and empirically (a standalone probe sweeping denominators/precisions found 476 cases where the scale-up saturated to whole == INT64_MAX, and every one had a fractional remainder of exactly 0, never >= 0.5) before writing this entry. A Rational's magnitude can never exceed INT64_MAX: its value is numerator/denominator with denominator >= 1 and numerator in [-INT64_MAX, INT64_MAX] (canonicalise() clamps INT64_MIN away), so |value| <= |numerator| <= INT64_MAX always. `whole` is trunc(scaled), so whole == INT64_MAX forces scaled == INT64_MAX/1 exactly -- there is no room for scaled to be in (INT64_MAX, INT64_MAX + 1) the way an unbounded rational could land. With scaled == whole exactly, `fraction` (scaled minus whole) is always 0, so `roundAway` (set from comparing fraction against 1/2) is always false when whole == INT64_MAX, and stepping never happens on that side. On the other side, step == -1 would need whole == INT64_MIN to overflow, but whole's range is [-INT64_MAX, INT64_MAX] (INT64_MIN is never a valid Rational magnitude), so that direction cannot overflow either. The guard is defensive: reachable only if a future change let a Rational's magnitude exceed INT64_MAX, which the type's invariants currently forbid everywhere else in this file." }, - { - "file": "include/morph/core/strand.hpp", - "line": 323, - "source": "if (iter != _strands.end() && iter->second == strand) {", - "reason": "Unreachable by construction given this class's lock discipline (core audit finding ST1, resolved to (b) by a concurrency-focused review pass after an initial (a)/(b)-undecided pass). `_strands` has exactly two mutation sites: the insert-if-absent `post()` reaches through `installStrand` (this file) and this exact block's own removal a few lines below -- an `extract` into `_spare`, which detaches the entry at the point an `erase` would -- both under `_mapMtx`. At most one lambda per `Strand` runs at a time (`post()` only schedules when `!strand->running`, and re-arming happens only through this same lambda's own `more` branch), so dispatch for one `Strand` is strictly serial; and only a strand's own currently-running lambda can erase its map entry (the erase fires only in the `!more` branch for the entry this frame just found under `_mapMtx`, and a concurrent `post(key)` while this lambda runs can only push onto the existing `Strand`, never replace it; a node parked in `_spare` is out of the map, so `find` cannot return it). Together these force `_strands.find(key)` to yield this exact strand whenever this line runs, so `iter->second == strand` cannot be false. No stress test needed: one was considered, but given the strength of the lock-discipline argument it would spend CI time re-confirming an already-proven invariant rather than searching for an unknown one." - }, { "file": "include/morph/core/backend.hpp", - "line": 1294, + "line": 1379, "source": "if (const auto* inst = _instances.find(modelId)) {", "reason": "Unreachable by construction given the `_changeAware`/`_instances` invariant (core audit finding BK2). `_changeAware` is an index over the instance directory: an id enters it in `createHolder` (this file, when the holder answers `isBackendChangeAware()`) in the same `_regMtx`-held critical section that files the instance, and leaves it in `deregisterModel` only when `InstanceDirectory::release` reports the instance actually destroyed. `notifyBackendChanged()` (this function) holds the same `_regMtx` while walking `_changeAware` and looking each id up at this line, so every id it walks is still live -- the null arm cannot occur without a code change that breaks that subset invariant." }, @@ -115,37 +109,37 @@ }, { "file": "include/morph/core/remote.hpp", - "line": 1413, + "line": 1417, "source": "if (_inFlightExecutes.compare_exchange_weak(current, current + 1, std::memory_order_relaxed)) {", - "reason": "Real but requires genuine thread contention to trigger -- accepted as documented rather than closed with a flaky test (core audit finding RM11). The false arm (the CAS lost the race and must retry) needs two threads to genuinely collide on the same atomic increment at the same instant; it is a real, reachable hazard the retry loop correctly handles, not dead code, but inherently non-deterministic to trigger from a test without exact thread-timing control. Same disposition class as `strand.hpp`'s ST1 above, and as core audit finding O1 (`observability.hpp`'s `endSpan`), whose entry left this file once a coverage run showed its arm taken: a stress test with many concurrent `execute()` calls against a tight `maxInFlightExecutes` limit would probably eventually hit it, but flakily. RE-READ under -fprofile-update=atomic, without which this disposition is not safe to trust: the corruption direction is untaken -> appears taken, so an entry arguing \"real but never observed\" is exactly the kind that can rest on a wrapped count. It does not, and the reading below is a spread rather than a single figure -- one run cannot tell a flag effect from run order. Five CI-equivalent coverage runs, same binaries, same 2962 tests, atomic counters, on this line: `Branch (1416:21): [True: 3, False: 0]` in all five, identical to the digit. The false arm is taken by nothing in any run, so the disposition stands unchanged; had it read a wrapped 18.4E the entry would have been retired instead. What would retire it now: any run reporting a non-zero False here, which would mean the retry arm is reachable from the suite after all." + "reason": "Real but requires genuine thread contention to trigger -- accepted as documented rather than closed with a flaky test (core audit finding RM11). The false arm (the CAS lost the race and must retry) needs two threads to genuinely collide on the same atomic increment at the same instant; it is a real, reachable hazard the retry loop correctly handles, not dead code, but inherently non-deterministic to trigger from a test without exact thread-timing control. Same disposition class as core audit finding O1 (`observability.hpp`'s `endSpan`), whose entry left this file once a coverage run showed its arm taken: a stress test with many concurrent `execute()` calls against a tight `maxInFlightExecutes` limit would probably eventually hit it, but flakily. RE-READ under -fprofile-update=atomic, without which this disposition is not safe to trust: the corruption direction is untaken -> appears taken, so an entry arguing \"real but never observed\" is exactly the kind that can rest on a wrapped count. It does not, and the reading below is a spread rather than a single figure -- one run cannot tell a flag effect from run order. Five CI-equivalent coverage runs, same binaries, same 2962 tests, atomic counters, on this line: `Branch (1416:21): [True: 3, False: 0]` in all five, identical to the digit. The false arm is taken by nothing in any run, so the disposition stands unchanged; had it read a wrapped 18.4E the entry would have been retired instead. What would retire it now: any run reporting a non-zero False here, which would mean the retry arm is reachable from the suite after all." }, { "file": "include/morph/core/bridge.hpp", - "line": 2012, + "line": 2124, "source": "if (_executeDeadline.count() > 0 && _timeoutScheduler) {", "reason": "Unreachable by construction (core audit finding B6). `setExecuteDeadline` (this file) is the only writer of both `_executeDeadline` and `_timeoutScheduler`, and always creates `_timeoutScheduler` in the same call that sets `_executeDeadline` positive (`_executeDeadline = deadline; if (_executeDeadline.count() > 0 && !_timeoutScheduler) { _timeoutScheduler = std::make_shared<...>(); }`, both under `_executeDeadlineMtx`); nothing anywhere resets `_timeoutScheduler` back to null -- the class's own doc comment on `setExecuteDeadline` says so explicitly (\"setting the deadline back to 0 stops new calls from arming it but does not tear the thread down\"). So `_executeDeadline > 0 && !_timeoutScheduler` cannot happen at this line once any positive deadline has ever been set." }, { "file": "include/morph/core/remote.hpp", - "line": 1491, + "line": 1499, "source": "if (_timeoutScheduler) {", "reason": "Unreachable by construction, mirrors `bridge.hpp`'s B6 (core audit finding RM10). `setLimitPolicy` (this file) is the only writer of both `_limits.executeTimeout` and `_timeoutScheduler`: `_limits = policy; if (_limits.executeTimeout.count() > 0 && !_timeoutScheduler) { _timeoutScheduler = std::make_unique<...>(); }`, both under `_limitsMtx` -- the same lock this line's enclosing block holds. Nothing anywhere nulls `_timeoutScheduler` afterward, so `dispatchExecute`'s `limits.executeTimeout.count() > 0` guard (this line's enclosing `if`, a few lines above) already guarantees `_timeoutScheduler` is non-null whenever this line runs." }, { "file": "include/morph/net/socket_server.hpp", - "line": 189, + "line": 192, "source": "if (t.joinable()) {", "reason": "Unreachable by construction (net audit, `socket_server.hpp` finding #6, verified against a `close()` whose whole body is serialized under `_closeMtx`). `_clientThreads` has exactly one push site (`acceptLoop()`, always a freshly-constructed, running `std::thread`) and this loop is the only place any entry is ever joined or detached. With `close()`'s entire body serialized by `_closeMtx`, only one caller's `close()` can ever reach this loop: a second, later call observes `wasAlreadyClosing == true` and `!_acceptThread.joinable()` (already joined by the winner) and takes the early return above, before ever reaching the client-thread swap-and-join section this line is in. So every `std::thread` this loop iterates over is a fresh entry pushed by `acceptLoop()` that nothing has touched yet -- `joinable()` cannot be false here." }, { "file": "include/morph/net/socket_server.hpp", - "line": 224, + "line": 227, "source": "if (closed.load() || !socket.valid()) {", "reason": "The `!socket.valid()` disjunct is unreachable by construction (net audit, `socket_server.hpp` finding #7). `ClientConnection::socket` is set once at construction and never moved from or reassigned anywhere in this file (only method calls on it, never an assignment or `std::move`); `TcpSocket::valid()` is `_fd >= 0`, and `_fd` only becomes -1 in the move constructor/assignment and the destructor, neither of which can run while `sendText()` holds a `shared_ptr`. `shutdownBoth()` does not touch `_fd`. `closed.store(true)` is what every real teardown path sets first, so the `closed.load()` disjunct alone accounts for all of them. This entry survives a gate failure that looks like it retires it, so the artifact is recorded here: without -fprofile-update=atomic, a coverage run can report this disjunct as *taken* and fail the gate with \"include/morph/net/socket_server.hpp:224 is allowlisted as an uncoverable partial branch, but it is not a partial line in this report\", while four neighbouring reports of the same unchanged code all report it untaken (11 partial lines in this file, each time). The cause: llvm-cov derives the second operand of a short-circuit || by subtracting counters rather than counting it, so with non-atomic counters a concurrent update makes that subtraction go negative and wrap to a huge \"taken\" count. Demonstrated with a controlled probe: 12 threads over `if (flag.load() || !alwaysTrue())` reported `True: 18.4E` for the never-taken arm in 8 runs of 8; the same binary single-threaded reported `True: 0` in 3 of 3, and the same 12 threads built with -fprofile-update=atomic reported `True: 0` in 3 of 3. cmake/compiler_options.cmake's apply_coverage() passes that flag, which is what makes this entry stable rather than flaky. Re-measured with it: `Branch (224:17): [True: 69, False: 1.52k]` and `Branch (224:34): [True: 0, False: 1.52k]`. What would make it reachable, and so retire this entry: any code that move-assigns or destroys ClientConnection::socket while another thread can be inside sendText() -- replacing the connection's socket on a reconnect, say, or dropping the shared_ptr discipline. If this gate reports the line non-partial again on a build carrying -fprofile-update=atomic, that is a real change and the entry should be deleted rather than argued with." }, { "file": "include/morph/net/socket_server.hpp", - "line": 267, + "line": 276, "source": "if (!clientSocket) {", "reason": "Real, reachable race (`tryAccept()` returning nullopt because the pending connection went away before it was taken), but accepted as documented rather than forced with a flaky test after extensive attempts (net audit, `socket_server.hpp` finding #10). Three different techniques were tried: a single real `TcpSocket::connect()` immediately followed by an abortive (`SO_LINGER{1,0}`) close (0/150 hits); a burst of many such attempts to build backlog depth (still 0 hits); and a burst of bare non-blocking `::connect()`+abort attempts skipping `TcpSocket::connect()`'s `getaddrinfo()`/poll overhead (960 attempts across 15 bursts, still 0 hits, with most connections resetting before the TCP handshake progressed far enough to make the listener readable at all, rather than after). No way was found, from outside the process, to reliably land in the specific narrow window this branch requires on this machine. Reported as attempted-and-left-open rather than forcing something flakier. RE-READ under -fprofile-update=atomic, over several runs rather than one, because a single figure cannot be told apart from run order. Five CI-equivalent coverage runs, same binaries, same 2962 tests, atomic counters: `Branch (267:17): [True: 0, False: 3.80k / 3.80k / 3.82k / 3.75k / 3.78k]`, with line 268 (`continue`) at 0 executions in every run. The denominator moves with how many accepts the suite happens to perform -- 3.75k to 3.82k across the five, and 576 on another machine -- and the numerator does not move at all: tens of thousands of accepts across five runs, none of them nullopt. The disposition stands, and the figure it quotes is one no concurrent counter update can inflate. What would retire it: a True count above 0 in any run." }, diff --git a/scripts/check_coverage_objects.sh b/scripts/check_coverage_objects.sh index c5ad995c1..7d948bd62 100755 --- a/scripts/check_coverage_objects.sh +++ b/scripts/check_coverage_objects.sh @@ -73,6 +73,9 @@ coverage_exclusion_reason() { morph_journal_skew_old|morph_journal_skew_new) echo "two probe programs registering a model that exists nowhere else in the tree; instrumenting them would add template instantiations unique to the probe and score them against the library, and what the probe asserts is about a journal file on disk rather than about coverage" ;; + morph_client_only_guard_links|morph_client_only_runtime_throw|morph_client_only_facade) + echo "the MORPH_CLIENT_ONLY guard probes (tests/CMakeLists.txt), which exit 0 when the guard holds: configure-time try_compile()/try_run() checks until core-cpp's compiled modules made them build-time targets, and never measured then either; they compile morph's headers under MORPH_CLIENT_ONLY, so instrumenting them would score that configuration against the library's" + ;; fuzz_wire_decode|fuzz_dispatch_execute) echo "libFuzzer harnesses (MORPH_BUILD_FUZZERS=ON only, which the coverage leg does not set); apply_fuzzer() builds them at -O1 under -fsanitize=fuzzer,address, a different instrumentation from apply_coverage()'s" ;; diff --git a/scripts/check_coverage_roots.sh b/scripts/check_coverage_roots.sh index 98c6e1840..67d5d3160 100755 --- a/scripts/check_coverage_roots.sh +++ b/scripts/check_coverage_roots.sh @@ -78,27 +78,19 @@ readonly profdata="${build_dir}/merged.profdata" # symlink would report every file as foreign. readonly source_root="$(pwd -P)" -# Third-party dependency sources are legitimately outside the checkout -# `cmake/DepCache.cmake` points FetchContent at a shared cache so a -# CI run clones once instead of a dozen times, and those trees then sit under -# the runner's home rather than under `build/*/_deps`, where they used to be -# only because FetchContent happened to put them there. +# Third-party dependency sources may legitimately be outside the checkout. +# CPM keeps every fetched dependency in CPM_SOURCE_CACHE so a CI run clones +# once instead of a dozen times. CMakeLists.txt defaults that directory to +# `.cache/cpm` inside the checkout, which the source-root test below already +# admits; an explicit `CPM_SOURCE_CACHE` in the environment can put it anywhere, +# and is admitted here. # # They are dropped from the report either way -- coverage.sh filters to # `include/morph` and the example rungs -- so their absence is intended, not the # silence this gate exists to catch. What it is looking for is *morph's own* # sources arriving from a foreign worktree, and that hazard is untouched by # this: a foreign worktree is not the dependency cache. -# -# Resolved exactly as DepCache.cmake resolves it, so the two cannot drift into -# disagreeing about where the cache is. -if [ -n "${MORPH_DEP_CACHE:-}" ]; then - dep_cache_root="${MORPH_DEP_CACHE}" -elif [ -n "${CI:-}" ] && [ -n "${HOME:-}" ]; then - dep_cache_root="${HOME}/.cache/morph-dep-cache" -else - dep_cache_root="" -fi +dep_cache_root="${CPM_SOURCE_CACHE:-}" readonly dep_cache_root if [ -n "$export_json_file" ]; then diff --git a/scripts/check_install_export.sh b/scripts/check_install_export.sh index 487fd6f79..a45be62fa 100755 --- a/scripts/check_install_export.sh +++ b/scripts/check_install_export.sh @@ -152,6 +152,12 @@ if [ "$skip_header_set_verification" -eq 0 ]; then fi fi +# morph is header-only, but morph::morph links core-cpp's static modules, and +# morph's install installs them with core-cpp's package. They have to be built +# first: installing an unbuilt tree fails with "file INSTALL cannot find". +run_step "the library did not build" \ + cmake --build "$build_dir" || true + # `cmake --install` exiting 0 is exactly what it does when it installs nothing, so its # exit status is worth nothing on its own. It is checked anyway -- a *failing* # install is still a failure -- and then the prefix is inspected. diff --git a/scripts/test_check_coverage_roots.sh b/scripts/test_check_coverage_roots.sh index b5c7e5730..61de5bb96 100755 --- a/scripts/test_check_coverage_roots.sh +++ b/scripts/test_check_coverage_roots.sh @@ -103,12 +103,12 @@ fi # 2b. The dependency cache is admitted: its trees are third-party # sources that coverage.sh filters out anyway, and they live outside the -# checkout only because DepCache.cmake shares them across a run's dozen -# configures instead of re-cloning each time. +# checkout only because CPM_SOURCE_CACHE points CPM's shared cache there +# instead of re-cloning for each of a run's dozen configures. write_export "$tmp/depcache.json" \ "${repo_root}/include/morph/core/bridge.hpp" \ "$tmp/dep-cache/glaze_v7_4_0/include/glaze/glaze.hpp" -if MORPH_DEP_CACHE="$tmp/dep-cache" run_checker "$tmp/nonexistent-build" "$tmp/depcache.json" > "$tmp/2b.out" 2>&1; then +if CPM_SOURCE_CACHE="$tmp/dep-cache" run_checker "$tmp/nonexistent-build" "$tmp/depcache.json" > "$tmp/2b.out" 2>&1; then note "ok 2b: a file in the configured dependency cache passes" else fail "2b: a file in the dependency cache was rejected" @@ -123,7 +123,7 @@ write_export "$tmp/depcache-and-foreign.json" \ "${repo_root}/include/morph/core/bridge.hpp" \ "$tmp/dep-cache/glaze_v7_4_0/include/glaze/glaze.hpp" \ "/home/somebody/repo/morph-wt/999/examples/crm/src/models/account_model.cpp" -if MORPH_DEP_CACHE="$tmp/dep-cache" run_checker "$tmp/nonexistent-build" \ +if CPM_SOURCE_CACHE="$tmp/dep-cache" run_checker "$tmp/nonexistent-build" \ "$tmp/depcache-and-foreign.json" > "$tmp/2c.out" 2>&1; then fail "2c: with a dependency cache configured, a foreign worktree was accepted too" cat "$tmp/2c.out" >&2 diff --git a/scripts/test_check_install_export.sh b/scripts/test_check_install_export.sh index 80172e85d..8b8b8598e 100755 --- a/scripts/test_check_install_export.sh +++ b/scripts/test_check_install_export.sh @@ -171,8 +171,10 @@ expect_caught "morph's install rules not running at all" \ # a fixed one, because the compile stops at the first missing include and never # reports the rest. So it moves whenever a public header gains a detail/ include # that sorts ahead of the previous first -- it read `morph/detail/fixed_string.hpp` -# until `morph/core/backend.hpp` started including `detail/instance_directory.hpp` -# which the consumer reaches earlier. A failure here saying "caught +# until `morph/core/backend.hpp` started including `detail/instance_directory.hpp`, +# and that until `morph/core/completion.hpp` started including +# `detail/completion_awaiter.hpp`, each of which the consumer reaches earlier. A +# failure here saying "caught # for the WRONG reason" and naming some other detail/ header is that, and the fix # is to update this needle, not to touch the install rules. The needle stays # specific rather than becoming a loose `detail/` match so that this case still @@ -182,7 +184,7 @@ expect_caught "the detail/ header set dropped from the install" \ "edit CMakeLists.txt -e '/^# The detail\/ headers that public headers include\./,/^set_target_properties(morph PROPERTIES INTERFACE_HEADER_SETS_TO_VERIFY HEADERS)$/d' -e '/FILE_SET morph_detail_headers DESTINATION/d'" \ fast \ "the consumer project did not compile against the install prefix" \ - "detail/instance_directory.hpp" + "detail/completion_awaiter.hpp" # Bug 2: INTERFACE_HEADER_SETS_TO_VERIFY defaults to *every* interface header # set, including the detail/ one, which is deliberately not held to compiling diff --git a/src/qt/forms/CMakeLists.txt b/src/qt/forms/CMakeLists.txt index 4afeb9e73..8e9fd67aa 100644 --- a/src/qt/forms/CMakeLists.txt +++ b/src/qt/forms/CMakeLists.txt @@ -64,6 +64,7 @@ if(MORPH_BUILD_TESTS AND NOT EMSCRIPTEN) qt_add_executable(morph_forms_qml_tests tests/tst_main.cpp) target_link_libraries(morph_forms_qml_tests PRIVATE morph_forms_moduleplugin Qt6::QuickTest) + morph_suppress_test_dialogs(morph_forms_qml_tests) if(DEFINED AF_SANITIZER) apply_sanitizers(morph_forms_qml_tests ${AF_SANITIZER}) diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 077fdd340..e2aeef265 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -22,13 +22,14 @@ add_executable(morph_tests test_example.cpp test_executor_extra.cpp test_strand.cpp - test_strand_extra.cpp - test_strand_race.cpp test_completion.cpp test_completion_extra.cpp test_completion_multi_handler.cpp test_completion_promise.cpp test_completion_value_contract.cpp + test_coroutine_client.cpp + test_coroutine_model.cpp + test_async_delay.cpp test_model.cpp test_logger.cpp test_observability.cpp @@ -218,55 +219,10 @@ include(Catch) # test fails fast instead of stalling the whole CI job (observed: a single test # hanging blocked a Windows runner for over half an hour). # -# That number was never chosen with any particular test in mind, which is -# fine while every test is sub-second and wrong for the one that is not: the -# longest, most load-sensitive case in the binary was governed by the same -# ceiling as the cheapest. So the blanket stays and `[slow]` is -# registered separately -- DISCOVERY_MODE -# PRE_TEST defers discovery to ctest invocation time, so a per-test TIMEOUT -# cannot be set with set_tests_properties() here (nothing is named that test -# yet at configure time); excluding a tag and giving it its own -# catch_discover_tests() call is the mechanism that is actually available. -# -# (The `equation()` depth test no longer needs the tag: at 70,000 nodes it blew -# past 120s under TSan because of O(n^2) string building, and with that -# rendering linear it is back under the blanket cap. The tag is reused -# here for a different test and a different reason.) -catch_discover_tests(morph_tests DISCOVERY_MODE PRE_TEST TEST_SPEC "~[slow]" PROPERTIES TIMEOUT 120) - -# `[slow]`: `StrandExecutor never runs two tasks for one key -# concurrently under contention`. Not slow because it computes anything -- -# 0.14 s on an idle box -- but because the strand serialises 3200 tasks per -# iteration and every handoff is a thread wakeup that has to wait its turn on -# the run queue. That cost is set by how contended the host is, and it climbs -# steeply. Measured on this tree, 12 cores, clang 22.1.8 Release, load made -# with plain spin loops, whole-case wall clock, one run each: -# -# run queue 1 (idle) -> 0.14 s -# run queue 14 -> 23.8 s -# run queue 27 -> 170.5 s -# run queue 38 -> 396.4 s <- load average 31-38 at measurement -# -# Every one of those runs *passed*: 40 assertions, `inFlight 1, maxInFlight 1` -# throughout. What this tag answers is the case being killed while passing, at -# load average 29-48 -- above the 38 measured here, and the curve is -# superlinear, so the fitted figure at a run queue of ~50 is roughly 730 s. -# -# 900 s is therefore ~2.3x the measured 396 s and ~1.2x that extrapolation. It -# is also the number this file already uses for its other load-sensitive case -# (`forms_schema_generation_is_not_route_count_sensitive`, below), chosen there -# for the same reason: a slow shared runner can be several times a workstation. -# -# What this deliberately does **not** do is reduce `kIterations`. Twenty -# iterations is this case's detection power for a rare interleaving; trading it -# away to fit a ceiling would make the case cheaper and worse at the only thing -# it exists for. See the comment on `kIterations` in test_strand_race.cpp. -# -# And what it does not buy is immunity: a host oversubscribed past roughly 4.5x -# will exceed 900 s too. The ceiling is here to catch a *deadlock* -- which is -# unbounded, not merely large -- and it still does, in 15 minutes instead of 2, -# for one test out of 3044. -catch_discover_tests(morph_tests DISCOVERY_MODE PRE_TEST TEST_SPEC "[slow]" PROPERTIES TIMEOUT 900) +# The one case that needed more, the strand race test tagged `[slow]`, went +# with morph's own strand when morph moved to core-cpp's `KeyedStrands`, which +# carries its own race tests. +catch_discover_tests(morph_tests DISCOVERY_MODE PRE_TEST PROPERTIES TIMEOUT 120) # ── Two-binary journal-path skew test ─────────────────────────────────────── # The executable form of the journal's data-at-rest contract: an @@ -304,6 +260,7 @@ foreach(_skew_role old new) target_compile_definitions(morph_journal_skew_${_skew_role} PRIVATE MORPH_SKEW_ROLE_${_skew_role_upper}) target_link_libraries(morph_journal_skew_${_skew_role} PRIVATE morph::morph) + morph_suppress_test_dialogs(morph_journal_skew_${_skew_role}) apply_warnings(morph_journal_skew_${_skew_role}) if(DEFINED AF_SANITIZER) apply_sanitizers(morph_journal_skew_${_skew_role} ${AF_SANITIZER}) @@ -414,6 +371,11 @@ endif() get_target_property(MORPH_VETTED_HMAC_GUARD_INCLUDE_DIRS morph INTERFACE_INCLUDE_DIRECTORIES) get_target_property(MORPH_VETTED_HMAC_GUARD_GLAZE_INCLUDE_DIRS glaze::glaze INTERFACE_INCLUDE_DIRECTORIES) list(APPEND MORPH_VETTED_HMAC_GUARD_INCLUDE_DIRS ${MORPH_VETTED_HMAC_GUARD_GLAZE_INCLUDE_DIRS}) +# core-cpp's headers, spelled out rather than read from core::base: its +# include directories are $ generator expressions, which +# a try_compile() here would receive unevaluated. `src/` holds the headers and +# the binary directory's `include/` the generated . +list(APPEND MORPH_VETTED_HMAC_GUARD_INCLUDE_DIRS "${core-cpp_SOURCE_DIR}/src" "${core-cpp_BINARY_DIR}/include") string(REPLACE ";" "\\;" MORPH_VETTED_HMAC_GUARD_INCLUDE_DIRS "${MORPH_VETTED_HMAC_GUARD_INCLUDE_DIRS}") unset(MORPH_VETTED_HMAC_GUARD_BLOCKS_DEFAULT CACHE) @@ -461,12 +423,9 @@ if(NOT MORPH_VETTED_HMAC_GUARD_DEFAULT_WORKS_UNGATED) endif() # ── MORPH_CLIENT_ONLY guard check ──────────────────────────────────────────── -# Configure-time proof that MORPH_CLIENT_ONLY actually suppresses the two -# model-owning registrars (registerModelOnce, registerActionOnce) -- see -# docs/spec/core/registry.md, "MORPH_CLIENT_ONLY". Reuses -# MORPH_VETTED_HMAC_GUARD_INCLUDE_DIRS (morph + glaze include dirs; see that -# guard's own comments above for why try_compile needs them forwarded this -# way rather than via LINK_LIBRARIES). +# Proof that MORPH_CLIENT_ONLY actually suppresses the two model-owning +# registrars (registerModelOnce, registerActionOnce) -- see +# docs/spec/core/registry.md, "MORPH_CLIENT_ONLY". # # compile_checks/client_only_no_model_link.cpp declares (never defines) # ClientOnlyModel's constructor and execute(). Two probes, in opposite @@ -475,27 +434,36 @@ endif() # - WITH MORPH_CLIENT_ONLY defined: must LINK (the model-owning registrars # are macroed away, so nothing in the program ever references either # undefined symbol). -# - WITHOUT it (today's default): must FAIL TO LINK (the registrars' lambda -# bodies do reference them, and the linker cannot resolve either symbol). -unset(MORPH_CLIENT_ONLY_GUARD_SUPPRESSES_LINKAGE CACHE) -try_compile(MORPH_CLIENT_ONLY_GUARD_SUPPRESSES_LINKAGE - SOURCES "${CMAKE_CURRENT_SOURCE_DIR}/compile_checks/client_only_no_model_link.cpp" - CMAKE_FLAGS "-DINCLUDE_DIRECTORIES=${MORPH_VETTED_HMAC_GUARD_INCLUDE_DIRS}" - CXX_STANDARD 23 - COMPILE_DEFINITIONS "-DMORPH_CLIENT_ONLY" - OUTPUT_VARIABLE MORPH_CLIENT_ONLY_GUARD_SUPPRESSES_LINKAGE_OUTPUT -) -if(NOT MORPH_CLIENT_ONLY_GUARD_SUPPRESSES_LINKAGE) - message(FATAL_ERROR - "MORPH_CLIENT_ONLY guard check failed: " - "compile_checks/client_only_no_model_link.cpp failed to link with " - "MORPH_CLIENT_ONLY defined, even though ClientOnlyModel's constructor " - "and execute() are never called anywhere in that program (the " - "model-owning registrars must be fully suppressed).\n" - "--- compiler/linker output ---\n" - "${MORPH_CLIENT_ONLY_GUARD_SUPPRESSES_LINKAGE_OUTPUT}") -endif() +# - WITHOUT it (today's default): must FAIL TO LINK, and must fail on +# ClientOnlyModel's symbols (the registrars' lambda bodies do reference +# them, and the linker cannot resolve either). +# +# The probes that must link, and the two that must also run, are ordinary +# build-time targets rather than try_compile()/try_run(): morph's headers use +# core-cpp's compiled modules (core::net's event loop, through +# TimeoutScheduler), and a try_compile() scratch project can link only +# imported targets -- core-cpp's are built by this project, after configure. +# A probe that fails to link now fails the build rather than the configure; +# the run probes are ctest cases. The probes link morph's dependencies +# directly rather than morph::morph, whose interface carries MORPH_CLIENT_ONLY +# when that option is on, so each probe decides the macro for itself. +add_library(morph_guard_probe_deps INTERFACE) +target_include_directories(morph_guard_probe_deps INTERFACE "${PROJECT_SOURCE_DIR}/include") +target_link_libraries(morph_guard_probe_deps INTERFACE + glaze::glaze core::base core::async core::net core::platform + $) +add_executable(morph_client_only_guard_links compile_checks/client_only_no_model_link.cpp) +target_compile_definitions(morph_client_only_guard_links PRIVATE MORPH_CLIENT_ONLY) +target_link_libraries(morph_client_only_guard_links PRIVATE morph_guard_probe_deps) +morph_suppress_test_dialogs(morph_client_only_guard_links) +add_test(NAME morph_client_only_guard_links COMMAND morph_client_only_guard_links) +set_tests_properties(morph_client_only_guard_links PROPERTIES TIMEOUT 60) + +# The negative probe stays a configure-time try_compile(): the link failure is +# the assertion. Its scratch project cannot link core-cpp either, so a failure +# alone would prove nothing; the check is that the linker named +# ClientOnlyModel -- the symbols the default registrars reference. unset(MORPH_CLIENT_ONLY_GUARD_DEFAULT_NEEDS_LINKAGE CACHE) try_compile(MORPH_CLIENT_ONLY_GUARD_DEFAULT_NEEDS_LINKAGE SOURCES "${CMAKE_CURRENT_SOURCE_DIR}/compile_checks/client_only_no_model_link.cpp" @@ -503,6 +471,11 @@ try_compile(MORPH_CLIENT_ONLY_GUARD_DEFAULT_NEEDS_LINKAGE CXX_STANDARD 23 OUTPUT_VARIABLE MORPH_CLIENT_ONLY_GUARD_DEFAULT_NEEDS_LINKAGE_OUTPUT ) +# GNU ld: "undefined reference to `ClientOnlyModel::...", lld: "undefined +# symbol: ClientOnlyModel::...", MSVC: "unresolved external symbol ... +# ClientOnlyModel...", ld64: "ClientOnlyModel::..., referenced from". +set(_morph_client_only_unresolved + "(undefined reference to|undefined symbol:|unresolved external symbol)[^\n]*ClientOnlyModel|ClientOnlyModel[^\n]*referenced from") if(MORPH_CLIENT_ONLY_GUARD_DEFAULT_NEEDS_LINKAGE) message(FATAL_ERROR "MORPH_CLIENT_ONLY guard check failed: " @@ -513,54 +486,30 @@ if(MORPH_CLIENT_ONLY_GUARD_DEFAULT_NEEDS_LINKAGE) "probe is not actually proving anything about the guard).\n" "--- compiler/linker output ---\n" "${MORPH_CLIENT_ONLY_GUARD_DEFAULT_NEEDS_LINKAGE_OUTPUT}") -endif() - -# The two try_compile() checks above only prove the *registration-suppression* -# half of the guard (static-init-time link resolution). The other half -- -# that Bridge::executeVia's localOp actually throws std::logic_error at -# *runtime* under MORPH_CLIENT_ONLY, instead of silently calling -# Model::execute -- needs the probe to actually run, not just link. try_run() -# compiles AND executes compile_checks/client_only_runtime_throw.cpp, -# capturing its exit code (0 = the expected std::logic_error was caught). -unset(MORPH_CLIENT_ONLY_RUNTIME_THROW_COMPILED CACHE) -unset(MORPH_CLIENT_ONLY_RUNTIME_THROW_EXITCODE CACHE) -try_run(MORPH_CLIENT_ONLY_RUNTIME_THROW_EXITCODE MORPH_CLIENT_ONLY_RUNTIME_THROW_COMPILED - "${CMAKE_CURRENT_BINARY_DIR}/client_only_runtime_throw_check" - "${CMAKE_CURRENT_SOURCE_DIR}/compile_checks/client_only_runtime_throw.cpp" - CMAKE_FLAGS "-DINCLUDE_DIRECTORIES=${MORPH_VETTED_HMAC_GUARD_INCLUDE_DIRS}" - CXX_STANDARD 23 - COMPILE_DEFINITIONS "-DMORPH_CLIENT_ONLY" - # CMAKE_THREAD_LIBS_INIT (a raw linker-flag string set by the outer - # project's find_package(Threads REQUIRED) at CMakeLists.txt:102, e.g. - # "-lpthread" on Linux, empty on platforms needing nothing extra) rather - # than the Threads::Threads *target*: unlike Qt6::Core/Network/WebSockets - # (an exported find_package(Qt6) target, used the same way by the - # MORPH_QT_NO_SSL_GUARD_COMPILES check above), the CMake-bundled - # FindThreads module's imported target does not reliably resolve inside - # try_run()'s isolated scratch project on every platform -- confirmed by - # a real CI failure on Windows/MSVC ("Target ... links to: Threads::Threads - # ... but the target was not found"). - LINK_LIBRARIES "${CMAKE_THREAD_LIBS_INIT}" - COMPILE_OUTPUT_VARIABLE MORPH_CLIENT_ONLY_RUNTIME_THROW_COMPILE_OUTPUT - RUN_OUTPUT_VARIABLE MORPH_CLIENT_ONLY_RUNTIME_THROW_RUN_OUTPUT -) -if(NOT MORPH_CLIENT_ONLY_RUNTIME_THROW_COMPILED) +elseif(NOT MORPH_CLIENT_ONLY_GUARD_DEFAULT_NEEDS_LINKAGE_OUTPUT MATCHES "${_morph_client_only_unresolved}") message(FATAL_ERROR "MORPH_CLIENT_ONLY guard check failed: " - "compile_checks/client_only_runtime_throw.cpp failed to compile with " - "MORPH_CLIENT_ONLY defined.\n" - "--- compiler output ---\n" - "${MORPH_CLIENT_ONLY_RUNTIME_THROW_COMPILE_OUTPUT}") -endif() -if(NOT MORPH_CLIENT_ONLY_RUNTIME_THROW_EXITCODE EQUAL 0) - message(FATAL_ERROR - "MORPH_CLIENT_ONLY guard check failed: " - "compile_checks/client_only_runtime_throw.cpp did not observe the " - "expected std::logic_error from Bridge::executeVia's localOp under " - "MORPH_CLIENT_ONLY (exit code ${MORPH_CLIENT_ONLY_RUNTIME_THROW_EXITCODE}).\n" - "--- program output ---\n" - "${MORPH_CLIENT_ONLY_RUNTIME_THROW_RUN_OUTPUT}") + "compile_checks/client_only_no_model_link.cpp failed to link WITHOUT " + "MORPH_CLIENT_ONLY defined, as it must -- but the linker did not name " + "ClientOnlyModel, so it failed for some other reason and proves " + "nothing about the guard.\n" + "--- compiler/linker output ---\n" + "${MORPH_CLIENT_ONLY_GUARD_DEFAULT_NEEDS_LINKAGE_OUTPUT}") endif() +unset(_morph_client_only_unresolved) + +# The link probe above only proves the *registration-suppression* half of the +# guard (static-init-time link resolution). The other half -- that +# Bridge::executeVia's localOp actually throws std::logic_error at *runtime* +# under MORPH_CLIENT_ONLY, instead of silently calling Model::execute -- needs +# the probe to run: compile_checks/client_only_runtime_throw.cpp exits 0 when +# the expected std::logic_error was caught. +add_executable(morph_client_only_runtime_throw compile_checks/client_only_runtime_throw.cpp) +target_compile_definitions(morph_client_only_runtime_throw PRIVATE MORPH_CLIENT_ONLY) +target_link_libraries(morph_client_only_runtime_throw PRIVATE morph_guard_probe_deps) +morph_suppress_test_dialogs(morph_client_only_runtime_throw) +add_test(NAME morph_client_only_runtime_throw COMMAND morph_client_only_runtime_throw) +set_tests_properties(morph_client_only_runtime_throw PROPERTIES TIMEOUT 60) # BRIDGE_REGISTER_ACTION_FOR_CLIENT lets a MORPH_CLIENT_ONLY client # register an action's ActionTraits (JSON codecs + an explicitly-named Result @@ -568,37 +517,65 @@ endif() # docs/spec/core/registry.md, "BRIDGE_REGISTER_ACTION_FOR_CLIENT". # compile_checks/client_only_facade_no_model_header.cpp forward-declares (never # defines) ClientOnlyFacadeModel and proves the resulting ActionTraits -# specialisation still round-trips JSON correctly. try_run(), not try_compile(): -# this probe's main() actually exercises toJson/fromJson, not just links. -unset(MORPH_CLIENT_ONLY_FACADE_COMPILED CACHE) -unset(MORPH_CLIENT_ONLY_FACADE_EXITCODE CACHE) -try_run(MORPH_CLIENT_ONLY_FACADE_EXITCODE MORPH_CLIENT_ONLY_FACADE_COMPILED - "${CMAKE_CURRENT_BINARY_DIR}/client_only_facade_check" - "${CMAKE_CURRENT_SOURCE_DIR}/compile_checks/client_only_facade_no_model_header.cpp" - CMAKE_FLAGS "-DINCLUDE_DIRECTORIES=${MORPH_VETTED_HMAC_GUARD_INCLUDE_DIRS}" - CXX_STANDARD 23 - COMPILE_DEFINITIONS "-DMORPH_CLIENT_ONLY" - LINK_LIBRARIES "${CMAKE_THREAD_LIBS_INIT}" - COMPILE_OUTPUT_VARIABLE MORPH_CLIENT_ONLY_FACADE_COMPILE_OUTPUT - RUN_OUTPUT_VARIABLE MORPH_CLIENT_ONLY_FACADE_RUN_OUTPUT -) -if(NOT MORPH_CLIENT_ONLY_FACADE_COMPILED) - message(FATAL_ERROR - "BRIDGE_REGISTER_ACTION_FOR_CLIENT guard check failed: " - "compile_checks/client_only_facade_no_model_header.cpp failed to " - "compile/link with MORPH_CLIENT_ONLY defined, even though " - "ClientOnlyFacadeModel is never a complete type anywhere in this " - "program (BRIDGE_REGISTER_ACTION_FOR_CLIENT must not require model " - "completeness).\n" - "--- compiler/linker output ---\n" - "${MORPH_CLIENT_ONLY_FACADE_COMPILE_OUTPUT}") +# specialisation still round-trips JSON correctly: it builds, links and exits 0. +add_executable(morph_client_only_facade compile_checks/client_only_facade_no_model_header.cpp) +target_compile_definitions(morph_client_only_facade PRIVATE MORPH_CLIENT_ONLY) +target_link_libraries(morph_client_only_facade PRIVATE morph_guard_probe_deps) +morph_suppress_test_dialogs(morph_client_only_facade) +add_test(NAME morph_client_only_facade COMMAND morph_client_only_facade) +set_tests_properties(morph_client_only_facade PROPERTIES TIMEOUT 60) + +# Instrumented like every other executable here: on a sanitizer leg core-cpp's +# modules are instrumented too (root CMakeLists.txt), and an uninstrumented +# program linking them fails with the sanitizer runtime undefined. +if(DEFINED AF_SANITIZER) + foreach(_morph_guard_probe IN ITEMS morph_client_only_guard_links morph_client_only_runtime_throw + morph_client_only_facade) + apply_sanitizers(${_morph_guard_probe} ${AF_SANITIZER}) + endforeach() + unset(_morph_guard_probe) endif() -if(NOT MORPH_CLIENT_ONLY_FACADE_EXITCODE EQUAL 0) - message(FATAL_ERROR - "BRIDGE_REGISTER_ACTION_FOR_CLIENT guard check failed: " - "compile_checks/client_only_facade_no_model_header.cpp did not " - "round-trip its action through toJson/fromJson correctly " - "(exit code ${MORPH_CLIENT_ONLY_FACADE_EXITCODE}).\n" - "--- program output ---\n" - "${MORPH_CLIENT_ONLY_FACADE_RUN_OUTPUT}") + +# ── Windows dialog canary ──────────────────────────────────────────────────── +# Proves that a morph test executable dies on a failed assert() or an abort() +# instead of waiting on a dialog nobody will click, and that an invalid CRT +# parameter is handled rather than raising one. Its main() asks for nothing: +# linking core::testing_dialogs, as every test executable here does, is what +# installs the suppression. Modelled on core-cpp's WindowsDialogCanary, and +# judged the same way: by the marker each mode prints before it fails +# (PASS_REGULAR_EXPRESSION, which also makes ctest ignore the exit status), not +# by WILL_FAIL, which would score a run that never reached its assertion as a +# pass. The TIMEOUT is what catches a dialog: a run still waiting when it +# expires is waiting for a click. +if(WIN32 AND TARGET core-cpp-testing_dialogs) + add_executable(morph_windows_dialog_canary windows_dialog_canary.cpp) + morph_suppress_test_dialogs(morph_windows_dialog_canary) + foreach(_mode IN ITEMS assert abort invalid-parameter) + add_test(NAME morph_windows_dialog_canary.${_mode} COMMAND morph_windows_dialog_canary ${_mode}) + # `assert` and `abort` must kill the process; `invalid-parameter` must + # survive, because the suppression installs a no-op handler, so the one + # marker is the failure for two modes and the pass for the third. + set(_pass "failing by ${_mode}") + set(_fail "CONTINUED AFTER FAILURE") + if(_mode STREQUAL "invalid-parameter") + set(_pass "CONTINUED AFTER FAILURE") + set(_fail "") + endif() + # A Debug CRT writes abort()'s message to stderr, and the suppression + # keeps it there rather than dropping it: the abort run must show it. A + # Release UCRT writes none, and a multi-config generator does not say + # which it builds, so those keep the "failing by" marker. + if(_mode STREQUAL "abort" AND CMAKE_BUILD_TYPE STREQUAL "Debug") + set(_pass "abort\\(\\) has been called") + endif() + set_tests_properties(morph_windows_dialog_canary.${_mode} PROPERTIES + PASS_REGULAR_EXPRESSION "${_pass}" + FAIL_REGULAR_EXPRESSION "${_fail}" + TIMEOUT 60 + SKIP_RETURN_CODE 77 + LABELS "canary") + endforeach() + unset(_mode) + unset(_pass) + unset(_fail) endif() diff --git a/tests/bench/CMakeLists.txt b/tests/bench/CMakeLists.txt index ea0b0b6ca..9df928c6f 100644 --- a/tests/bench/CMakeLists.txt +++ b/tests/bench/CMakeLists.txt @@ -29,6 +29,7 @@ catch_discover_tests(morph_bench DISCOVERY_MODE PRE_TEST PROPERTIES TIMEOUT 60 L # docs/spec/testing_strategy.md. add_executable(morph_bench_alloc bench_dispatch_allocations.cpp) target_link_libraries(morph_bench_alloc PRIVATE morph::morph) +morph_suppress_test_dialogs(morph_bench_alloc) apply_warnings(morph_bench_alloc) # -Wmismatched-new-delete pairs a `new` the compiler inlined with the # `std::free` inside the *replaced* `operator delete` and calls it a mismatch. diff --git a/tests/compile_checks/completion_await_rvalue_only.hpp b/tests/compile_checks/completion_await_rvalue_only.hpp new file mode 100644 index 000000000..776c6a83c --- /dev/null +++ b/tests/compile_checks/completion_await_rvalue_only.hpp @@ -0,0 +1,26 @@ +// SPDX-License-Identifier: Apache-2.0 +// +// Compile-time check: a morph::async::Completion can be awaited only as an +// rvalue. Awaiting consumes the completion (its state moves into the awaiter), +// so `co_await completion` on an lvalue must not compile, and +// `co_await std::move(completion)` must. See docs/spec/core/coroutines.md. +// Included by tests/test_coroutine_client.cpp, which is what compiles it. + +#pragma once +#include +#include + +namespace morph::compile_checks { + +/// Whether `co_await` accepts a @p C lvalue. +template +concept AwaitableAsLvalue = requires(C& completion) { completion.operator co_await(); }; + +/// Whether `co_await` accepts a @p C rvalue. +template +concept AwaitableAsRvalue = requires(C& completion) { std::move(completion).operator co_await(); }; + +static_assert(AwaitableAsRvalue<::morph::async::Completion>); +static_assert(!AwaitableAsLvalue<::morph::async::Completion>); + +} // namespace morph::compile_checks diff --git a/tests/compile_checks/qt_no_ssl_main.cpp b/tests/compile_checks/qt_no_ssl_main.cpp index 2398f72bc..cec657162 100644 --- a/tests/compile_checks/qt_no_ssl_main.cpp +++ b/tests/compile_checks/qt_no_ssl_main.cpp @@ -1,7 +1,7 @@ // SPDX-License-Identifier: Apache-2.0 // -// Trivial main() so the QT_NO_SSL try_compile() guard in tests/qt/CMakeLists.txt -// links a full executable (try_compile builds one by default) around -// src/qt/qt_websocket_backend.cpp, which has no main() of its own. +// A translation unit of its own in the QT_NO_SSL try_compile() guard in +// tests/qt/CMakeLists.txt, which compiles src/qt/qt_websocket_backend.cpp and +// qt_websocket_server.cpp into a static library under QT_NO_SSL. int main() { return 0; } diff --git a/tests/net/test_base64.cpp b/tests/net/test_base64.cpp index 06721e16f..7405f0053 100644 --- a/tests/net/test_base64.cpp +++ b/tests/net/test_base64.cpp @@ -1,21 +1,22 @@ // SPDX-License-Identifier: Apache-2.0 +// +// morph::net's WebSocket handshake takes its base64 from core-cpp +// (ws_handshake.hpp: Sec-WebSocket-Accept and Sec-WebSocket-Key), so this pins +// the encoding it relies on: standard RFC 4648 alphabet, `=` padding. +#include #include +#include #include -#include -#include #include -#include +#include namespace { -std::string encodeAscii(std::string_view text) { - std::vector bytes(text.begin(), text.end()); - return morph::net::detail::base64Encode(std::span(bytes.data(), bytes.size())); -} +std::string encodeAscii(std::string_view text) { return ::core::base64::encode(text); } } // namespace // RFC 4648 §10 test vectors. -TEST_CASE("base64Encode matches RFC 4648 test vectors", "[net][base64]") { +TEST_CASE("core::base64::encode matches RFC 4648 test vectors", "[net][base64]") { REQUIRE(encodeAscii("") == ""); REQUIRE(encodeAscii("f") == "Zg=="); REQUIRE(encodeAscii("fo") == "Zm8="); @@ -24,3 +25,11 @@ TEST_CASE("base64Encode matches RFC 4648 test vectors", "[net][base64]") { REQUIRE(encodeAscii("fooba") == "Zm9vYmE="); REQUIRE(encodeAscii("foobar") == "Zm9vYmFy"); } + +// The handshake encodes raw bytes (a SHA-1 digest, a random key), not text: +// bytes above 0x7F and the alphabet's last two symbols, `+` and `/`, which the +// URL-safe alphabet would spell `-` and `_`. +TEST_CASE("core::base64::encode encodes high bytes with the standard alphabet", "[net][base64]") { + std::array const bytes{0xFB, 0xFF}; + REQUIRE(::core::base64::encode(bytes.begin(), bytes.end()) == "+/8="); +} diff --git a/tests/qt/CMakeLists.txt b/tests/qt/CMakeLists.txt index d89d8497c..b60794ffc 100644 --- a/tests/qt/CMakeLists.txt +++ b/tests/qt/CMakeLists.txt @@ -4,10 +4,12 @@ add_executable(qt_test_server qt_test_server_main.cpp qt_test_models.hpp) target_link_libraries(qt_test_server PRIVATE morph_qt_impl) +morph_suppress_test_dialogs(qt_test_server) apply_warnings(qt_test_server) add_executable(qt_test_client qt_test_client_main.cpp qt_test_models.hpp) target_link_libraries(qt_test_client PRIVATE morph_qt_impl) +morph_suppress_test_dialogs(qt_test_client) apply_warnings(qt_test_client) # AF_SANITIZER, for the same reason these two carry apply_coverage(): the @@ -104,7 +106,7 @@ catch_discover_tests(morph_qt_tests # "QSslConfiguration is an incomplete type" failure an actual SSL-less Qt hits # -- confirmed by compiling the pre-fix source this way, which fails with # exactly that error. Both source files are compiled into the same -# try_compile executable because they both live in the morph_qt_impl target +# try_compile target because they both live in the morph_qt_impl target # (see CMakeLists.txt) and must independently satisfy the guard for that # target to build under QT_NO_SSL. # See qt_websocket_backend.hpp's / qt_websocket_server.hpp's class doc @@ -120,6 +122,8 @@ catch_discover_tests(morph_qt_tests get_target_property(MORPH_QT_NO_SSL_GUARD_MORPH_INCLUDE_DIRS morph INTERFACE_INCLUDE_DIRECTORIES) get_target_property(MORPH_QT_NO_SSL_GUARD_GLAZE_INCLUDE_DIRS glaze::glaze INTERFACE_INCLUDE_DIRECTORIES) list(APPEND MORPH_QT_NO_SSL_GUARD_MORPH_INCLUDE_DIRS ${MORPH_QT_NO_SSL_GUARD_GLAZE_INCLUDE_DIRS}) +# core-cpp's headers, for the reason tests/CMakeLists.txt's guards spell them out. +list(APPEND MORPH_QT_NO_SSL_GUARD_MORPH_INCLUDE_DIRS "${core-cpp_SOURCE_DIR}/src" "${core-cpp_BINARY_DIR}/include") string(REPLACE ";" "\\;" MORPH_QT_NO_SSL_GUARD_MORPH_INCLUDE_DIRS "${MORPH_QT_NO_SSL_GUARD_MORPH_INCLUDE_DIRS}") # QtWebSocketServer is a Q_OBJECT type, so the try_compile executable below @@ -140,7 +144,13 @@ if(NOT MORPH_QT_NO_SSL_GUARD_MOC_RESULT EQUAL 0) "${MORPH_QT_NO_SSL_GUARD_MOC_ERROR}") endif() +# Compiled into a static library, not linked into an executable: the guard is +# about compiling under QT_NO_SSL, and the code now references core-cpp's +# compiled modules (through TimeoutScheduler), which a try_compile() scratch +# project cannot link -- they are built by this project, after configure. unset(MORPH_QT_NO_SSL_GUARD_COMPILES CACHE) +set(_morph_saved_try_compile_target_type "${CMAKE_TRY_COMPILE_TARGET_TYPE}") +set(CMAKE_TRY_COMPILE_TARGET_TYPE STATIC_LIBRARY) try_compile(MORPH_QT_NO_SSL_GUARD_COMPILES SOURCES "${CMAKE_SOURCE_DIR}/src/qt/qt_websocket_backend.cpp" @@ -152,6 +162,8 @@ try_compile(MORPH_QT_NO_SSL_GUARD_COMPILES CXX_STANDARD 23 COMPILE_DEFINITIONS "-DQT_NO_SSL" ) +set(CMAKE_TRY_COMPILE_TARGET_TYPE "${_morph_saved_try_compile_target_type}") +unset(_morph_saved_try_compile_target_type) if(NOT MORPH_QT_NO_SSL_GUARD_COMPILES) message(FATAL_ERROR "QT_NO_SSL guard check failed: src/qt/qt_websocket_backend.cpp and/or " diff --git a/tests/test_async_delay.cpp b/tests/test_async_delay.cpp new file mode 100644 index 000000000..238893f63 --- /dev/null +++ b/tests/test_async_delay.cpp @@ -0,0 +1,150 @@ +// SPDX-License-Identifier: Apache-2.0 +// +// morph::async::delay (docs/spec/core/coroutines.md, "delay"): a stop-aware +// wait on a TimeoutScheduler entry that resumes in the awaiting context. + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "test_support.hpp" + +namespace { + +using namespace std::chrono_literals; + +template +[[nodiscard]] bool pumpUntil(morph::exec::MainThreadExecutor& exec, Pred pred) { + auto const deadline = std::chrono::steady_clock::now() + morph::testing::kDefaultWaitBudget; + while (!pred()) { + if (std::chrono::steady_clock::now() >= deadline) { + return false; + } + exec.runFor(morph::testing::kDefaultWaitStep); + } + return true; +} + +struct DelayObserved { + std::atomic finished{false}; + bool cancelled = false; + std::chrono::steady_clock::duration waited{}; + std::thread::id resumedOn; + /// Set immediately before the `co_await`, in the same step that suspends. + std::atomic reachedAwait{false}; +}; + +core::async::Task waitFor(morph::async::detail::TimeoutScheduler* scheduler, std::chrono::milliseconds duration, + std::shared_ptr seen) { + auto const started = std::chrono::steady_clock::now(); + try { + seen->reachedAwait = true; + co_await morph::async::delay(*scheduler, duration); + } catch (const core::async::OperationCancelled&) { + seen->cancelled = true; + } + seen->waited = std::chrono::steady_clock::now() - started; + seen->resumedOn = std::this_thread::get_id(); + seen->finished = true; +} + +} // namespace + +TEST_CASE("delay resumes on the awaiting context's executor once the time has elapsed", "[coroutine][delay]") { + morph::async::detail::TimeoutScheduler scheduler; + morph::exec::MainThreadExecutor exec; + auto seen = std::make_shared(); + + morph::async::spawn(exec, waitFor(&scheduler, 30ms, seen)); + + REQUIRE(pumpUntil(exec, [&] { return seen->finished.load(); })); + REQUIRE_FALSE(seen->cancelled); + REQUIRE(seen->waited >= 30ms); + // Resumed through spawn's executor, not on the scheduler's loop thread. + REQUIRE(seen->resumedOn == std::this_thread::get_id()); +} + +TEST_CASE("a stop request cancels a delay and resumes it with OperationCancelled", "[coroutine][delay]") { + morph::async::detail::TimeoutScheduler scheduler; + morph::exec::MainThreadExecutor exec; + auto seen = std::make_shared(); + + auto task = waitFor(&scheduler, 60s, seen); + // Not const: request_stop() is a non-const member in the standard, and in + // MSVC's library; libstdc++ declaring it const is what makes this look + // const-able. + // NOLINTNEXTLINE(misc-const-correctness) + core::async::StopSource stop; + task.handle().promise().setStopToken(stop.get_token()); + morph::async::spawn(exec, std::move(task)); + REQUIRE(pumpUntil(exec, [&] { return seen->reachedAwait.load(); })); + REQUIRE_FALSE(seen->finished.load()); + + stop.request_stop(); + + REQUIRE(pumpUntil(exec, [&] { return seen->finished.load(); })); + REQUIRE(seen->cancelled); + REQUIRE(seen->waited < 10s); + REQUIRE(seen->resumedOn == std::this_thread::get_id()); +} + +TEST_CASE("a delay awaited under a stop already requested does not suspend", "[coroutine][delay]") { + morph::async::detail::TimeoutScheduler scheduler; + morph::exec::MainThreadExecutor exec; + auto seen = std::make_shared(); + + auto task = waitFor(&scheduler, 60s, seen); + // Not const: request_stop() is a non-const member in the standard. + // NOLINTNEXTLINE(misc-const-correctness) + core::async::StopSource stop; + stop.request_stop(); + task.handle().promise().setStopToken(stop.get_token()); + morph::async::spawn(exec, std::move(task)); + + REQUIRE(pumpUntil(exec, [&] { return seen->finished.load(); })); + REQUIRE(seen->cancelled); + REQUIRE(seen->waited < 10s); +} + +TEST_CASE("a delay outside any resumption context resumes on the scheduler's thread", "[coroutine][delay]") { + morph::async::detail::TimeoutScheduler scheduler; + auto seen = std::make_shared(); + + // Started by hand rather than spawned, so no resumption context is current. + auto task = waitFor(&scheduler, 10ms, seen); + task.handle().resume(); + REQUIRE(morph::testing::waitUntil([&] { return seen->finished.load(); })); + // The loop runs one callback at a time: once this one has run, the one + // that resumed the coroutine has returned, and its frame may be destroyed. + std::atomic drained{false}; + static_cast(scheduler.schedule(0ms, [&] { drained = true; })); + REQUIRE(morph::testing::waitUntil([&] { return drained.load(); })); + + REQUIRE_FALSE(seen->cancelled); + REQUIRE(seen->resumedOn != std::this_thread::get_id()); +} + +TEST_CASE("a delay whose coroutine is destroyed while it waits withdraws its timer", "[coroutine][delay]") { + morph::async::detail::TimeoutScheduler scheduler; + auto seen = std::make_shared(); + { + auto task = waitFor(&scheduler, 20ms, seen); + task.handle().resume(); + REQUIRE(seen->reachedAwait.load()); + } // the frame, and the awaiter in it, die here while the timer is armed + + // Wait past the withdrawn deadline: had the timer survived, it would have + // resumed the destroyed frame. + std::atomic past{false}; + static_cast(scheduler.schedule(60ms, [&] { past = true; })); + REQUIRE(morph::testing::waitUntil([&] { return past.load(); })); + REQUIRE_FALSE(seen->finished.load()); +} diff --git a/tests/test_backend_extra.cpp b/tests/test_backend_extra.cpp index 9f6aced1f..a254cc116 100644 --- a/tests/test_backend_extra.cpp +++ b/tests/test_backend_extra.cpp @@ -352,8 +352,8 @@ TEST_CASE("morph::backend::LocalBackend: amortised pending compaction bounds the // Declared *before* the pool and the backend, so they outlive them. Only the // one parked task that is actually running has left the strand queue when - // this scope ends; the other 47 are still queued, and `~StrandExecutor` / - // `~ThreadPoolExecutor` run them during teardown — after any state declared + // this scope ends; the other 47 are still queued, and `~LocalBackend` / + // `~ThreadPoolExecutor` drain them during teardown — after any state declared // below the pool has already been destroyed. Getting this backwards is a // stack-use-after-scope on `gate`, which is exactly what ASan reported the // first time round, not a theoretical one. @@ -420,11 +420,13 @@ TEST_CASE("morph::backend::LocalBackend: amortised pending compaction bounds the backend.cancelPending(std::make_exception_ptr(morph::backend::BackendChangedError{})); auto const cancelledCount = cancelled.load(std::memory_order_relaxed); - // Release the parked ops and drain them before any assertion can abandon the - // fixture: `~StrandExecutor` blocks until the running task returns, and a - // `CHECK` that fires mid-teardown should not leave that to chance. + // Release the parked op that is running before any assertion can abandon + // the fixture: `~LocalBackend` blocks until its strand is idle, and a + // `CHECK` that fires mid-teardown should not leave that to chance. Only + // that one runs: the ones queued behind it were failed by `cancelPending` + // while they waited, so they are skipped rather than run. gate.store(true, std::memory_order_release); - REQUIRE(morph::testing::waitUntil([&] { return parkedRan.load(std::memory_order_relaxed) == kRounds; }, + REQUIRE(morph::testing::waitUntil([&] { return parkedRan.load(std::memory_order_relaxed) == 1; }, morph::testing::WaitBudget{std::chrono::milliseconds{10000}})); live.clear(); diff --git a/tests/test_completion_multi_handler.cpp b/tests/test_completion_multi_handler.cpp index 7c4365161..aa78a5cdb 100644 --- a/tests/test_completion_multi_handler.cpp +++ b/tests/test_completion_multi_handler.cpp @@ -215,6 +215,42 @@ TEST_CASE("Completion: a throwing last then handler is isolated the same as a no REQUIRE(firstFired); } +// A handler attached after its completion settled is fired by its own posted +// closure, not by the settle-time fan-out above, and that closure had no +// try/catch: its throw escaped into the executor. Over an inline executor that +// is the attaching call itself; over a pumped one, the pump -- which is how +// `Presenter::track()`'s destroy-then-throw tests failed whenever the backend +// settled before `track()` attached. Isolated now like every other handler. +TEST_CASE("Completion: a throwing onError handler attached after the error is ready is isolated", + "[completion][issue-59]") { + LogGuard const guard; + SyncExecutor exec; + auto state = std::make_shared>(); + morph::async::Completion comp{state, &exec}; + + state->setException(std::make_exception_ptr(std::runtime_error{"err"})); + + bool laterFired = false; + REQUIRE_NOTHROW(comp.onError([](const std::exception_ptr&) { throw std::runtime_error{"handler blew up"}; })); + comp.onError([&](const std::exception_ptr&) { laterFired = true; }); + REQUIRE(laterFired); +} + +TEST_CASE("Completion: a throwing then handler attached after the value is ready is isolated", + "[completion][issue-59]") { + LogGuard const guard; + SyncExecutor exec; + auto state = std::make_shared>(); + morph::async::Completion comp{state, &exec}; + + state->setValue(1); + + bool laterFired = false; + REQUIRE_NOTHROW(comp.then([](int) { throw std::runtime_error{"handler blew up"}; })); + comp.then([&](int) { laterFired = true; }); + REQUIRE(laterFired); +} + TEST_CASE("Completion: mismatched attach (onError on a value-ready state) is still a no-op for all handlers", "[completion][issue-59]") { SyncExecutor exec; diff --git a/tests/test_concurrency_invariants.cpp b/tests/test_concurrency_invariants.cpp index 92b7950ae..e1bdf5e9c 100644 --- a/tests/test_concurrency_invariants.cpp +++ b/tests/test_concurrency_invariants.cpp @@ -45,10 +45,10 @@ using LogGuard = morph::log::ScopedLoggerOverride; // ── Strand: per-producer FIFO under multi-thread contention ─────────────────── // NOLINTNEXTLINE(readability-function-cognitive-complexity) -TEST_CASE("morph::exec::detail::StrandExecutor: per-producer FIFO preserved across many concurrent producers", +TEST_CASE("morph::exec::detail::ModelStrands: per-producer FIFO preserved across many concurrent producers", "[strand][concurrency][quantum-parity]") { morph::exec::ThreadPoolExecutor pool{4}; - morph::exec::detail::StrandExecutor strand{pool}; + auto const strand = std::make_shared(pool); morph::exec::detail::ModelId key{42}; constexpr int numProducers = 8; @@ -64,7 +64,7 @@ TEST_CASE("morph::exec::detail::StrandExecutor: per-producer FIFO preserved acro for (int producerIdx = 0; producerIdx < numProducers; ++producerIdx) { producers.emplace_back([&, producerIdx] { for (int seq = 0; seq < perProducer; ++seq) { - strand.post(key, [&, producerIdx, seq] { + strand->post(key, [&, producerIdx, seq] { { std::scoped_lock lock{obsMtx}; observed.emplace_back(producerIdx, seq); @@ -92,11 +92,11 @@ TEST_CASE("morph::exec::detail::StrandExecutor: per-producer FIFO preserved acro // ── Strand: cross-key concurrency saturates pool ────────────────────────────── -TEST_CASE("morph::exec::detail::StrandExecutor: cross-key concurrency saturates pool size and never exceeds it", +TEST_CASE("morph::exec::detail::ModelStrands: cross-key concurrency saturates pool size and never exceeds it", "[strand][concurrency][quantum-parity]") { constexpr std::size_t poolSize = 4; morph::exec::ThreadPoolExecutor pool{poolSize}; - morph::exec::detail::StrandExecutor strand{pool}; + auto const strand = std::make_shared(pool); constexpr int numKeys = 16; std::atomic active{0}; @@ -104,7 +104,7 @@ TEST_CASE("morph::exec::detail::StrandExecutor: cross-key concurrency saturates std::atomic done{0}; for (int key = 1; key <= numKeys; ++key) { - strand.post(morph::exec::detail::ModelId{static_cast(key)}, [&] { + strand->post(morph::exec::detail::ModelId{static_cast(key)}, [&] { int now = active.fetch_add(1) + 1; int prev = peak.load(); while (now > prev && !peak.compare_exchange_weak(prev, now)) { @@ -122,23 +122,23 @@ TEST_CASE("morph::exec::detail::StrandExecutor: cross-key concurrency saturates // ── Strand: thousands of distinct keys exercise cleanup path ────────────────── -TEST_CASE("morph::exec::detail::StrandExecutor: churn across thousands of distinct keys completes without deadlock", +TEST_CASE("morph::exec::detail::ModelStrands: churn across thousands of distinct keys completes without deadlock", "[strand][churn][quantum-parity]") { morph::exec::ThreadPoolExecutor pool{4}; - morph::exec::detail::StrandExecutor strand{pool}; + auto const strand = std::make_shared(pool); constexpr int numKeys = 3000; std::atomic done{0}; for (int key = 1; key <= numKeys; ++key) { - strand.post(morph::exec::detail::ModelId{static_cast(key)}, [&] { done.fetch_add(1); }); + strand->post(morph::exec::detail::ModelId{static_cast(key)}, [&] { done.fetch_add(1); }); } REQUIRE(waitUntil([&] { return done.load() == numKeys; }, morph::testing::WaitBudget{10s})); // Post once more after the churn — confirms the cleanup path left the // strand executor in a usable state (regression target for map corruption). std::atomic after{false}; - strand.post(morph::exec::detail::ModelId{static_cast(numKeys + 1)}, [&] { after.store(true); }); + strand->post(morph::exec::detail::ModelId{static_cast(numKeys + 1)}, [&] { after.store(true); }); REQUIRE(waitUntil([&] { return after.load(); })); } @@ -275,6 +275,25 @@ TEST_CASE( std::this_thread::sleep_for(1ms); } }); + // Stops and joins the switcher on every way out of this scope. Without it a + // failed REQUIRE below unwinds past a joinable std::thread, whose destructor + // calls std::terminate: the failure became an abort() (on Windows, a dialog) + // instead of a reported assertion. + struct StopAndJoin { + std::atomic* stop; + std::thread* thread; + StopAndJoin(std::atomic* stop_, std::thread* thread_) noexcept : stop{stop_}, thread{thread_} {} + StopAndJoin(const StopAndJoin&) = delete; + StopAndJoin& operator=(const StopAndJoin&) = delete; + StopAndJoin(StopAndJoin&&) = delete; + StopAndJoin& operator=(StopAndJoin&&) = delete; + ~StopAndJoin() { + stop->store(true); + if (thread->joinable()) { + thread->join(); + } + } + } const stopSwitcher{&stopSwitch, &switcher}; std::vector producers; producers.reserve(numProducers); diff --git a/tests/test_coroutine_client.cpp b/tests/test_coroutine_client.cpp new file mode 100644 index 000000000..5b5fd97a8 --- /dev/null +++ b/tests/test_coroutine_client.cpp @@ -0,0 +1,285 @@ +// SPDX-License-Identifier: Apache-2.0 +// +// The client side of docs/spec/core/coroutines.md: a coroutine awaiting a +// morph::async::Completion, started with morph::async::spawn. + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "compile_checks/completion_await_rvalue_only.hpp" +#include "test_support.hpp" + +namespace { + +using morph::async::Completion; + +/// Pumps @p exec in bounded steps until @p pred holds or the budget is spent. +template +[[nodiscard]] bool pumpUntil(morph::exec::MainThreadExecutor& exec, Pred pred) { + auto const deadline = std::chrono::steady_clock::now() + morph::testing::kDefaultWaitBudget; + while (!pred()) { + if (std::chrono::steady_clock::now() >= deadline) { + return false; + } + exec.runFor(morph::testing::kDefaultWaitStep); + } + return true; +} + +struct Observed { + std::optional value; + std::string error; + bool cancelled = false; + std::thread::id resumedOn; + /// Set immediately before the `co_await`, in the same step that suspends. + std::atomic reachedAwait{false}; + std::atomic finished{false}; +}; + +core::async::Task awaitInto(Completion completion, std::shared_ptr seen) { + try { + seen->reachedAwait = true; + int const value = co_await std::move(completion); + seen->value = value; + } catch (const core::async::OperationCancelled&) { + seen->cancelled = true; + } catch (const std::exception& exc) { + seen->error = exc.what(); + } + seen->resumedOn = std::this_thread::get_id(); + seen->finished = true; +} + +} // namespace + +TEST_CASE("co_await on a Completion yields the settled value", "[coroutine][client]") { + morph::exec::MainThreadExecutor exec; + auto [completion, promise] = Completion::makeSettleable(&exec); + auto seen = std::make_shared(); + + morph::async::spawn(exec, awaitInto(std::move(completion), seen)); + promise.resolve(42); + + REQUIRE(pumpUntil(exec, [&] { return seen->finished.load(); })); + REQUIRE(seen->value == 42); + REQUIRE(seen->error.empty()); +} + +TEST_CASE("co_await on a rejected Completion rethrows its exception", "[coroutine][client]") { + morph::exec::MainThreadExecutor exec; + auto [completion, promise] = Completion::makeSettleable(&exec); + auto seen = std::make_shared(); + + morph::async::spawn(exec, awaitInto(std::move(completion), seen)); + promise.reject(std::make_exception_ptr(std::runtime_error{"rejected on purpose"})); + + REQUIRE(pumpUntil(exec, [&] { return seen->finished.load(); })); + REQUIRE_FALSE(seen->value.has_value()); + REQUIRE(seen->error == "rejected on purpose"); +} + +TEST_CASE("co_await resumes on the executor, not on the thread that settled the Completion", "[coroutine][client]") { + morph::exec::MainThreadExecutor exec; + auto [completion, promise] = Completion::makeSettleable(&exec); + auto seen = std::make_shared(); + + morph::async::spawn(exec, awaitInto(std::move(completion), seen)); + // Nothing runs until the executor is pumped: spawn posts the first step too. + REQUIRE_FALSE(seen->finished.load()); + + std::thread settler{[&promise] { promise.resolve(7); }}; + settler.join(); + + REQUIRE(pumpUntil(exec, [&] { return seen->finished.load(); })); + REQUIRE(seen->value == 7); + REQUIRE(seen->resumedOn == std::this_thread::get_id()); +} + +TEST_CASE("a stop request withdraws the await and releases the coroutine's captures", "[coroutine][client]") { + morph::exec::MainThreadExecutor exec; + auto [completion, promise] = Completion::makeSettleable(&exec); + auto seen = std::make_shared(); + auto capture = std::make_shared(0); + std::weak_ptr const captureObserver = capture; + + auto task = [](Completion pending, std::shared_ptr observed, + std::shared_ptr held) -> core::async::Task { + static_cast(held); + co_await awaitInto(std::move(pending), std::move(observed)); + }(std::move(completion), seen, std::move(capture)); + core::async::StopSource stop; + task.handle().promise().setStopToken(stop.get_token()); + + morph::async::spawn(exec, std::move(task)); + // The step that sets the flag is the one that suspends, and runs to its + // end before `runFor` returns: once seen, the coroutine is suspended on a + // completion that nothing settles. + REQUIRE(pumpUntil(exec, [&] { return seen->reachedAwait.load(); })); + REQUIRE_FALSE(seen->finished.load()); + REQUIRE_FALSE(captureObserver.expired()); + + std::thread stopper{[&stop] { stop.request_stop(); }}; + stopper.join(); + + REQUIRE(pumpUntil(exec, [&] { return seen->finished.load(); })); + REQUIRE(seen->cancelled); + REQUIRE(seen->resumedOn == std::this_thread::get_id()); + // The frame has finished and been freed, and the completion's handlers + // hold none of it. + REQUIRE(pumpUntil(exec, [&] { return captureObserver.expired(); })); + + // A settlement after the stop reaches nothing. + promise.resolve(1); + exec.runFor(std::chrono::milliseconds{20}); + REQUIRE_FALSE(seen->value.has_value()); +} + +TEST_CASE("co_await on an empty Completion throws std::logic_error without suspending", "[coroutine][client]") { + morph::exec::MainThreadExecutor exec; + auto seen = std::make_shared(); + + morph::async::spawn(exec, awaitInto(Completion{}, seen)); + + REQUIRE(pumpUntil(exec, [&] { return seen->finished.load(); })); + REQUIRE_FALSE(seen->error.empty()); +} + +TEST_CASE("co_await on a Completion with no callback executor throws std::logic_error without suspending", + "[coroutine][client]") { + morph::exec::MainThreadExecutor exec; + auto seen = std::make_shared(); + auto state = std::make_shared>(); + + morph::async::spawn(exec, awaitInto(Completion{state, nullptr}, seen)); + + REQUIRE(pumpUntil(exec, [&] { return seen->finished.load(); })); + REQUIRE(seen->error.contains("no callback executor")); +} + +TEST_CASE("co_await under a stop already requested does not suspend", "[coroutine][client]") { + morph::exec::MainThreadExecutor exec; + auto [completion, promise] = Completion::makeSettleable(&exec); + auto seen = std::make_shared(); + + auto task = awaitInto(std::move(completion), seen); + // Not const: request_stop() is a non-const member in the standard. + // NOLINTNEXTLINE(misc-const-correctness) + core::async::StopSource stop; + stop.request_stop(); + task.handle().promise().setStopToken(stop.get_token()); + morph::async::spawn(exec, std::move(task)); + + REQUIRE(pumpUntil(exec, [&] { return seen->finished.load(); })); + REQUIRE(seen->cancelled); + promise.resolve(1); + exec.runFor(std::chrono::milliseconds{20}); + REQUIRE_FALSE(seen->value.has_value()); +} + +TEST_CASE("a rejection after a stop withdrew the await reaches nothing", "[coroutine][client]") { + morph::exec::MainThreadExecutor exec; + auto [completion, promise] = Completion::makeSettleable(&exec); + auto seen = std::make_shared(); + + auto task = awaitInto(std::move(completion), seen); + // NOLINTNEXTLINE(misc-const-correctness): request_stop() is non-const + core::async::StopSource stop; + task.handle().promise().setStopToken(stop.get_token()); + morph::async::spawn(exec, std::move(task)); + REQUIRE(pumpUntil(exec, [&] { return seen->reachedAwait.load(); })); + + stop.request_stop(); + REQUIRE(pumpUntil(exec, [&] { return seen->finished.load(); })); + REQUIRE(seen->cancelled); + + promise.reject(std::make_exception_ptr(std::runtime_error{"settled after the stop"})); + exec.runFor(std::chrono::milliseconds{20}); + REQUIRE(seen->error.empty()); +} + +TEST_CASE("co_await outside any resumption context resumes on the completion's executor", "[coroutine][client]") { + morph::exec::ThreadPoolExecutor callbacks{1}; + auto [completion, promise] = Completion::makeSettleable(&callbacks); + auto seen = std::make_shared(); + + // Started by hand rather than spawned, so no resumption context is current. + auto task = awaitInto(std::move(completion), seen); + task.handle().resume(); + REQUIRE(seen->reachedAwait.load()); + promise.resolve(7); + + REQUIRE(morph::testing::waitUntil([&] { return seen->finished.load(); })); + // One thread: once this task has run, the resumption before it has + // returned, and the frame may be destroyed. + std::atomic drained{false}; + callbacks.post([&] { drained = true; }); + REQUIRE(morph::testing::waitUntil([&] { return drained.load(); })); + REQUIRE(seen->value == 7); + REQUIRE(seen->resumedOn != std::this_thread::get_id()); +} + +TEST_CASE("a stop outside any resumption context resumes the await on the completion's executor", + "[coroutine][client]") { + morph::exec::ThreadPoolExecutor callbacks{1}; + auto [completion, promise] = Completion::makeSettleable(&callbacks); + auto seen = std::make_shared(); + + auto task = awaitInto(std::move(completion), seen); + // NOLINTNEXTLINE(misc-const-correctness): request_stop() is non-const + core::async::StopSource stop; + task.handle().promise().setStopToken(stop.get_token()); + task.handle().resume(); + REQUIRE(seen->reachedAwait.load()); + + // Requested here; the resumption is posted to the completion's executor, + // never run on the requesting thread. + stop.request_stop(); + REQUIRE(morph::testing::waitUntil([&] { return seen->finished.load(); })); + std::atomic drained{false}; + callbacks.post([&] { drained = true; }); + REQUIRE(morph::testing::waitUntil([&] { return drained.load(); })); + REQUIRE(seen->cancelled); + REQUIRE(seen->resumedOn != std::this_thread::get_id()); +} + +namespace { + +/// An executor that refuses every task, as a full queue would. +class RefusingExecutor : public morph::exec::IExecutor { +public: + void post(std::function /*task*/) override { throw std::runtime_error{"executor refused the task"}; } +}; + +} // namespace + +TEST_CASE("co_await whose handler cannot be attached rethrows at the co_await and leaves nothing attached", + "[coroutine][client]") { + // Already settled, so attaching fires the handler through the completion's + // executor at once -- and that executor refuses it, inside await_suspend. + RefusingExecutor refusing; + auto [completion, promise] = Completion::makeSettleable(&refusing); + promise.resolve(5); + morph::exec::MainThreadExecutor exec; + auto seen = std::make_shared(); + + morph::async::spawn(exec, awaitInto(std::move(completion), seen)); + + REQUIRE(pumpUntil(exec, [&] { return seen->finished.load(); })); + REQUIRE(seen->error == "executor refused the task"); + REQUIRE_FALSE(seen->value.has_value()); + REQUIRE_FALSE(seen->cancelled); +} diff --git a/tests/test_coroutine_model.cpp b/tests/test_coroutine_model.cpp new file mode 100644 index 000000000..cfc9e3a4a --- /dev/null +++ b/tests/test_coroutine_model.cpp @@ -0,0 +1,1509 @@ +// SPDX-License-Identifier: Apache-2.0 +// +// The model side of docs/spec/core/coroutines.md: an action handler returning +// core::async::Task, driven on its model's strand by LocalBackend and by +// RemoteServer, one action at a time. + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "test_support.hpp" + +using namespace std::chrono_literals; + +// A named namespace, not an anonymous one: ActionDispatcher::registerAction +// files each action's schema, and glaze's reflection cannot name a type with +// internal linkage under MSVC (C7631). +namespace coro_test { + +/// @return Whether @p error holds an `Error`. +template +[[nodiscard]] bool holds(const std::exception_ptr& error) { + try { + std::rethrow_exception(error); + } catch (const Error&) { + return true; + } catch (...) { + return false; + } +} + +template +[[nodiscard]] bool pumpUntil(morph::exec::MainThreadExecutor& exec, Pred pred) { + auto const deadline = std::chrono::steady_clock::now() + morph::testing::kDefaultWaitBudget; + while (!pred()) { + if (std::chrono::steady_clock::now() >= deadline) { + return false; + } + exec.runFor(morph::testing::kDefaultWaitStep); + } + return true; +} + +// ── A model with an ordinary handler, for a Task handler to await ──────────── +struct CoroLookup { + int x = 0; +}; + +struct CoroSourceModel { + // NOLINTNEXTLINE(readability-convert-member-functions-to-static) + int execute(const CoroLookup& lookup) { return lookup.x * 10; } +}; + +// ── The model under test: every handler is a coroutine ─────────────────────── +struct CoroModel; +struct CoroDouble { + int x = 0; +}; +struct CoroAwaitOther { + int x = 0; +}; +struct CoroHold { + int tag = 0; +}; +struct CoroLog { + int tag = 0; +}; +struct CoroThrow { + bool fail = true; +}; +struct CoroSleep { + int ms = 0; +}; +struct CoroTick { + int ms = 0; +}; +struct CoroForeign { + int tag = 0; +}; +struct CoroPop { + int tag = 0; +}; +struct CoroPopBack { + int tag = 0; +}; +struct CoroAway { + int tag = 0; +}; +struct CoroDetach { + int tag = 0; +}; +/// A Task handler's action with a validator: only a positive `x` is ready. +struct CoroValidated { + int x = 0; + [[nodiscard]] bool validate() const { return x > 0; } +}; +/// A Task handler that completes without suspending. +struct CoroQuick { + int x = 0; +}; + +} // namespace coro_test + +using coro_test::CoroAwaitOther; +using coro_test::CoroAway; +using coro_test::CoroDetach; +using coro_test::CoroDouble; +using coro_test::CoroForeign; +using coro_test::CoroHold; +using coro_test::CoroLog; +using coro_test::CoroLookup; +using coro_test::CoroModel; +using coro_test::CoroPop; +using coro_test::CoroPopBack; +using coro_test::CoroQuick; +using coro_test::CoroSleep; +using coro_test::CoroSourceModel; +using coro_test::CoroThrow; +using coro_test::CoroTick; +using coro_test::CoroValidated; + +// Hand-written traits, as the other bridge tests use: the JSON codecs only +// matter on the remote path, and there only for ints. +// A macro because an explicit specialisation cannot be produced by a template. +// NOLINTNEXTLINE(cppcoreguidelines-macro-usage) +#define CORO_INT_ACTION(ACTION, NAME) \ + template <> \ + struct morph::model::ActionTraits { \ + using Result = int; \ + static constexpr std::string_view typeId() { return NAME; } \ + static std::string toJson(const ACTION&) { return "{}"; } \ + static ACTION fromJson(std::string_view) { return {}; } \ + static std::string resultToJson(const int& result) { return std::to_string(result); } \ + static int resultFromJson(std::string_view json) { return std::stoi(std::string{json}); } \ + }; + +CORO_INT_ACTION(CoroLookup, "Coro_Lookup") +CORO_INT_ACTION(CoroDouble, "Coro_Double") +CORO_INT_ACTION(CoroAwaitOther, "Coro_AwaitOther") +CORO_INT_ACTION(CoroHold, "Coro_Hold") +CORO_INT_ACTION(CoroLog, "Coro_Log") +CORO_INT_ACTION(CoroThrow, "Coro_Throw") +CORO_INT_ACTION(CoroSleep, "Coro_Sleep") +CORO_INT_ACTION(CoroTick, "Coro_Tick") +CORO_INT_ACTION(CoroForeign, "Coro_Foreign") +CORO_INT_ACTION(CoroPop, "Coro_Pop") +CORO_INT_ACTION(CoroPopBack, "Coro_PopBack") +CORO_INT_ACTION(CoroAway, "Coro_Away") +CORO_INT_ACTION(CoroDetach, "Coro_Detach") +CORO_INT_ACTION(CoroValidated, "Coro_Validated") +CORO_INT_ACTION(CoroQuick, "Coro_Quick") +#undef CORO_INT_ACTION + +template <> +struct morph::model::ModelTraits { + static constexpr std::string_view typeId() { return "Coro_SourceModel"; } +}; +template <> +struct morph::model::ModelTraits { + static constexpr std::string_view typeId() { return "Coro_Model"; } +}; + +namespace coro_test { + +/// What the handlers report back, shared with the test body. The model is +/// default-constructed by the bridge, so it reaches this through a global. +struct CoroProbe { + std::mutex mtx; + std::vector order; + std::vector onOwnStrand; + std::vector threads; + morph::bridge::BridgeHandler* source = nullptr; + std::optional::Promise> release; + std::optional> held; + std::atomic holdCancelled{false}; + std::atomic holdFinished{false}; + std::atomic ticks{0}; + /// A core-cpp queue, whose consumer resumes on the executor it parked on. + std::unique_ptr> queue; + /// Where `CoroAway` goes: an executor that is not the model's strand. + core::async::IExecutor* away = nullptr; + /// What the chain `CoroDetach` starts popped, once it has. + std::atomic detached{0}; + std::atomic popThread; + std::atomic logThread; + + void note(std::string entry) { + std::scoped_lock const lock{mtx}; + order.push_back(std::move(entry)); + } + void noteStrand(bool own) { + std::scoped_lock const lock{mtx}; + onOwnStrand.push_back(own); + threads.push_back(std::this_thread::get_id()); + } +}; + +/// A coroutine type from outside core::async: its promise carries no stop +/// token, so a morph awaiter inside it cannot observe a stop. +struct Unstoppable { + struct promise_type { + std::coroutine_handle<> continuation; + + Unstoppable get_return_object() noexcept { + return Unstoppable{std::coroutine_handle::from_promise(*this)}; + } + [[nodiscard]] std::suspend_always initial_suspend() const noexcept { return {}; } + struct Final { + [[nodiscard]] bool await_ready() const noexcept { return false; } + [[nodiscard]] std::coroutine_handle<> await_suspend( + std::coroutine_handle self) const noexcept { + return self.promise().continuation; + } + void await_resume() const noexcept {} + }; + [[nodiscard]] Final final_suspend() const noexcept { return {}; } + void return_void() const noexcept {} + [[noreturn]] void unhandled_exception() const noexcept { std::terminate(); } + }; + + explicit Unstoppable(std::coroutine_handle handle) noexcept : _handle{handle} {} + Unstoppable(const Unstoppable&) = delete; + Unstoppable& operator=(const Unstoppable&) = delete; + Unstoppable(Unstoppable&& other) noexcept : _handle{std::exchange(other._handle, {})} {} + Unstoppable& operator=(Unstoppable&&) = delete; + ~Unstoppable() { + if (_handle) { + _handle.destroy(); + } + } + + [[nodiscard]] bool await_ready() const noexcept { return false; } + [[nodiscard]] std::coroutine_handle<> await_suspend(std::coroutine_handle<> continuation) const noexcept { + _handle.promise().continuation = continuation; + return _handle; + } + void await_resume() const noexcept {} + +private: + std::coroutine_handle _handle; +}; + +namespace { + +CoroProbe& probe() { + static CoroProbe instance; + return instance; +} + +} // namespace + +namespace { + +/// The executor a Task handler runs in: its resumer, on its model's strand. +::core::async::IExecutor* strandContext() { + auto* context = core::async::currentExecutor(); + return dynamic_cast(context) != nullptr ? context : nullptr; +} + +} // namespace + +struct CoroModel { + // NOLINTBEGIN(readability-convert-member-functions-to-static) + core::async::Task execute(CoroDouble action) { + auto* const strand = strandContext(); + probe().noteStrand(strand != nullptr); + co_await morph::async::delay(scheduler(), 5ms); + probe().noteStrand(strand != nullptr && core::async::currentExecutor() == strand); + co_return action.x * 2; + } + + core::async::Task execute(CoroAwaitOther action) { + auto* const strand = strandContext(); + probe().noteStrand(strand != nullptr); + auto pending = probe().source->execute(CoroLookup{.x = action.x}); + int const looked = co_await std::move(pending); + // The other model's completion is delivered on its handler's executor; + // this handler must still resume on its own strand. + probe().noteStrand(strand != nullptr && core::async::currentExecutor() == strand); + co_return looked + 1; + } + + core::async::Task execute(CoroHold action) { + probe().note("hold-start"); + try { + auto pending = std::move(*probe().held); + int const value = co_await std::move(pending); + probe().note("hold-end"); + probe().holdFinished = true; + co_return value + action.tag; + } catch (const core::async::OperationCancelled&) { + probe().holdCancelled = true; + probe().holdFinished = true; + throw; + } + } + + core::async::Task execute(CoroValidated action) { co_return action.x; } + + core::async::Task execute(CoroQuick action) { co_return action.x + 1; } + + int execute(const CoroLog& action) { + probe().logThread = std::this_thread::get_id(); + probe().note("log-" + std::to_string(action.tag)); + return action.tag; + } + + // Awaits a core-cpp queue, which resumes it on the executor it parked on: + // its strand, though the push comes from another thread. + core::async::Task execute(CoroPopBack action) { + auto* const strand = strandContext(); + probe().note("popback-start"); + auto const item = co_await probe().queue->pop(); + probe().noteStrand(strand != nullptr && core::async::currentExecutor() == strand); + co_return item.value_or(0) + action.tag; + } + + // Starts a chain nobody owns that parks on a core-cpp queue, and returns: + // the chain comes back through this handler's resumer, with its claim. + core::async::Task execute(CoroDetach action) { + [](core::async::AsyncQueue* queue) -> core::async::DetachedTask { + auto const item = co_await queue->pop(); + probe().detached = item.value_or(-1); + }(probe().queue.get()); + co_return action.tag; + } + + // Leaves its strand for another executor, and ends there. + core::async::Task execute(CoroAway action) { + probe().note("away-start"); + co_await core::async::ResumeOn{*probe().away}; + probe().popThread = std::this_thread::get_id(); + probe().note("away-end"); + co_return action.tag; + } + + // Parks on a core-cpp queue, which resumes it through its strand -- a stop + // included. + core::async::Task execute(CoroPop action) { + probe().note("pop-start"); + try { + auto const item = co_await probe().queue->pop(); + probe().note("pop-end"); + co_return item.value_or(0) + action.tag; + } catch (const core::async::OperationCancelled&) { + probe().popThread = std::this_thread::get_id(); + probe().noteStrand(strandContext() != nullptr); + probe().note("pop-cancelled"); + probe().holdCancelled = true; + probe().holdFinished = true; + throw; + } + } + + core::async::Task execute(CoroSleep action) { + probe().note("sleep-start"); + try { + co_await morph::async::delay(scheduler(), std::chrono::milliseconds{action.ms}); + } catch (const core::async::OperationCancelled&) { + probe().note("sleep-cancelled"); + probe().holdCancelled = true; + probe().holdFinished = true; + throw; + } + probe().note("sleep-end"); + probe().holdFinished = true; + co_return action.ms; + } + + // Awaits the held completion from inside a coroutine that cannot see a stop. + core::async::Task execute(CoroForeign action) { + probe().note("foreign-start"); + auto inner = [](morph::async::Completion pending) -> Unstoppable { + int const value = co_await std::move(pending); + static_cast(value); + }(std::move(*probe().held)); + co_await std::move(inner); + probe().note("foreign-end"); + probe().holdFinished = true; + co_return action.tag; + } + + // Ticks until it is stopped: a handler that only a stop can end. + core::async::Task execute(CoroTick action) { + probe().note("tick-start"); + int ticks = 0; + try { + while (ticks < 1'000'000) { + co_await morph::async::delay(scheduler(), std::chrono::milliseconds{action.ms}); + ++ticks; + probe().ticks = ticks; + } + } catch (const core::async::OperationCancelled&) { + probe().note("tick-cancelled"); + probe().holdCancelled = true; + probe().holdFinished = true; + throw; + } + co_return ticks; + } + + core::async::Task execute(CoroThrow action) { + co_await morph::async::delay(scheduler(), 1ms); + // Conditional, so the coroutine still ends in a co_return: MSVC + // reports one that ends in a throw as returning no value (C4033). + if (action.fail) { + throw std::runtime_error{"handler failed after a suspension"}; + } + co_return 0; + } + // NOLINTEND(readability-convert-member-functions-to-static) + + static morph::async::detail::TimeoutScheduler& scheduler(); +}; + +/// Owns the scheduler CoroModel's handlers delay on, for one test, and destroys +/// it -- joining its thread -- when the test ends. It is not a function-local +/// static: one that outlived its test kept its thread for the rest of the run, +/// and every later fork() child (the registration-phase tests fork) inherited +/// the thread's state without the thread, which Valgrind reports as definitely +/// lost and turns into the child's exit status. +class SchedulerScope { +public: + SchedulerScope() { current = &_scheduler; } + SchedulerScope(const SchedulerScope&) = delete; + SchedulerScope& operator=(const SchedulerScope&) = delete; + SchedulerScope(SchedulerScope&&) = delete; + SchedulerScope& operator=(SchedulerScope&&) = delete; + ~SchedulerScope() { current = nullptr; } + + static inline morph::async::detail::TimeoutScheduler* current = nullptr; + +private: + morph::async::detail::TimeoutScheduler _scheduler; +}; + +morph::async::detail::TimeoutScheduler& CoroModel::scheduler() { + if (SchedulerScope::current == nullptr) { + throw std::logic_error{"a CoroModel handler delayed in a test that declares no SchedulerScope"}; + } + return *SchedulerScope::current; +} + +namespace { + +/// Arms `probe().held` with a completion the test settles through `release`. +void armHold(morph::exec::IExecutor* exec) { + auto [completion, promise] = morph::async::Completion::makeSettleable(exec); + probe().held.emplace(std::move(completion)); + probe().release.emplace(std::move(promise)); + probe().holdCancelled = false; + probe().holdFinished = false; + probe().ticks = 0; + probe().detached = 0; + probe().popThread = std::thread::id{}; + probe().logThread = std::thread::id{}; + std::scoped_lock const lock{probe().mtx}; + probe().order.clear(); + probe().onOwnStrand.clear(); + probe().threads.clear(); +} + +} // namespace + +} // namespace coro_test + +using coro_test::armHold; +using coro_test::holds; +using coro_test::probe; +using coro_test::pumpUntil; + +static_assert(morph::model::isTaskHandler().execute(CoroDouble{}))>); +static_assert(!morph::model::isTaskHandler().execute(CoroLog{}))>); +static_assert(std::is_same_v>, int>); + +TEST_CASE("a Task handler's result reaches the client, and every resumption runs on the model's strand", + "[coroutine][model]") { + coro_test::SchedulerScope const timers; + morph::exec::ThreadPoolExecutor pool{2}; + morph::exec::MainThreadExecutor exec; + morph::bridge::Bridge bridge{std::make_unique(pool)}; + morph::bridge::BridgeHandler handler{bridge, &exec}; + armHold(&exec); + + std::optional result; + handler.execute(CoroDouble{.x = 21}) + .then([&](int value) { result = value; }) + .onError([](const std::exception_ptr&) {}); + + REQUIRE(pumpUntil(exec, [&] { return result.has_value(); })); + REQUIRE(*result == 42); + + // The delay fires on the scheduler's thread; the step after it must not + // run there, nor on this one, which only delivers the result. + std::atomic timerThread{}; + std::atomic timerRan{false}; + CoroModel::scheduler().schedule(0ms, [&] { + timerThread = std::this_thread::get_id(); + timerRan = true; + }); + REQUIRE(pumpUntil(exec, [&] { return timerRan.load(); })); + + std::scoped_lock const lock{probe().mtx}; + REQUIRE(probe().onOwnStrand == std::vector{true, true}); + REQUIRE(probe().threads.size() == 2); + REQUIRE(probe().threads[1] != timerThread.load()); + REQUIRE(probe().threads[1] != std::this_thread::get_id()); +} + +namespace coro_test { + +/// A step of a coroutine that hops through @p executor before running. +struct Hop { + ::core::async::IExecutor* executor; + [[nodiscard]] constexpr bool await_ready() const noexcept { return false; } + void await_suspend(std::coroutine_handle<> awaiting) const { executor->submit(awaiting); } + void await_resume() const noexcept {} +}; + +/// Counts how many tasks are inside a critical section at once. +struct Overlap { + std::atomic active{0}; + std::atomic overlaps{0}; + + /// Holds the section for 200 us. It spins rather than sleeps: Windows rounds + /// a sleep up to its timer resolution, up to 15.6 ms, which made the 250 + /// serial steps of the test below outlast the default wait budget on CI. + void step() { + if (active.fetch_add(1) != 0) { + overlaps.fetch_add(1); + } + auto const until = std::chrono::steady_clock::now() + std::chrono::microseconds{200}; + while (std::chrono::steady_clock::now() < until) { + std::this_thread::yield(); + } + active.fetch_sub(1); + } +}; + +namespace { + +::core::async::Task hopSteps(::core::async::IExecutor* executor, Overlap* overlap, int count, + std::atomic* done) { + for (int step = 0; step < count; ++step) { + co_await Hop{executor}; + overlap->step(); + } + *done = true; +} + +} // namespace + +} // namespace coro_test + +TEST_CASE("TaskResumer resumes a coroutine as one of its strand's tasks, never beside them", + "[coroutine][model][strand]") { + morph::exec::ThreadPoolExecutor pool{4}; + auto const strands = std::make_shared(pool); + morph::exec::detail::ModelId const key{7}; + auto executor = std::make_shared(strands, key, morph::session::Context{}); + + coro_test::Overlap overlap; + std::atomic done{false}; + std::atomic posted{0}; + morph::async::spawn(pool, coro_test::hopSteps(executor.get(), &overlap, 50, &done)); + // Other tasks on the same strand, interleaved with the coroutine's steps + // on a pool of four: a step resumed anywhere but on the strand overlaps + // one of them. + for (int task = 0; task < 200; ++task) { + strands->post(key, [&] { + overlap.step(); + posted.fetch_add(1); + }); + } + + REQUIRE(morph::testing::waitUntil([&] { return done.load() && posted.load() == 200; })); + REQUIRE(overlap.overlaps.load() == 0); + strands->close(); +} + +TEST_CASE("a Task handler can await another model's execute and comes back to its own strand", "[coroutine][model]") { + coro_test::SchedulerScope const timers; + morph::exec::ThreadPoolExecutor pool{2}; + morph::exec::ThreadPoolExecutor otherCallbacks{1}; + morph::exec::MainThreadExecutor exec; + morph::bridge::Bridge bridge{std::make_unique(pool)}; + morph::bridge::BridgeHandler source{bridge, &otherCallbacks}; + morph::bridge::BridgeHandler handler{bridge, &exec}; + armHold(&exec); + probe().source = &source; + + std::optional result; + handler.execute(CoroAwaitOther{.x = 4}) + .then([&](int value) { result = value; }) + .onError([](const std::exception_ptr&) {}); + + REQUIRE(pumpUntil(exec, [&] { return result.has_value(); })); + REQUIRE(*result == 41); + std::scoped_lock const lock{probe().mtx}; + REQUIRE(probe().onOwnStrand == std::vector{true, true}); +} + +TEST_CASE("the next action on a model starts only once a suspended Task handler has completed", "[coroutine][model]") { + coro_test::SchedulerScope const timers; + morph::exec::ThreadPoolExecutor pool{2}; + morph::exec::MainThreadExecutor exec; + morph::bridge::Bridge bridge{std::make_unique(pool)}; + morph::bridge::BridgeHandler handler{bridge, &exec}; + armHold(&exec); + + std::optional held; + std::optional logged; + handler.execute(CoroHold{.tag = 1}).then([&](int value) { held = value; }).onError([](const std::exception_ptr&) { + }); + handler.execute(CoroLog{.tag = 2}).then([&](int value) { logged = value; }).onError([](const std::exception_ptr&) { + }); + + // The first handler is suspended on a completion nobody has settled; the + // second action must still be waiting behind it. + REQUIRE(pumpUntil(exec, [&] { + std::scoped_lock const lock{probe().mtx}; + return !probe().order.empty(); + })); + exec.runFor(50ms); + { + std::scoped_lock const lock{probe().mtx}; + REQUIRE(probe().order == std::vector{"hold-start"}); + } + REQUIRE_FALSE(logged.has_value()); + + probe().release->resolve(100); + + REQUIRE(pumpUntil(exec, [&] { return held.has_value() && logged.has_value(); })); + REQUIRE(*held == 101); + REQUIRE(*logged == 2); + std::scoped_lock const lock{probe().mtx}; + REQUIRE(probe().order == std::vector{"hold-start", "hold-end", "log-2"}); +} + +TEST_CASE("an execute deadline cancels a suspended Task handler and rejects with ClientTimeoutError", + "[coroutine][model]") { + coro_test::SchedulerScope const timers; + morph::exec::ThreadPoolExecutor pool{2}; + morph::exec::MainThreadExecutor exec; + morph::bridge::Bridge bridge{std::make_unique(pool)}; + bridge.setExecuteDeadline(50ms); + morph::bridge::BridgeHandler handler{bridge, &exec}; + armHold(&exec); + + bool timedOut = false; + handler.execute(CoroHold{.tag = 1}).onError([&](const std::exception_ptr& error) { + timedOut = holds(error); + }); + + REQUIRE(pumpUntil(exec, [&] { return timedOut && probe().holdFinished.load(); })); + REQUIRE(probe().holdCancelled.load()); + + // The gate was released: the next action on the model runs. + std::optional logged; + handler.execute(CoroLog{.tag = 3}).then([&](int value) { logged = value; }).onError([](const std::exception_ptr&) { + }); + REQUIRE(pumpUntil(exec, [&] { return logged.has_value(); })); +} + +namespace coro_test { + +namespace { + +/// Switches @p bridge to a fresh LocalBackend over @p pool on a helper thread, +/// and ends the process with a message if the switch does not return: a +/// `~LocalBackend` stuck draining its strand would otherwise hold the whole run +/// until ctest's timeout, with nothing said about why. +void switchWithin(morph::bridge::Bridge& bridge, morph::exec::IExecutor& pool) { + std::promise returned; + auto finished = returned.get_future(); + std::thread switcher{[&] { + bridge.switchBackend(std::make_unique(pool)); + returned.set_value(); + }}; + if (finished.wait_for(morph::testing::kDefaultWaitBudget * 5) != std::future_status::ready) { + static_cast(std::fputs( + "switchBackend did not return: the outgoing LocalBackend is stuck draining its strand\n", stderr)); + static_cast(std::fflush(stderr)); + std::abort(); + } + switcher.join(); +} + +} // namespace + +/// Records that a call failed because its backend was switched away. +struct SwitchedFlag { + std::atomic seen{false}; + + auto handler() { + return [this](const std::exception_ptr& error) { + if (holds(error)) { + seen = true; + } + }; + } +}; + +} // namespace coro_test + +// A backend switch fails every pending call and destroys the outgoing +// LocalBackend. Its destructor stops every Task handler it started, lets their +// cancelled resumptions and its queued work drain on the strand, and only then +// closes the strand to them. +TEST_CASE("a backend switch stops a Task handler suspended on a completion", "[coroutine][model][lifetime]") { + coro_test::SchedulerScope const timers; + morph::exec::ThreadPoolExecutor pool{2}; + morph::exec::MainThreadExecutor exec; + morph::bridge::Bridge bridge{std::make_unique(pool)}; + morph::bridge::BridgeHandler handler{bridge, &exec}; + armHold(&exec); + + coro_test::SwitchedFlag switched; + handler.execute(CoroHold{.tag = 1}).then([](int) {}).onError(switched.handler()); + REQUIRE(pumpUntil(exec, [&] { + std::scoped_lock const lock{probe().mtx}; + return !probe().order.empty(); + })); + + coro_test::switchWithin(bridge, pool); + REQUIRE(pumpUntil(exec, [&] { return switched.seen.load() && probe().holdFinished.load(); })); + REQUIRE(probe().holdCancelled.load()); + + // The await was withdrawn: settling the completion now reaches nothing. + probe().release->resolve(100); + exec.runFor(20ms); + std::scoped_lock const lock{probe().mtx}; + REQUIRE(probe().order == std::vector{"hold-start"}); +} + +TEST_CASE("a backend switch stops a Task handler suspended on a delay", "[coroutine][model][lifetime]") { + coro_test::SchedulerScope const timers; + morph::exec::ThreadPoolExecutor pool{2}; + morph::exec::MainThreadExecutor exec; + morph::bridge::Bridge bridge{std::make_unique(pool)}; + morph::bridge::BridgeHandler handler{bridge, &exec}; + armHold(&exec); + + coro_test::SwitchedFlag switched; + handler.execute(CoroSleep{.ms = 60'000}).then([](int) {}).onError(switched.handler()); + REQUIRE(pumpUntil(exec, [&] { + std::scoped_lock const lock{probe().mtx}; + return !probe().order.empty(); + })); + + coro_test::switchWithin(bridge, pool); + REQUIRE(pumpUntil(exec, [&] { return switched.seen.load() && probe().holdFinished.load(); })); + std::scoped_lock const lock{probe().mtx}; + REQUIRE(probe().order == std::vector{"sleep-start", "sleep-cancelled"}); +} + +TEST_CASE("a backend switch stops a Task handler that loops on delay", "[coroutine][model][lifetime]") { + coro_test::SchedulerScope const timers; + morph::exec::ThreadPoolExecutor pool{2}; + morph::exec::MainThreadExecutor exec; + morph::bridge::Bridge bridge{std::make_unique(pool)}; + morph::bridge::BridgeHandler handler{bridge, &exec}; + armHold(&exec); + + coro_test::SwitchedFlag switched; + handler.execute(CoroTick{.ms = 2}).then([](int) {}).onError(switched.handler()); + REQUIRE(pumpUntil(exec, [&] { return probe().ticks.load() >= 3; })); + + coro_test::switchWithin(bridge, pool); + REQUIRE(pumpUntil(exec, [&] { return switched.seen.load() && probe().holdFinished.load(); })); + REQUIRE(probe().holdCancelled.load()); + // Stopped for good: no tick after the cancellation. + int const ticksAtStop = probe().ticks.load(); + exec.runFor(30ms); + REQUIRE(probe().ticks.load() == ticksAtStop); + std::scoped_lock const lock{probe().mtx}; + REQUIRE(probe().order == std::vector{"tick-start", "tick-cancelled"}); +} + +TEST_CASE("an action queued behind a suspended Task handler does not run once a backend switch failed it", + "[coroutine][model][lifetime]") { + coro_test::SchedulerScope const timers; + morph::exec::ThreadPoolExecutor pool{2}; + morph::exec::MainThreadExecutor exec; + morph::bridge::Bridge bridge{std::make_unique(pool)}; + morph::bridge::BridgeHandler handler{bridge, &exec}; + armHold(&exec); + + auto const overlapsBefore = morph::model::detail::ActionGate::overlapsObserved(); + coro_test::SwitchedFlag holdSwitched; + coro_test::SwitchedFlag logSwitched; + handler.execute(CoroHold{.tag = 1}).then([](int) {}).onError(holdSwitched.handler()); + handler.execute(CoroLog{.tag = 2}).then([](int) {}).onError(logSwitched.handler()); + REQUIRE(pumpUntil(exec, [&] { + std::scoped_lock const lock{probe().mtx}; + return !probe().order.empty(); + })); + + coro_test::switchWithin(bridge, pool); + REQUIRE(pumpUntil(exec, [&] { return holdSwitched.seen.load() && logSwitched.seen.load(); })); + + // Whatever settles the held completion now, the queued action stays unrun. + probe().release->resolve(100); + REQUIRE(pumpUntil(exec, [&] { return probe().holdFinished.load(); })); + exec.runFor(20ms); + REQUIRE(morph::model::detail::ActionGate::overlapsObserved() == overlapsBefore); + std::scoped_lock const lock{probe().mtx}; + REQUIRE(probe().order == std::vector{"hold-start"}); +} + +// The one handler a stop cannot end: it is suspended in an awaitable that +// ignores stops. It outlives the strand, and resumes inline, not into it. +TEST_CASE("a handler that ignores the stop resumes inline once its backend is gone", "[coroutine][model][lifetime]") { + coro_test::SchedulerScope const timers; + morph::exec::ThreadPoolExecutor pool{2}; + morph::exec::MainThreadExecutor exec; + morph::bridge::Bridge bridge{std::make_unique(pool)}; + morph::bridge::BridgeHandler handler{bridge, &exec}; + armHold(&exec); + auto const overlapsBefore = morph::model::detail::ActionGate::overlapsObserved(); + + coro_test::SwitchedFlag switched; + handler.execute(CoroForeign{.tag = 1}).then([](int) {}).onError(switched.handler()); + REQUIRE(pumpUntil(exec, [&] { + std::scoped_lock const lock{probe().mtx}; + return !probe().order.empty(); + })); + + coro_test::switchWithin(bridge, pool); + REQUIRE(pumpUntil(exec, [&] { return switched.seen.load(); })); + REQUIRE_FALSE(probe().holdFinished.load()); + + // Settles on `exec`: the resumption runs here, with the strand gone. + probe().release->resolve(100); + REQUIRE(pumpUntil(exec, [&] { return probe().holdFinished.load(); })); + REQUIRE(morph::model::detail::ActionGate::overlapsObserved() == overlapsBefore); + std::scoped_lock const lock{probe().mtx}; + REQUIRE(probe().order == std::vector{"foreign-start", "foreign-end"}); +} + +// A core-cpp awaiter resumes a stopped handler on its own executor, not the +// strand. The handler finishes there, but what follows -- leaving the gate and +// starting the next action -- must still happen on the strand. +TEST_CASE("an execute deadline stops a handler parked on a core-cpp queue, which unwinds on its strand", + "[coroutine][model][foreign]") { + coro_test::SchedulerScope const timers; + morph::exec::ThreadPoolExecutor pool{2}; + core::async::ThreadPoolExecutor foreign{1}; + morph::exec::MainThreadExecutor exec; + morph::bridge::Bridge bridge{std::make_unique(pool)}; + bridge.setExecuteDeadline(50ms); + morph::bridge::BridgeHandler handler{bridge, &exec}; + armHold(&exec); + probe().queue = std::make_unique>(foreign, core::async::AsyncQueueOptions{}); + auto const overlapsBefore = morph::model::detail::ActionGate::overlapsObserved(); + + handler.execute(CoroPop{}).then([](int) {}).onError([](const std::exception_ptr&) {}); + handler.execute(CoroLog{.tag = 5}).then([](int) {}).onError([](const std::exception_ptr&) {}); + + // The deadline stops the pop; the queued action then runs. + REQUIRE(pumpUntil(exec, [&] { + std::scoped_lock const lock{probe().mtx}; + return probe().order.size() == 3; + })); + { + std::scoped_lock const lock{probe().mtx}; + REQUIRE(probe().order == std::vector{"pop-start", "pop-cancelled", "log-5"}); + // The stop came from the deadline's thread; the handler still unwound + // on its strand, not on the queue's executor. + REQUIRE(probe().onOwnStrand == std::vector{true}); + } + REQUIRE(morph::model::detail::ActionGate::overlapsObserved() == overlapsBefore); + probe().queue.reset(); +} + +TEST_CASE("the next action starts on the strand after a handler ended on another executor", + "[coroutine][model][foreign]") { + coro_test::SchedulerScope const timers; + morph::exec::ThreadPoolExecutor pool{2}; + core::async::ThreadPoolExecutor foreign{1}; + morph::exec::MainThreadExecutor exec; + morph::bridge::Bridge bridge{std::make_unique(pool)}; + morph::bridge::BridgeHandler handler{bridge, &exec}; + armHold(&exec); + probe().away = &foreign; + auto const overlapsBefore = morph::model::detail::ActionGate::overlapsObserved(); + + handler.execute(CoroAway{.tag = 1}).then([](int) {}).onError([](const std::exception_ptr&) {}); + handler.execute(CoroLog{.tag = 5}).then([](int) {}).onError([](const std::exception_ptr&) {}); + + REQUIRE(pumpUntil(exec, [&] { + std::scoped_lock const lock{probe().mtx}; + return probe().order.size() == 3; + })); + { + std::scoped_lock const lock{probe().mtx}; + REQUIRE(probe().order == std::vector{"away-start", "away-end", "log-5"}); + } + // The handler ended on the foreign executor; the action after it did not. + REQUIRE(probe().popThread.load() != std::thread::id{}); + REQUIRE(probe().logThread.load() != probe().popThread.load()); + REQUIRE(morph::model::detail::ActionGate::overlapsObserved() == overlapsBefore); + probe().away = nullptr; +} + +TEST_CASE("a backend switch stops a handler parked on a core-cpp queue, and skips the action behind it", + "[coroutine][model][foreign][lifetime]") { + coro_test::SchedulerScope const timers; + morph::exec::ThreadPoolExecutor pool{2}; + core::async::ThreadPoolExecutor foreign{1}; + morph::exec::MainThreadExecutor exec; + morph::bridge::Bridge bridge{std::make_unique(pool)}; + morph::bridge::BridgeHandler handler{bridge, &exec}; + armHold(&exec); + probe().queue = std::make_unique>(foreign, core::async::AsyncQueueOptions{}); + auto const overlapsBefore = morph::model::detail::ActionGate::overlapsObserved(); + + coro_test::SwitchedFlag popSwitched; + coro_test::SwitchedFlag logSwitched; + handler.execute(CoroPop{}).then([](int) {}).onError(popSwitched.handler()); + handler.execute(CoroLog{.tag = 6}).then([](int) {}).onError(logSwitched.handler()); + REQUIRE(pumpUntil(exec, [&] { + std::scoped_lock const lock{probe().mtx}; + return !probe().order.empty(); + })); + + coro_test::switchWithin(bridge, pool); + REQUIRE(pumpUntil( + exec, [&] { return popSwitched.seen.load() && logSwitched.seen.load() && probe().holdFinished.load(); })); + exec.runFor(20ms); + REQUIRE(morph::model::detail::ActionGate::overlapsObserved() == overlapsBefore); + std::scoped_lock const lock{probe().mtx}; + REQUIRE(probe().order == std::vector{"pop-start", "pop-cancelled"}); + // Resumed through its strand, which the backend drains before it closes. + REQUIRE(probe().onOwnStrand == std::vector{true}); + probe().queue.reset(); +} + +TEST_CASE("a handler that awaits a core-cpp queue comes back to its own strand", "[coroutine][model][foreign]") { + coro_test::SchedulerScope const timers; + morph::exec::ThreadPoolExecutor pool{2}; + core::async::ThreadPoolExecutor foreign{1}; + morph::exec::MainThreadExecutor exec; + morph::bridge::Bridge bridge{std::make_unique(pool)}; + morph::bridge::BridgeHandler handler{bridge, &exec}; + armHold(&exec); + probe().queue = std::make_unique>(foreign, core::async::AsyncQueueOptions{}); + + std::optional result; + handler.execute(CoroPopBack{.tag = 1}) + .then([&](int value) { result = value; }) + .onError([](const std::exception_ptr&) {}); + REQUIRE(pumpUntil(exec, [&] { + std::scoped_lock const lock{probe().mtx}; + return !probe().order.empty(); + })); + static_cast(probe().queue->push(41)); + + REQUIRE(pumpUntil(exec, [&] { return result.has_value(); })); + REQUIRE(*result == 42); + { + // The push came from this thread; the handler resumed on its strand. + std::scoped_lock const lock{probe().mtx}; + REQUIRE(probe().onOwnStrand == std::vector{true}); + } + probe().queue.reset(); +} + +TEST_CASE("a detached chain a Task handler starts on a core-cpp queue resumes after the handler has finished", + "[coroutine][model][foreign]") { + coro_test::SchedulerScope const timers; + morph::exec::ThreadPoolExecutor pool{2}; + core::async::ThreadPoolExecutor foreign{1}; + morph::exec::MainThreadExecutor exec; + morph::bridge::Bridge bridge{std::make_unique(pool)}; + morph::bridge::BridgeHandler handler{bridge, &exec}; + armHold(&exec); + probe().queue = std::make_unique>(foreign, core::async::AsyncQueueOptions{}); + + std::optional result; + handler.execute(CoroDetach{.tag = 3}) + .then([&](int value) { result = value; }) + .onError([](const std::exception_ptr&) {}); + REQUIRE(pumpUntil(exec, [&] { return result.has_value(); })); + REQUIRE(*result == 3); + + // The push hands the parked chain -- a DetachedTask, whose claim is + // armed -- to the resumer it parked under. Dropping that claim would free + // the frame while its handle waits on the strand. + static_cast(probe().queue->push(41)); + REQUIRE(pumpUntil(exec, [&] { return probe().detached.load() == 41; })); + probe().queue.reset(); +} + +TEST_CASE("an exception a Task handler throws after a suspension reaches onError", "[coroutine][model]") { + coro_test::SchedulerScope const timers; + morph::exec::ThreadPoolExecutor pool{2}; + morph::exec::MainThreadExecutor exec; + morph::bridge::Bridge bridge{std::make_unique(pool)}; + morph::bridge::BridgeHandler handler{bridge, &exec}; + armHold(&exec); + + std::string message; + handler.execute(CoroThrow{}).then([](int) {}).onError([&](const std::exception_ptr& error) { + try { + std::rethrow_exception(error); + } catch (const std::exception& exc) { + message = exc.what(); + } + }); + + REQUIRE(pumpUntil(exec, [&] { return !message.empty(); })); + REQUIRE(message == "handler failed after a suspension"); +} + +TEST_CASE("RemoteServer's executeTimeout stops a suspended Task handler and releases the model", + "[coroutine][model][remote]") { + coro_test::SchedulerScope const timers; + morph::exec::ThreadPoolExecutor pool{2}; + morph::exec::MainThreadExecutor exec; + morph::model::detail::ModelRegistryFactory registry; + morph::model::detail::ActionDispatcher dispatcher; + registry.registerModel("Coro_Model"); + dispatcher.registerAction("Coro_Model", "Coro_Hold"); + dispatcher.registerAction("Coro_Model", "Coro_Log"); + auto server = std::make_shared(pool, dispatcher, registry); + morph::backend::LimitPolicy policy; + policy.executeTimeout = 50ms; + server->setLimitPolicy(policy); + morph::bridge::Bridge bridge{std::make_unique(*server)}; + morph::bridge::BridgeHandler handler{bridge, &exec}; + armHold(&exec); + + bool timedOut = false; + handler.execute(CoroHold{}).then([](int) {}).onError([&](const std::exception_ptr& error) { + timedOut = holds(error); + }); + + // The held completion is never settled: only the server's timeout can + // end the handler, and it must, or the model stays gated for good. + REQUIRE(pumpUntil(exec, [&] { return timedOut && probe().holdFinished.load(); })); + REQUIRE(probe().holdCancelled.load()); + + std::optional logged; + handler.execute(CoroLog{.tag = 4}).then([&](int value) { logged = value; }).onError([](const std::exception_ptr&) { + }); + REQUIRE(pumpUntil(exec, [&] { return logged.has_value(); })); +} + +TEST_CASE("a Task handler behind RemoteServer replies with its result, and its failures as err", + "[coroutine][model][remote]") { + coro_test::SchedulerScope const timers; + morph::exec::ThreadPoolExecutor pool{2}; + morph::exec::MainThreadExecutor exec; + morph::model::detail::ModelRegistryFactory registry; + morph::model::detail::ActionDispatcher dispatcher; + registry.registerModel("Coro_Model"); + dispatcher.registerAction("Coro_Model", "Coro_Double"); + dispatcher.registerAction("Coro_Model", "Coro_Throw"); + auto server = std::make_shared(pool, dispatcher, registry); + morph::bridge::Bridge bridge{std::make_unique(*server)}; + morph::bridge::BridgeHandler handler{bridge, &exec}; + armHold(&exec); + + std::optional result; + std::string message; + handler.execute(CoroDouble{}).then([&](int value) { result = value; }).onError([](const std::exception_ptr&) {}); + handler.execute(CoroThrow{}).then([](int) {}).onError([&](const std::exception_ptr& error) { + try { + std::rethrow_exception(error); + } catch (const std::exception& exc) { + message = exc.what(); + } + }); + + // The action decodes to {} on the server, so x == 0. + REQUIRE(pumpUntil(exec, [&] { return result.has_value() && !message.empty(); })); + REQUIRE(*result == 0); + REQUIRE(message.contains("handler failed after a suspension")); + // A synchronous dispatch of a Task handler is refused rather than blocking. + auto holder = morph::model::detail::ModelFactory::create(); + REQUIRE_THROWS_AS(dispatcher.dispatch("Coro_Model", "Coro_Double", *holder, "{}"), std::logic_error); +} + +namespace coro_test { +namespace { + +/// A sink that refuses to record a success, as test_action_log.cpp's does: an +/// action that committed, and whose entry did not reach the backend. +class SuccessRefusingLog : public morph::journal::IActionLog { +public: + void append(morph::journal::LogEntry entry) override { + std::scoped_lock const lock{_mtx}; + _offered.push_back(entry); + if (entry.outcome == morph::journal::Outcome::Succeeded) { + throw std::runtime_error{"journal sink unavailable"}; + } + } + void flush() override {} + [[nodiscard]] std::vector entries(std::string_view /*entityKey*/ = {}) const override { + return {}; + } + + /// Every entry the framework asked this sink to record, refused ones included. + [[nodiscard]] std::vector offered() const { + std::scoped_lock const lock{_mtx}; + return _offered; + } + +private: + mutable std::mutex _mtx; + std::vector _offered; +}; + +/// @return The ActionRecordingError @p error holds, or nothing for any other std::exception. +std::optional recordingError(const std::exception_ptr& error) { + try { + std::rethrow_exception(error); + } catch (const morph::model::ActionRecordingError& err) { + return err; + } catch (const std::exception&) { + return std::nullopt; + } +} + +} // namespace +} // namespace coro_test + +TEST_CASE("a Task handler whose success the journal refuses reports ActionRecordingError, not a rejection", + "[coroutine][model][action_log]") { + coro_test::SchedulerScope const timers; + morph::exec::ThreadPoolExecutor pool{2}; + morph::exec::MainThreadExecutor exec; + morph::bridge::Bridge bridge{std::make_unique(pool)}; + auto log = std::make_shared(); + auto binding = std::make_shared(); + binding->typeId = "Coro_Model"; + binding->modelFactory = [log] { + auto holder = morph::model::detail::ModelFactory::create(); + holder->attachActionLog(log, "coro-sink-down"); + return holder; + }; + morph::bridge::BridgeHandler handler{bridge, &exec, binding}; + + std::exception_ptr failure; + handler.execute(CoroDouble{.x = 21}).then([](int) {}).onError([&](const std::exception_ptr& error) { + failure = error; + }); + REQUIRE(pumpUntil(exec, [&] { return failure != nullptr; })); + + // The Task completed, so the model's mutation committed: the caller is told + // it ran and was not recorded, with the result the journal never received. + auto const seen = coro_test::recordingError(failure); + REQUIRE(seen.has_value()); + REQUIRE(std::string{seen->what()} == "action executed but was not recorded: journal sink unavailable"); + REQUIRE(seen->result() == "42"); + // Offered as a success and refused; never filed as a rejection. + auto const offered = log->offered(); + REQUIRE(offered.size() == 1); + REQUIRE(offered.front().outcome == morph::journal::Outcome::Succeeded); +} + +TEST_CASE("dispatchAsync reports a Task handler's refused journal append as ActionRecordingError", + "[coroutine][model][action_log][remote]") { + coro_test::SchedulerScope const timers; + morph::exec::ThreadPoolExecutor pool{2}; + auto const strands = std::make_shared(pool); + morph::exec::detail::ModelId const key{9}; + auto executor = std::make_shared(strands, key, morph::session::Context{}); + morph::model::detail::ActionDispatcher dispatcher; + dispatcher.registerAction("Coro_Model", "Coro_Double"); + auto holder = morph::model::detail::ModelFactory::create(); + auto log = std::make_shared(); + holder->attachActionLog(log, "coro-sink-down-remote"); + + std::promise outcome; + auto settled = outcome.get_future(); + strands->post(key, [&] { + dispatcher.dispatchAsync( + "Coro_Model", "Coro_Double", *holder, "{}", executor, core::async::StopToken{}, + [&](const std::string&, const std::exception_ptr& error) { outcome.set_value(error); }); + }); + REQUIRE(settled.wait_for(5s) == std::future_status::ready); + auto const error = settled.get(); + + // The wire payload decodes to x == 0, so the committed result is 0. + auto const seen = coro_test::recordingError(error); + REQUIRE(seen.has_value()); + REQUIRE(seen->cause() == "journal sink unavailable"); + REQUIRE(seen->result() == "0"); + auto const offered = log->offered(); + REQUIRE(offered.size() == 1); + REQUIRE(offered.front().outcome == morph::journal::Outcome::Succeeded); + strands->close(); +} + +TEST_CASE("ActionGate queues an action that arrives during its drain behind the ones already waiting", + "[coroutine][gate]") { + morph::model::detail::ActionGate gate; + std::vector order; + + gate.enter([&] { order.emplace_back("a"); }); // takes the gate and keeps it + REQUIRE_FALSE(gate.tryEnter()); + gate.enter([&] { + order.emplace_back("b"); + gate.leave(); + // b is done, but c still waits: a newcomer queues behind it rather + // than starting ahead of it. + REQUIRE_FALSE(gate.tryEnter()); + gate.enter([&] { + order.emplace_back("d"); + gate.leave(); + }); + }); + gate.enter([&] { + order.emplace_back("c"); + gate.leave(); + }); + gate.leave(); + REQUIRE(order == std::vector{"a", "b", "c", "d"}); + + // Drained, the gate starts an action at once again, by either door. + REQUIRE(gate.tryEnter()); + gate.leave(); + bool ran = false; + gate.enter([&] { + ran = true; + gate.leave(); + }); + REQUIRE(ran); +} + +TEST_CASE("dispatchesAsync tells a Task handler from an ordinary one, and dispatchAsync runs either", + "[coroutine][remote]") { + morph::model::detail::ActionDispatcher dispatcher; + dispatcher.registerAction("Coro_Model", "Coro_Double"); + dispatcher.registerAction("Coro_Model", "Coro_Log"); + REQUIRE(dispatcher.dispatchesAsync("Coro_Model", "Coro_Double")); + REQUIRE_FALSE(dispatcher.dispatchesAsync("Coro_Model", "Coro_Log")); + REQUIRE_FALSE(dispatcher.dispatchesAsync("Coro_Model", "Coro_Unknown")); + + // An ordinary handler through dispatchAsync reports at once, and needs no + // strand executor. + auto holder = morph::model::detail::ModelFactory::create(); + std::optional result; + dispatcher.dispatchAsync("Coro_Model", "Coro_Log", *holder, "{}", nullptr, core::async::StopToken{}, + [&](const std::string& json, const std::exception_ptr& error) { + result = error ? std::string{"error"} : json; + }); + REQUIRE(result == "0"); +} + +TEST_CASE("a Task handler's action that fails its validator is rejected before the handler starts", + "[coroutine][model]") { + coro_test::SchedulerScope const timers; + morph::exec::ThreadPoolExecutor pool{2}; + morph::exec::MainThreadExecutor exec; + morph::bridge::Bridge bridge{std::make_unique(pool)}; + morph::bridge::BridgeHandler handler{bridge, &exec}; + + std::exception_ptr failure; + handler.execute(CoroValidated{.x = 0}).then([](int) {}).onError([&](const std::exception_ptr& error) { + failure = error; + }); + REQUIRE(pumpUntil(exec, [&] { return failure != nullptr; })); + REQUIRE(holds(failure)); + + std::optional ready; + handler.execute(CoroValidated{.x = 5}) + .then([&](int value) { ready = value; }) + .onError([](const std::exception_ptr&) {}); + REQUIRE(pumpUntil(exec, [&] { return ready.has_value(); })); + REQUIRE(*ready == 5); +} + +TEST_CASE("a Task handler that throws is journalled as Outcome::Failed, locally and through dispatchAsync", + "[coroutine][model][action_log]") { + coro_test::SchedulerScope const timers; + morph::exec::ThreadPoolExecutor pool{2}; + morph::exec::MainThreadExecutor exec; + auto log = std::make_shared(); + { + morph::bridge::Bridge bridge{std::make_unique(pool)}; + auto binding = std::make_shared(); + binding->typeId = "Coro_Model"; + binding->modelFactory = [log] { + auto holder = morph::model::detail::ModelFactory::create(); + holder->attachActionLog(log, "coro-throws-local"); + return holder; + }; + morph::bridge::BridgeHandler handler{bridge, &exec, binding}; + std::exception_ptr failure; + handler.execute(CoroThrow{}).then([](int) {}).onError([&](const std::exception_ptr& error) { + failure = error; + }); + REQUIRE(pumpUntil(exec, [&] { return failure != nullptr; })); + } + + auto const strands = std::make_shared(pool); + morph::exec::detail::ModelId const key{11}; + auto executor = std::make_shared(strands, key, morph::session::Context{}); + morph::model::detail::ActionDispatcher dispatcher; + dispatcher.registerAction("Coro_Model", "Coro_Throw"); + auto holder = morph::model::detail::ModelFactory::create(); + holder->attachActionLog(log, "coro-throws-remote"); + std::promise outcome; + auto settled = outcome.get_future(); + strands->post(key, [&] { + dispatcher.dispatchAsync( + "Coro_Model", "Coro_Throw", *holder, "{}", executor, core::async::StopToken{}, + [&](const std::string&, const std::exception_ptr& error) { outcome.set_value(error); }); + }); + REQUIRE(settled.wait_for(5s) == std::future_status::ready); + REQUIRE(settled.get() != nullptr); + strands->close(); + + auto const offered = log->offered(); + REQUIRE(offered.size() == 2); + for (auto const& entry : offered) { + REQUIRE(entry.outcome == morph::journal::Outcome::Failed); + REQUIRE(entry.error == "handler failed after a suspension"); + } +} + +TEST_CASE("a Task action queued behind a suspended one does not start once a backend switch failed it", + "[coroutine][model][lifetime]") { + coro_test::SchedulerScope const timers; + morph::exec::ThreadPoolExecutor pool{2}; + morph::exec::MainThreadExecutor exec; + morph::bridge::Bridge bridge{std::make_unique(pool)}; + morph::bridge::BridgeHandler handler{bridge, &exec}; + armHold(&exec); + + coro_test::SwitchedFlag holdSwitched; + coro_test::SwitchedFlag queuedSwitched; + handler.execute(CoroHold{.tag = 1}).then([](int) {}).onError(holdSwitched.handler()); + handler.execute(CoroDouble{.x = 2}).then([](int) {}).onError(queuedSwitched.handler()); + REQUIRE(pumpUntil(exec, [&] { + std::scoped_lock const lock{probe().mtx}; + return !probe().order.empty(); + })); + + coro_test::switchWithin(bridge, pool); + REQUIRE(pumpUntil(exec, [&] { return holdSwitched.seen.load() && queuedSwitched.seen.load(); })); + probe().release->resolve(100); + REQUIRE(pumpUntil(exec, [&] { return probe().holdFinished.load(); })); + exec.runFor(20ms); + // CoroDouble notes its strand first thing: it never started. + std::scoped_lock const lock{probe().mtx}; + REQUIRE(probe().onOwnStrand.empty()); +} + +TEST_CASE("LocalBackend still stops a running Task handler after sweeping many finished ones", + "[coroutine][model][lifetime]") { + coro_test::SchedulerScope const timers; + morph::exec::ThreadPoolExecutor pool{2}; + morph::exec::MainThreadExecutor exec; + morph::bridge::Bridge bridge{std::make_unique(pool)}; + morph::bridge::BridgeHandler handler{bridge, &exec}; + + // More finished Task runs than the backend keeps before sweeping them. + constexpr int quick = 40; + std::atomic finished{0}; + for (int index = 0; index < quick; ++index) { + handler.execute(CoroQuick{.x = index}) + .then([&](int) { finished.fetch_add(1); }) + .onError([](const std::exception_ptr&) {}); + } + REQUIRE(pumpUntil(exec, [&] { return finished.load() == quick; })); + + // The sweep kept what is still running: a switch stops this one. + armHold(&exec); + coro_test::SwitchedFlag holdSwitched; + handler.execute(CoroHold{.tag = 1}).then([](int) {}).onError(holdSwitched.handler()); + REQUIRE(pumpUntil(exec, [&] { + std::scoped_lock const lock{probe().mtx}; + return !probe().order.empty(); + })); + coro_test::switchWithin(bridge, pool); + REQUIRE(pumpUntil(exec, [&] { return probe().holdCancelled.load() && holdSwitched.seen.load(); })); +} + +TEST_CASE("RemoteServer queues an ordinary action behind a suspended Task handler", "[coroutine][model][remote]") { + coro_test::SchedulerScope const timers; + morph::exec::ThreadPoolExecutor pool{2}; + morph::exec::MainThreadExecutor exec; + morph::model::detail::ModelRegistryFactory registry; + morph::model::detail::ActionDispatcher dispatcher; + registry.registerModel("Coro_Model"); + dispatcher.registerAction("Coro_Model", "Coro_Hold"); + dispatcher.registerAction("Coro_Model", "Coro_Log"); + auto server = std::make_shared(pool, dispatcher, registry); + morph::bridge::Bridge bridge{std::make_unique(*server)}; + morph::bridge::BridgeHandler handler{bridge, &exec}; + armHold(&exec); + + std::optional held; + std::optional logged; + handler.execute(CoroHold{.tag = 1}).then([&](int value) { held = value; }).onError([](const std::exception_ptr&) { + }); + handler.execute(CoroLog{.tag = 2}).then([&](int value) { logged = value; }).onError([](const std::exception_ptr&) { + }); + REQUIRE(pumpUntil(exec, [&] { + std::scoped_lock const lock{probe().mtx}; + return !probe().order.empty(); + })); + exec.runFor(20ms); + { + std::scoped_lock const lock{probe().mtx}; + REQUIRE(probe().order == std::vector{"hold-start"}); + } + + probe().release->resolve(100); + REQUIRE(pumpUntil(exec, [&] { return held.has_value() && logged.has_value(); })); + // The wire payloads decode to {}, so both tags are 0. + std::scoped_lock const lock{probe().mtx}; + REQUIRE(probe().order == std::vector{"hold-start", "hold-end", "log-0"}); +} + +TEST_CASE("LocalBackend rejects a call that carries neither localOp nor localOpAsync", "[coroutine][model]") { + morph::exec::ThreadPoolExecutor pool{1}; + morph::exec::MainThreadExecutor exec; + morph::backend::LocalBackend backend{pool}; + auto const mid = backend.registerModel("Coro_Model", morph::model::detail::ModelFactory::create); + + morph::backend::detail::ActionCall call; + call.modelTypeId = "Coro_Model"; + call.actionTypeId = "Coro_Log"; + std::string message; + backend.execute(mid, std::move(call), &exec) + .then([](const std::shared_ptr&) {}) + .onError([&](const std::exception_ptr& error) { + try { + std::rethrow_exception(error); + } catch (const std::exception& exc) { + message = exc.what(); + } + }); + REQUIRE(pumpUntil(exec, [&] { return !message.empty(); })); + REQUIRE(message.contains("localOp is null")); +} + +TEST_CASE("dispatchAsync reports an unknown action through done, not by throwing", "[coroutine][remote]") { + morph::model::detail::ActionDispatcher dispatcher; + auto holder = morph::model::detail::ModelFactory::create(); + std::string message; + dispatcher.dispatchAsync("Coro_Model", "Coro_Unknown", *holder, "{}", nullptr, core::async::StopToken{}, + [&](const std::string&, const std::exception_ptr& error) { + try { + std::rethrow_exception(error); + } catch (const std::exception& exc) { + message = exc.what(); + } + }); + REQUIRE(message == "unknown action: Coro_Model/Coro_Unknown"); +} + +TEST_CASE("ActionGate counts a second thread inside it as an overlap", "[coroutine][gate]") { + morph::model::detail::ActionGate gate; + std::promise inside; + std::promise release; + auto released = release.get_future(); + std::thread holder{[&] { + gate.enter([&] { + inside.set_value(); + // Bounded: the test body releases it right after looking. + static_cast(released.wait_for(5s)); + }); + }}; + REQUIRE(inside.get_future().wait_for(5s) == std::future_status::ready); + + // The holder is still inside enter(); this thread touching the gate now is + // exactly the misuse the counter exists to catch. + auto const before = morph::model::detail::ActionGate::overlapsObserved(); + REQUIRE_FALSE(gate.tryEnter()); + REQUIRE(morph::model::detail::ActionGate::overlapsObserved() > before); + + release.set_value(); + holder.join(); +} diff --git a/tests/test_flows_apps.cpp b/tests/test_flows_apps.cpp index 54793f877..a31b86c8a 100644 --- a/tests/test_flows_apps.cpp +++ b/tests/test_flows_apps.cpp @@ -81,7 +81,7 @@ struct FlowStepExplodesNonStdResult { // how fast the backend would otherwise run it. Used to drive a stale reply // for a step FlowSession has since navigated away from via back() (as // opposed to advance(): both FlowStepOne and FlowStepSlow share one model -// instance, hence one StrandExecutor key -- see strand.hpp -- so a second, +// instance, hence one ModelStrands key -- see strand.hpp -- so a second, // later-queued step's dispatch cannot even *start* until an earlier one // queued ahead of it on the same model returns; back() sidesteps that // entirely, since it touches only FlowSession's own local state and never @@ -590,7 +590,7 @@ TEST_CASE("FlowSession: a late error for a step left behind via back() does not // The test above ("a late error for a step already left behind...") // reads as though it exercises fireStep's `stepIndex == _activeStep` // guard's False arm (line 464's condition), but it does not: FlowStepOne - // and FlowStepTwo share one model instance, hence one StrandExecutor key + // and FlowStepTwo share one model instance, hence one ModelStrands key // (strand.hpp), so every dispatch against that model -- including a // second, independent fire of the *same* step -- runs strictly in // enqueue order, one at a time. The stale "explode" fire there always diff --git a/tests/test_issues.cpp b/tests/test_issues.cpp index 873f98375..e363b4e11 100644 --- a/tests/test_issues.cpp +++ b/tests/test_issues.cpp @@ -7,6 +7,7 @@ #include #include #include +#include #include #include #include @@ -62,18 +63,18 @@ TEST_CASE("Issue 2: attachThen on errored state does not call handler and does n REQUIRE_FALSE(thenFired); } -// ── Issue 4: morph::exec::detail::StrandExecutor map cleaned up after queue drains ───────────────── +// ── Issue 4: morph::exec::detail::ModelStrands map cleaned up after queue drains ───────────────── -TEST_CASE("Issue 4: morph::exec::detail::StrandExecutor works correctly after strand entries are cleaned up", +TEST_CASE("Issue 4: morph::exec::detail::ModelStrands works correctly after strand entries are cleaned up", "[strand][issue4]") { morph::exec::ThreadPoolExecutor pool{2}; - morph::exec::detail::StrandExecutor strand{pool}; + auto const strand = std::make_shared(pool); constexpr int numKeys = 20; std::atomic completed{0}; for (int key = 1; key <= numKeys; ++key) { - strand.post(morph::exec::detail::ModelId{static_cast(key)}, [&] { completed.fetch_add(1); }); + strand->post(morph::exec::detail::ModelId{static_cast(key)}, [&] { completed.fetch_add(1); }); } REQUIRE(waitUntil([&] { return completed.load() == numKeys; })); @@ -83,20 +84,20 @@ TEST_CASE("Issue 4: morph::exec::detail::StrandExecutor works correctly after st // Post again to confirm strand still works after cleanup std::atomic completed2{0}; for (int key = 1; key <= numKeys; ++key) { - strand.post(morph::exec::detail::ModelId{static_cast(key)}, [&] { completed2.fetch_add(1); }); + strand->post(morph::exec::detail::ModelId{static_cast(key)}, [&] { completed2.fetch_add(1); }); } REQUIRE(waitUntil([&] { return completed2.load() == numKeys; })); } -TEST_CASE("Issue 4: morph::exec::detail::StrandExecutor per-key ordering preserved after re-use", "[strand][issue4]") { +TEST_CASE("Issue 4: morph::exec::detail::ModelStrands per-key ordering preserved after re-use", "[strand][issue4]") { morph::exec::ThreadPoolExecutor pool{2}; - morph::exec::detail::StrandExecutor strand{pool}; + auto const strand = std::make_shared(pool); morph::exec::detail::ModelId key{42}; // First batch: drain and clean up std::atomic batch1{0}; for (int task = 0; task < 5; ++task) { - strand.post(key, [&] { batch1.fetch_add(1); }); + strand->post(key, [&] { batch1.fetch_add(1); }); } REQUIRE(waitUntil([&] { return batch1.load() == 5; })); std::this_thread::sleep_for(20ms); @@ -106,7 +107,7 @@ TEST_CASE("Issue 4: morph::exec::detail::StrandExecutor per-key ordering preserv std::mutex orderMtx; std::atomic batch2{0}; for (int taskId = 0; taskId < 5; ++taskId) { - strand.post(key, [&, taskId] { + strand->post(key, [&, taskId] { std::scoped_lock lock{orderMtx}; order.push_back(taskId); batch2.fetch_add(1); diff --git a/tests/test_remote_execute_ordering.cpp b/tests/test_remote_execute_ordering.cpp index d1b8f87f6..08d5ca5c4 100644 --- a/tests/test_remote_execute_ordering.cpp +++ b/tests/test_remote_execute_ordering.cpp @@ -640,7 +640,7 @@ class RejectMarkerAuthorizer : public morph::session::IAuthorizer { /// third ticket already existing. /// /// Only the *arming window* is intercepted: once `n` posts have been captured -/// every later post (notably `StrandExecutor`'s, which `RemoteServer` routes +/// every later post (notably the strands', which `RemoteServer` routes /// through this same executor) passes straight through. class HoldNextPostsExecutor : public morph::exec::IExecutor { public: diff --git a/tests/test_remote_step_interleaving.cpp b/tests/test_remote_step_interleaving.cpp index 05291c1eb..a495f7294 100644 --- a/tests/test_remote_step_interleaving.cpp +++ b/tests/test_remote_step_interleaving.cpp @@ -3,7 +3,7 @@ // Covers the public deterministic-interleaving-harness seam for `RemoteServer` // hand-stepping `RemoteServer`'s real per-model // ordering via `morph::testing::StepExecutor`, without naming -// `morph::exec::detail::StrandExecutor` or `morph::exec::detail::ModelId`. +// `morph::exec::detail::ModelStrands` or `morph::exec::detail::ModelId`. #include #include diff --git a/tests/test_strand.cpp b/tests/test_strand.cpp index b2f0cbd38..5761a4681 100644 --- a/tests/test_strand.cpp +++ b/tests/test_strand.cpp @@ -1,69 +1,568 @@ // SPDX-License-Identifier: Apache-2.0 +#include +#include #include #include #include +#include +#include +#include +#include +#include +#include +#include #include +#include +#include +#include #include #include -TEST_CASE("morph::exec::detail::StrandExecutor serialises tasks for the same key", "[strand]") { +#include "test_support.hpp" + +// morph's strands are core-cpp's `KeyedStrands`, whose ordering and +// one-strand-per-key invariants core-cpp tests (`Strand_test.cpp`, +// `KeyedStrands_test.cpp`). What these cases pin is what `ModelStrands` adds: +// the base adapter, a posted task's throw logged rather than propagated, +// `runOnStrand`, `drain`, and the Task handler's resumer with the session. + +namespace { + +using morph::exec::detail::ModelId; +using morph::exec::detail::ModelStrands; +using morph::exec::detail::TaskResumer; + +/// Collects every message logged while it lives. +class CapturedLog { +public: + CapturedLog() + : _override{[this](morph::log::LogLevel /*level*/, std::string_view message) { + std::scoped_lock const lock{_mtx}; + _messages.emplace_back(message); + }} {} + + [[nodiscard]] bool contains(std::string_view needle) { + std::scoped_lock const lock{_mtx}; + return std::ranges::any_of(_messages, + [needle](const std::string& message) { return message.contains(needle); }); + } + +private: + std::mutex _mtx; + std::vector _messages; + morph::log::ScopedLoggerOverride _override; +}; + +/// A coroutine that only records where each of its resumptions ran. +struct Probe { + struct promise_type { + Probe get_return_object() { return Probe{std::coroutine_handle::from_promise(*this)}; } + std::suspend_always initial_suspend() noexcept { return {}; } + std::suspend_always final_suspend() noexcept { return {}; } + void return_void() {} + void unhandled_exception() { std::terminate(); } + }; + + explicit Probe(std::coroutine_handle frame) : handle{frame} {} + Probe(const Probe&) = delete; + Probe& operator=(const Probe&) = delete; + Probe(Probe&&) = delete; + Probe& operator=(Probe&&) = delete; + ~Probe() { handle.destroy(); } + + std::coroutine_handle handle; +}; + +struct Seen { + std::string principal; + bool onStrand = false; + bool resumerCurrent = false; +}; + +Probe record(const ModelStrands* strands, ModelId key, const TaskResumer* resumer, Seen* seen) { + seen->principal = morph::session::current() != nullptr ? morph::session::current()->principal : ""; + seen->onStrand = strands->runningHere(key); + seen->resumerCurrent = core::async::currentExecutor() == resumer; + co_return; +} + +} // namespace + +TEST_CASE("ModelStrands serialises tasks for the same key, in order", "[strand]") { morph::exec::ThreadPoolExecutor pool{4}; - morph::exec::detail::ModelId key{1}; + auto const strands = std::make_shared(pool); + ModelId const key{1}; std::atomic concurrent{0}; std::atomic maxConcurrent{0}; - std::atomic completed{0}; - constexpr int numTasks = 20; + std::vector order; + constexpr int numTasks = 50; - // Scoped so ~StrandExecutor's own _inFlight == 0 wait (strand.hpp) is the - // drain, not a fixed-iteration poll: every queued task has run by the time - // this block exits, with no dependence on how fast the host runs them - // rather than on a sleep long enough to have probably finished. - { - morph::exec::detail::StrandExecutor strand{pool}; - for (int i = 0; i < numTasks; ++i) { - strand.post(key, [&] { - int cnt = concurrent.fetch_add(1) + 1; - int prev = maxConcurrent.load(); - while (cnt > prev && !maxConcurrent.compare_exchange_weak(prev, cnt)) { - } - std::this_thread::sleep_for(std::chrono::milliseconds(2)); - concurrent.fetch_sub(1); - completed.fetch_add(1); - }); - } + for (int i = 0; i < numTasks; ++i) { + strands->post(key, [&, i] { + int const cnt = concurrent.fetch_add(1) + 1; + int prev = maxConcurrent.load(); + while (cnt > prev && !maxConcurrent.compare_exchange_weak(prev, cnt)) { + } + // Touched only on the key's strand, so it needs no lock. + order.push_back(i); + std::this_thread::sleep_for(std::chrono::microseconds(200)); + concurrent.fetch_sub(1); + }); } + // The drain is the wait: every queued task has run when it returns. + strands->drain(); - REQUIRE(completed.load() == numTasks); - REQUIRE(maxConcurrent.load() == 1); // never more than 1 at a time for same key + REQUIRE(maxConcurrent.load() == 1); + REQUIRE(order.size() == static_cast(numTasks)); + for (int i = 0; i < numTasks; ++i) { + CHECK(order[static_cast(i)] == i); + } } -TEST_CASE("morph::exec::detail::StrandExecutor runs tasks for different keys concurrently", "[strand]") { +TEST_CASE("ModelStrands runs tasks for different keys concurrently", "[strand]") { morph::exec::ThreadPoolExecutor pool{4}; + auto const strands = std::make_shared(pool); std::atomic concurrent{0}; std::atomic maxConcurrent{0}; std::atomic completed{0}; constexpr int numKeys = 4; - // Scoped so ~StrandExecutor's own _inFlight == 0 wait (strand.hpp) is the - // drain -- see the "same key" case above for why this replaces the poll. - { - morph::exec::detail::StrandExecutor strand{pool}; - for (int i = 0; i < numKeys; ++i) { - strand.post(morph::exec::detail::ModelId{static_cast(i + 1)}, [&] { - int cnt = concurrent.fetch_add(1) + 1; - int prev = maxConcurrent.load(); - while (cnt > prev && !maxConcurrent.compare_exchange_weak(prev, cnt)) { - } - std::this_thread::sleep_for(std::chrono::milliseconds(30)); - concurrent.fetch_sub(1); - completed.fetch_add(1); - }); - } + for (int i = 0; i < numKeys; ++i) { + strands->post(ModelId{static_cast(i + 1)}, [&] { + int const cnt = concurrent.fetch_add(1) + 1; + int prev = maxConcurrent.load(); + while (cnt > prev && !maxConcurrent.compare_exchange_weak(prev, cnt)) { + } + std::this_thread::sleep_for(std::chrono::milliseconds(30)); + concurrent.fetch_sub(1); + completed.fetch_add(1); + }); } + strands->drain(); REQUIRE(completed.load() == numKeys); - REQUIRE(maxConcurrent.load() > 1); // different keys ran in parallel + REQUIRE(maxConcurrent.load() > 1); +} + +TEST_CASE("ModelStrands: independent keys each run exactly their own tasks", "[strand]") { + morph::exec::ThreadPoolExecutor pool{4}; + auto const strands = std::make_shared(pool); + + constexpr std::size_t numKeys = 3; + constexpr int tasksPerKey = 5; + std::array, numKeys> results{}; + + for (std::size_t key = 0; key < numKeys; ++key) { + for (int task = 0; task < tasksPerKey; ++task) { + strands->post(ModelId{key + 1}, [&results, key] { results.at(key).fetch_add(1); }); + } + } + strands->drain(); + + for (auto const& result : results) { + REQUIRE(result.load() == tasksPerKey); + } + REQUIRE(strands->idle()); +} + +TEST_CASE("ModelStrands: a task's throw is logged and the next task still runs", "[strand]") { + CapturedLog log; + morph::exec::ThreadPoolExecutor pool{2}; + auto const strands = std::make_shared(pool); + ModelId const key{42}; + + std::atomic afterRan{false}; + strands->post(key, [] { throw std::runtime_error("strand bomb"); }); + strands->post(key, [&] { afterRan.store(true); }); + strands->drain(); + + REQUIRE(afterRan.load()); + CHECK(log.contains("[strand] task threw: strand bomb")); +} + +TEST_CASE("ModelStrands: a non-std::exception throw is swallowed and the next task still runs", "[strand]") { + CapturedLog log; + morph::exec::ThreadPoolExecutor pool{2}; + auto const strands = std::make_shared(pool); + ModelId const key{43}; + + std::atomic afterRan{false}; + strands->post(key, [] { throw 7; }); // NOLINT(hicpp-exception-baseclass) — exercises the catch(...) arm + strands->post(key, [&] { afterRan.store(true); }); + strands->drain(); + + REQUIRE(afterRan.load()); + CHECK(log.contains("[strand] task threw unknown exception")); +} + +TEST_CASE("ModelStrands::runOnStrand runs inline on the strand, posts off it, and runs inline once closed", + "[strand]") { + morph::exec::ThreadPoolExecutor pool{2}; + auto const strands = std::make_shared(pool); + ModelId const key{5}; + auto const caller = std::this_thread::get_id(); + + std::thread::id offStrand; + bool offStrandWasOnStrand = false; + strands->runOnStrand(key, [&] { + offStrand = std::this_thread::get_id(); + offStrandWasOnStrand = strands->runningHere(key); + }); + strands->drain(); + CHECK(offStrand != caller); + CHECK(offStrandWasOnStrand); + + bool nestedInline = false; + strands->post(key, [&] { + bool ranBeforeReturn = false; + strands->runOnStrand(key, [&] { ranBeforeReturn = true; }); + nestedInline = ranBeforeReturn; + }); + strands->drain(); + CHECK(nestedInline); + + strands->close(); + std::thread::id afterClose; + strands->runOnStrand(key, [&] { afterClose = std::this_thread::get_id(); }); + CHECK(afterClose == caller); + + bool posted = false; + strands->post(key, [&] { posted = true; }); + CHECK_FALSE(posted); +} + +TEST_CASE("ModelStrands: runningHere and runningAnyHere answer inside a task only", "[strand]") { + morph::exec::ThreadPoolExecutor pool{2}; + auto const strands = std::make_shared(pool); + ModelId const key{8}; + ModelId const other{9}; + + bool here = false; + bool otherHere = true; + bool anyHere = false; + strands->post(key, [&] { + here = strands->runningHere(key); + otherHere = strands->runningHere(other); + anyHere = strands->runningAnyHere(); + }); + strands->drain(); + + CHECK(here); + CHECK_FALSE(otherHere); + CHECK(anyHere); + CHECK_FALSE(strands->runningAnyHere()); +} + +TEST_CASE("TaskResumer resumes on its key's strand with the session installed, and inline once closed", + "[strand][coroutine]") { + morph::exec::ThreadPoolExecutor pool{2}; + auto const strands = std::make_shared(pool); + ModelId const key{11}; + morph::session::Context session; + session.principal = "alice"; + auto const resumer = std::make_shared(strands, key, session); + strands->enroll(key, resumer); + + SECTION("a submit through the strand, as an awaitable that parked there does") { + Seen seen; + Probe const probe = record(strands.get(), key, resumer.get(), &seen); + resumer->submit(probe.handle); + strands->drain(); + CHECK(probe.handle.done()); + CHECK(seen.principal == "alice"); + CHECK(seen.onStrand); + CHECK(seen.resumerCurrent); + } + + SECTION("a callable posted to an enrolled key runs outside the resumer; a resumption runs inside it") { + // onBackendChanged, an action queued behind the handler: neither is + // the handler, so neither runs under its session (core-cpp#53). + std::string principal = ""; + bool resumerCurrent = true; + strands->post(key, [&] { + principal = morph::session::current() != nullptr ? morph::session::current()->principal : ""; + resumerCurrent = core::async::currentExecutor() == resumer.get(); + }); + strands->drain(); + CHECK(principal == ""); + CHECK_FALSE(resumerCurrent); + + // A coroutine resumed on the key -- an awaitable that parked on the + // strand itself, not through the resumer -- runs inside it. + Seen seen; + Probe const probe = record(strands.get(), key, resumer.get(), &seen); + REQUIRE(strands->trySubmit(key, probe.handle)); + strands->drain(); + CHECK(probe.handle.done()); + CHECK(seen.principal == "alice"); + CHECK(seen.resumerCurrent); + } + + SECTION("a resumption of an enrolled key runs inside the resumer until it is withdrawn") { + // A withdraw naming another resumer leaves the enrolment alone. + auto const other = std::make_shared(strands, key, morph::session::Context{}); + strands->withdraw(key, other.get()); + Seen stillEnrolled; + Probe const first = record(strands.get(), key, resumer.get(), &stillEnrolled); + REQUIRE(strands->trySubmit(key, first.handle)); + strands->drain(); + CHECK(stillEnrolled.principal == "alice"); + CHECK(stillEnrolled.resumerCurrent); + + strands->withdraw(key, resumer.get()); + Seen withdrawn; + Probe const second = record(strands.get(), key, resumer.get(), &withdrawn); + REQUIRE(strands->trySubmit(key, second.handle)); + strands->drain(); + CHECK(second.handle.done()); + CHECK(withdrawn.principal.empty()); + CHECK_FALSE(withdrawn.resumerCurrent); + } + + SECTION("once closed, a submit resumes inline, still inside the resumer") { + strands->close(); + Seen seen; + Probe const probe = record(strands.get(), key, resumer.get(), &seen); + resumer->submit(probe.handle); + CHECK(probe.handle.done()); + CHECK(seen.principal == "alice"); + CHECK_FALSE(seen.onStrand); + CHECK(seen.resumerCurrent); + } + + strands->withdraw(key, resumer.get()); +} + +namespace { + +/// Parks the awaiting coroutine and hands its `ParkedWork` -- with the claim a +/// `DetachedTask` chain carries -- to the test, as `AsyncQueue::pop` hands it +/// to the executor it parked on. +struct ParkInto { + core::async::ParkedWork* out; + [[nodiscard]] bool await_ready() const noexcept { return false; } + template + void await_suspend(std::coroutine_handle awaiting) const { + *out = core::async::detail::parkedWorkFor(awaiting); + } + void await_resume() const noexcept {} +}; + +core::async::DetachedTask parkOnce(core::async::ParkedWork* parked, std::atomic* finished) { + co_await ParkInto{parked}; + finished->store(true); +} + +} // namespace + +TEST_CASE("TaskResumer keeps a detached chain's claim until it resumes it, on the strand or inline", + "[strand][coroutine]") { + morph::exec::ThreadPoolExecutor pool{2}; + auto const strands = std::make_shared(pool); + ModelId const key{13}; + auto const resumer = std::make_shared(strands, key, morph::session::Context{}); + + SECTION("on the strand") { + core::async::ParkedWork parked; + std::atomic finished{false}; + parkOnce(&parked, &finished); + REQUIRE(parked.abandon.armed()); + // The claim travels with the handle: dropping it here would free the + // frame while its handle is queued. + resumer->submit(std::move(parked)); + strands->drain(); + CHECK(finished.load()); + } + + SECTION("inline, once the strands are closed") { + strands->close(); + core::async::ParkedWork parked; + std::atomic finished{false}; + parkOnce(&parked, &finished); + REQUIRE(parked.abandon.armed()); + resumer->submit(std::move(parked)); + CHECK(finished.load()); + } +} + +TEST_CASE("ModelStrands teardown: a resumption or a handler's end arriving after the drain runs inline", + "[strand][coroutine][lifetime]") { + morph::exec::ThreadPoolExecutor pool{2}; + auto const strands = std::make_shared(pool); + ModelId const key{21}; + auto const resumer = std::make_shared(strands, key, morph::session::Context{}); + + // Teardown's steps, stopped between the drain and the close: work that + // arrives here and is queued would be dropped by the close. + strands->seal(); + strands->drain(); + + Seen seen; + Probe const probe = record(strands.get(), key, resumer.get(), &seen); + resumer->submit(probe.handle); + CHECK(probe.handle.done()); + CHECK_FALSE(seen.onStrand); + CHECK(seen.resumerCurrent); + + auto const caller = std::this_thread::get_id(); + std::thread::id finishedOn; + strands->runOnStrand(key, [&] { finishedOn = std::this_thread::get_id(); }); + CHECK(finishedOn == caller); + + strands->close(); +} + +TEST_CASE("ModelStrands teardown: after the seal, queued work runs and a handler's end is refused and runs inline", + "[strand][lifetime]") { + morph::exec::MainThreadExecutor exec; + auto const strands = std::make_shared(exec); + ModelId const key{22}; + bool ran = false; + strands->post(key, [&] { ran = true; }); + strands->seal(); + // runOnStrand's post is a try-form, which the seal refuses: the end runs + // here, before anything is pumped, rather than behind the queued work. + bool endedInline = false; + strands->runOnStrand(key, [&] { endedInline = !ran; }); + CHECK(endedInline); + exec.drain(); + CHECK(ran); + strands->close(); +} + +TEST_CASE("ModelStrands teardown, sealing first: a handler stopped with nothing to run its strand unwinds inline", + "[strand][coroutine][lifetime]") { + // The single-threaded build's situation on a native one: the executor is + // pumped by the thread that tears the strands down, which is busy doing so. + morph::exec::MainThreadExecutor exec; + auto const strands = std::make_shared(exec); + ModelId const key{23}; + auto const resumer = std::make_shared(strands, key, morph::session::Context{}); + Seen seen; + Probe const probe = record(strands.get(), key, resumer.get(), &seen); + + // The stop resumes the suspended handler through its resumer, as a + // stop-aware awaiter does. Bounded: were the resumption queued on the + // executor nobody pumps, the drain would wait for it for ever, so the test + // pumps it itself after the bound and fails. + std::atomic tornDown{false}; + std::thread teardown{[&] { + strands->teardown([&] { resumer->submit(probe.handle); }, morph::exec::detail::TeardownOrder::SealThenStop); + tornDown = true; + }}; + bool const inTime = morph::testing::waitUntil([&] { return tornDown.load(); }); + while (!tornDown.load()) { + exec.runOnce(); + } + teardown.join(); + CHECK(inTime); + CHECK(probe.handle.done()); + CHECK_FALSE(seen.onStrand); + CHECK(seen.resumerCurrent); +} + +TEST_CASE("ModelStrands teardown: a handler's end arriving after the seal never overlaps its key's queued gate entry", + "[strand][coroutine][lifetime]") { + // A Task handler holds its model's action gate, parked on another + // executor -- a core::net socket's loop -- and a failed call's task is + // queued behind it on the same strand, where it tries the gate. The + // teardown stops the handler, whose end arrives from the loop thread once + // the strands are sealed, so it is refused and runs inline there. Were the + // failed call's task still running on a pool thread then, two threads + // would be inside one gate: the overlap counter counts that, and TSan + // reports the race on the gate's state. The teardown drains before it + // seals, so the task has finished by then. + using morph::model::detail::ActionGate; + ActionGate gate; + morph::exec::ThreadPoolExecutor pool{2}; + auto const strands = std::make_shared(pool); + ModelId const key{24}; + ModelId const probeKey{25}; + auto const overlapsBefore = ActionGate::overlapsObserved(); + + std::atomic nextStarted{false}; + std::atomic nextSawFailedCall{false}; + std::atomic failedCallDone{false}; + std::atomic handlerHolds{false}; + strands->post(key, [&] { + handlerHolds = gate.tryEnter(); + // The action queued behind the handler, which the handler's leave() + // starts. It stays inside that leave() until the failed call is done. + gate.enter([&] { + nextStarted = true; + nextSawFailedCall = morph::testing::waitUntil([&] { return failedCallDone.load(); }); + }); + }); + std::atomic failedCallStarted{false}; + strands->post(key, [&] { + failedCallStarted = true; + // Holds the task open for as long as the handler's end may take to + // arrive: through it, when the end runs beside the task; to the budget, + // when the end is queued behind it. + (void)morph::testing::waitUntil([&] { return nextStarted.load(); }, + morph::testing::WaitBudget{std::chrono::milliseconds{500}}); + if (!gate.tryEnter()) { + gate.enter([] {}); + } + failedCallDone = true; + }); + REQUIRE(morph::testing::waitUntil([&] { return failedCallStarted.load(); })); + REQUIRE(handlerHolds.load()); + + // The loop thread: once the strands are sealed, the handler's end. + // A probe's runOnStrand runs inline on the loop thread only once the seal + // refuses its post. One probe is out at a time, at a slow cadence, so the + // drain before the seal finds the strands idle between probes instead of + // racing a post per poll. + std::atomic sealSeen{false}; + std::atomic probeOut{false}; + std::thread loop; + strands->teardown([&] { + loop = std::thread{[&] { + auto const self = std::this_thread::get_id(); + (void)morph::testing::waitUntil( + [&] { + if (!probeOut.exchange(true)) { + strands->runOnStrand(probeKey, [&] { + if (std::this_thread::get_id() == self) { + sealSeen = true; + } + probeOut = false; + }); + } + return sealSeen.load(); + }, + morph::testing::WaitBudget{std::chrono::milliseconds{5000}}, + morph::testing::WaitStep{std::chrono::milliseconds{10}}); + strands->runOnStrand(key, [&] { gate.leave(); }); + }}; + }); + loop.join(); + + CHECK(sealSeen.load()); + CHECK(nextStarted.load()); + CHECK(nextSawFailedCall.load()); + CHECK(failedCallDone.load()); + CHECK(ActionGate::overlapsObserved() == overlapsBefore); +} + +// Regression test for ThreadPoolExecutor(0): a zero-worker pool used to accept +// tasks that could never run, hanging every post() forever. The constructor now +// clamps the worker count to at least 1, so a pool built with 0 is still usable. +TEST_CASE("ThreadPoolExecutor(0) yields a usable pool", "[executor]") { + std::atomic ran{false}; + + // Scoped so ~ThreadPoolExecutor's own drain-before-join (executor.hpp's own + // doc comment on it) is the wait, not a fixed-iteration poll: the posted + // task is queued before this block ends, so the destructor's join is + // guaranteed not to return until it has run. + { + morph::exec::ThreadPoolExecutor pool{0}; + pool.post([&] { ran.store(true); }); + } + + REQUIRE(ran.load()); } diff --git a/tests/test_strand_extra.cpp b/tests/test_strand_extra.cpp deleted file mode 100644 index 7ed8d12ad..000000000 --- a/tests/test_strand_extra.cpp +++ /dev/null @@ -1,110 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -#include -#include -#include -#include -#include -#include -#include - -TEST_CASE("morph::exec::detail::StrandExecutor: exception in task is swallowed and next task still runs", "[strand]") { - morph::exec::ThreadPoolExecutor pool{2}; - morph::exec::detail::StrandExecutor strand{pool}; - morph::exec::detail::ModelId key{42}; - - std::atomic afterRan{false}; - - strand.post(key, [] { throw std::runtime_error("strand bomb"); }); - strand.post(key, [&] { afterRan.store(true); }); - - for (int i = 0; i < 50 && !afterRan.load(); ++i) { - std::this_thread::sleep_for(std::chrono::milliseconds(10)); - } - REQUIRE(afterRan.load()); -} - -TEST_CASE("morph::exec::detail::StrandExecutor: non-std::exception throw is swallowed and next task still runs", - "[strand]") { - morph::exec::ThreadPoolExecutor pool{2}; - morph::exec::detail::StrandExecutor strand{pool}; - morph::exec::detail::ModelId key{43}; - - std::atomic afterRan{false}; - - strand.post(key, [] { throw 7; }); // NOLINT(hicpp-exception-baseclass) — exercises the catch(...) arm - strand.post(key, [&] { afterRan.store(true); }); - - for (int i = 0; i < 50 && !afterRan.load(); ++i) { - std::this_thread::sleep_for(std::chrono::milliseconds(10)); - } - REQUIRE(afterRan.load()); -} - -TEST_CASE("morph::exec::detail::StrandExecutor: rapid post to running strand queues correctly", "[strand]") { - // Post many tasks to same key without waiting — exercises the "already running" branch - morph::exec::ThreadPoolExecutor pool{4}; - morph::exec::detail::StrandExecutor strand{pool}; - morph::exec::detail::ModelId key{7}; - - constexpr int numTasks = 50; - std::atomic completed{0}; - std::vector order; - std::mutex orderMtx; - - for (int i = 0; i < numTasks; ++i) { - strand.post(key, [&, i] { - { - std::scoped_lock lock{orderMtx}; - order.push_back(i); - } - completed.fetch_add(1); - }); - } - - for (int i = 0; i < 200 && completed.load() < numTasks; ++i) { - std::this_thread::sleep_for(std::chrono::milliseconds(5)); - } - - REQUIRE(completed.load() == numTasks); - // Tasks for same key must arrive in submission order - REQUIRE(order.size() == static_cast(numTasks)); - for (int i = 0; i < numTasks; ++i) { - REQUIRE(order[static_cast(i)] == i); - } -} - -TEST_CASE("morph::exec::detail::StrandExecutor: independent keys each run exactly their own tasks", "[strand]") { - morph::exec::ThreadPoolExecutor pool{4}; - morph::exec::detail::StrandExecutor strand{pool}; - - constexpr int numKeys = 3; - constexpr int tasksPerKey = 5; - std::array, numKeys> results{}; - - constexpr auto numKeysZ = static_cast(numKeys); - constexpr auto tasksPerKeyZ = static_cast(tasksPerKey); - - for (std::size_t key = 0; key < numKeysZ; ++key) { - for (std::size_t task = 0; task < tasksPerKeyZ; ++task) { - strand.post(morph::exec::detail::ModelId{key + 1}, [&results, key] { results[key].fetch_add(1); }); - } - } - - for (int i = 0; i < 100; ++i) { - bool done = true; - for (std::size_t key = 0; key < numKeysZ; ++key) { - if (results[key].load() < tasksPerKey) { - done = false; - } - } - if (done) { - break; - } - std::this_thread::sleep_for(std::chrono::milliseconds(5)); - } - - for (std::size_t key = 0; key < numKeysZ; ++key) { - REQUIRE(results[key].load() == tasksPerKey); - } -} diff --git a/tests/test_strand_race.cpp b/tests/test_strand_race.cpp deleted file mode 100644 index 6e9489c35..000000000 --- a/tests/test_strand_race.cpp +++ /dev/null @@ -1,614 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include - -// Regression test for the per-key serialisation race in StrandExecutor. -// -// The bug: post() captured the strand under _mapMtx, released _mapMtx, then -// re-armed the strand under only strand->mtx. A concurrent drain in -// scheduleNext (holding {_mapMtx, strand->mtx}) could see the pending queue -// empty, clear running, and erase the strand from the map in the window after -// post() released _mapMtx but before it re-armed. The re-armed strand was then -// orphaned: a later post(key) created a *second* strand for the same key, and -// both strands dispatched tasks for that key concurrently — breaking the -// per-model serialisation guarantee. -// -// This file holds three cases, and they cover different things. This first one is -// a *load* test: it hammers post() on a single key from many threads, and every -// task bumps a per-key in-flight counter on entry and drops it on exit, so if -// two tasks for the same key ever run concurrently the counter exceeds 1 and the -// test fails. -// -// What it does **not** cover is the drain-and-erase boundary the bug above lives -// on. The erase only fires when a drain finds the pending queue empty, and eight -// threads posting 3200 tasks back-to-back onto one key keep it non-empty almost -// throughout: the quiet moment the defect needs never arrives. Measured, not -// assumed -- with the pre-fix two-step drain restored in `scheduleNext` (flip -// `running` under `strand->mtx`, release it, then erase under `_mapMtx` in a -// separate critical section), this case passed 10/10 under ThreadSanitizer on -// x86-64 Linux / clang 22.1.8. Short tasks maximise *re-arm*; they do -// not produce a drain. Turning the thread or post counts up makes that worse, -// not better. -// -// The second case below produces the shape this one cannot, and is the one that -// fails against that mutant. The third covers the node the drain now recycles -// which neither of the first two can be wrong about. Keep all -// three: saturation, the drain boundary, and the recycled node's key are -// different failure modes of the same invariant. -namespace { - -// ── The drain's diagnostic, and why it is a watchdog and not a deadline ────── -// -// This case has been observed hanging under ThreadSanitizer and killed by -// ctest's 120 s TIMEOUT having printed nothing but the Catch2 banner: no -// assertion, no TSan report, no reason. That observation is weak and stays -// weak -- 1 of 3 full-suite runs, 0 of 40 isolated, and this lane did not -// reproduce it. -// -// The *structural* half of the issue is checkable by reading, and it holds. -// `~StrandExecutor` waits on -// `_cv.wait(lock, [this] { return _inFlight == 0; })` -// (`include/morph/core/strand.hpp:64-66`) with no timeout, and the strand is -// scoped so that this wait *is* the drain. -// -// One correction to the issue's framing, because it decides the remedy. The -// pre-#374 `2000 x 1 ms` budget was never a bound on the hang: -// `~StrandExecutor` was unbounded then too and ran at the end of every -// iteration regardless, so a lost wakeup hung the pre-#374 binary just as -// thoroughly. What that budget bounded was the time to the *first diagnostic* -// -- a failed `REQUIRE` naming `completed` against `kExpected`, printed before -// the same unbounded wait was entered. A deadline there does not create the -// hang; it is the only thing that speaks before it. -// -// So restoring a deadline would be the wrong remedy twice over: it would not -// bound the hang, and it would re-introduce -// a `REQUIRE` about how fast the host is, evaluated before the invariant this -// file exists for. What is restored below is the diagnostic with no verdict -// attached: a watchdog thread that says where the case is and whether it is -// still moving, and that fails nothing. A slow host prints a few lines and -// still passes. A wedged one prints the same lines with `completed` frozen, -// and the ctest timeout that follows carries the evidence it used to lack. -// -// The `+N since the last report` field is the whole point: it separates "this -// host is slow" from "this strand is stuck", which is the one thing a bare -// timeout cannot determine about an observed hang. -// -// The period is `MORPH_STRAND_DRAIN_WATCHDOG_MS`, default 10000. Ten seconds -// against a 0.4 s median for the whole case leaves about eleven reports inside -// ctest's 120 s TIMEOUT, and the override is how the diagnostic is -// demonstrated without waiting for a hang that may never come back. -class DrainWatchdog { -public: - /// @brief What the watchdog reads. Every field is written by the test - /// thread and read by the watchdog thread, so all of it is atomic. - struct State { - std::atomic* completed; - std::atomic* inFlight; - std::atomic* maxInFlight; - std::atomic phase{"starting"}; - int iteration{0}; - int expected{0}; - }; - - /// @brief Arms the watchdog. @p state must outlive it. - explicit DrainWatchdog(State& state) : _state{&state}, _thread{[this] { run(); }} {} - - DrainWatchdog(const DrainWatchdog&) = delete; - DrainWatchdog(DrainWatchdog&&) = delete; - DrainWatchdog& operator=(const DrainWatchdog&) = delete; - DrainWatchdog& operator=(DrainWatchdog&&) = delete; - - /// @brief Disarms and joins. Declared *before* the strand in the scope - /// below, so reverse destruction order keeps it running across - /// `~StrandExecutor` -- which is the wait it exists to report on. - ~DrainWatchdog() { - { - const std::scoped_lock lock{_mtx}; - _stop = true; - } - _cv.notify_all(); - _thread.join(); - } - -private: - static std::chrono::milliseconds period() { - constexpr std::chrono::milliseconds kDefault{10000}; - // NOLINTNEXTLINE(concurrency-mt-unsafe) - const char* raw = std::getenv("MORPH_STRAND_DRAIN_WATCHDOG_MS"); - if (raw == nullptr) { - return kDefault; - } - const long parsed = std::strtol(raw, nullptr, 10); - return parsed > 0 ? std::chrono::milliseconds{parsed} : kDefault; - } - - void run() { - const auto tick = period(); - const auto armed = std::chrono::steady_clock::now(); - int previous = _state->completed->load(); - std::unique_lock lock{_mtx}; - while (!_cv.wait_for(lock, tick, [this] { return _stop; })) { - const int current = _state->completed->load(); - const auto elapsed = - std::chrono::duration_cast(std::chrono::steady_clock::now() - armed).count(); - // Straight to `std::cerr`, never through Catch2: this runs on a - // second thread, where Catch2's macros are not safe to call, and - // the point is to emit something even when the process is about to - // be killed. Flushed per line so a SIGKILL cannot eat half a report. - std::cerr << "strand-race watchdog: iteration " << _state->iteration << ", phase '" << _state->phase.load() - << "', " << elapsed << " s into the iteration, completed " << current << "/" << _state->expected - << " (+" << (current - previous) << " since the last report), inFlight " - << _state->inFlight->load() << ", maxInFlight " << _state->maxInFlight->load() << '\n' - << std::flush; - previous = current; - } - } - - State* _state; - std::mutex _mtx; - std::condition_variable _cv; - bool _stop{false}; - std::thread _thread; -}; - -} // namespace - -// `[slow]` is what gives this case its own ctest `TIMEOUT`; see -// tests/CMakeLists.txt, where the tag is excluded from the blanket 120 s and -// registered again with a budget sized from this case's measured loaded -// runtime. It is a *scheduling* budget, not a performance one -- see the note -// on `kIterations` below. -TEST_CASE("StrandExecutor never runs two tasks for one key concurrently under contention", "[strand][race][slow]") { - constexpr int kThreads = 8; - constexpr int kPostsPerThread = 400; - // Detection power, not a duration. Each iteration is one fresh - // pool/strand pair sampling the drain-and-re-arm interleaving once; twenty - // of them is how often this case gets to observe it. Cutting this number - // is the cheap way to fit a timeout and it makes the case worse at the one - // thing it exists for, so the budget lives on the ctest entry instead. - // - // What the case actually costs is set by the *scheduler*, not by the work: - // the strand serialises `kThreads * kPostsPerThread` tasks, and each - // handoff is a wakeup that has to wait its turn on the run queue. Measured - // here, 12 cores, clang 22.1.8 Release, synthetic spin-loop load, whole - // case wall clock: - // - // run queue 1 (idle) -> 0.14 s - // run queue 14 -> 23.8 s - // run queue 27 -> 170.5 s - // run queue 38 -> 396.4 s - // - // Steeply superlinear in the oversubscription ratio, and the serialisation - // invariant held in every one of those runs -- `inFlight 1, maxInFlight 1` - // throughout, 40 assertions passed. A host busy enough will still exceed - // any fixed ceiling; that is a property of the measurement, not a defect - // this case can assert its way out of. - constexpr int kIterations = 20; - - morph::exec::detail::ModelId const key{42}; - - constexpr int kExpected = kThreads * kPostsPerThread; - - for (int iter = 0; iter < kIterations; ++iter) { - // Declared outside the strand's scope so it is destroyed *after* the - // strand -- docs/spec/concurrency_and_lifetimes.md, "base IExecutor - // must outlive its StrandExecutor". The reverse order deadlocks. - morph::exec::ThreadPoolExecutor pool{4}; - - // Outlive the strand too: the drain below runs their final updates. - std::atomic inFlight{0}; - std::atomic maxInFlight{0}; - std::atomic completed{0}; - - auto task = [&] { - int const cur = inFlight.fetch_add(1) + 1; - int prev = maxInFlight.load(); - while (cur > prev && !maxInFlight.compare_exchange_weak(prev, cur)) { - } - // Tiny window so drains and re-arms interleave heavily. - std::this_thread::yield(); - inFlight.fetch_sub(1); - completed.fetch_add(1); - }; - - DrainWatchdog::State watched{.completed = &completed, - .inFlight = &inFlight, - .maxInFlight = &maxInFlight, - .phase = {"posting"}, - .iteration = iter, - .expected = kExpected}; - - { - // Declared before the strand, so reverse destruction order keeps - // it alive across `~StrandExecutor` -- the unbounded wait it - // exists to report on. See the note above `DrainWatchdog`. - const DrainWatchdog watchdog{watched}; - - morph::exec::detail::StrandExecutor strand{pool}; - - std::vector producers; - producers.reserve(kThreads); - for (int t = 0; t < kThreads; ++t) { - producers.emplace_back([&] { - for (int i = 0; i < kPostsPerThread; ++i) { - strand.post(key, task); - } - }); - } - watched.phase.store("joining producers"); - for (auto& producer : producers) { - producer.join(); - } - watched.phase.store("draining (~StrandExecutor)"); - // Every producer has joined, so nothing else will post -- which is - // also what the spec's "no post() may race or follow - // ~StrandExecutor" corollary requires. Closing this scope runs - // `~StrandExecutor`, which blocks until `_inFlight == 0`; because - // the re-arm in `scheduleNext` increments `_inFlight` for the next - // dispatch *before* the current one decrements, the count never - // dips to zero across a handoff, so `_inFlight == 0` with no - // producer left means every queued task has run. - // - // Not a fixed budget of 2000 x 1 ms sleeps. - // That budget is ~2 s of wall clock for 3200 strand-serialised - // tasks, 20 times over, and could expire with work still queued on - // a loaded machine. Worse, the deficit was a `REQUIRE` and came - // first, so Catch2 aborted the case before `maxInFlight` -- the - // only reason this test exists -- was ever evaluated: a busy host - // turned "the strand serialisation test" into "no strand - // serialisation check ran", reported as a strand failure. The - // drain is now a synchronisation point rather than a deadline, so - // it does not depend on how fast the host is. - // - // The budget was never the runtime bound it looked like, either: - // `~StrandExecutor` ran at the end of every iteration regardless - // and blocked for the same drain, so on a green run the polling - // loop waited for something the destructor was about to wait for - // anyway. All the loop ever added was a way to fail first. What - // remains is CTest's own per-test `TIMEOUT 120` - // (`tests/CMakeLists.txt`), which is the right place for "this - // host was too slow" to be reported: as a timeout, not as an - // invariant that did not hold. - } - - INFO("iteration " << iter << ": completed " << completed.load() << " of " << kExpected); - // A `CHECK`, and deliberately not a `REQUIRE`: the two questions are - // independent and both must be answered. `~StrandExecutor` has already - // returned, so a deficit here is a task the strand lost, never a slow - // host -- and reporting it must not stop `maxInFlight` below from - // being evaluated over the tasks that did run. - CHECK(completed.load() == kExpected); - // The core invariant: at most one task for this key ever runs at once. - // This one is a `REQUIRE` -- a value above 1 means two strands ran the - // same key's tasks concurrently, which is the race this file regresses - // and not something to keep iterating past. - REQUIRE(maxInFlight.load() == 1); - } -} - -// The drain-and-re-arm boundary, which the case above never reaches. -// -// Shape, not volume. The defect needs a strand to reach *empty* while a post is -// arriving, so this case manufactures that rendezvous instead of hoping for it: -// -// 1. A pilot task is posted alone on the key. Its last act is to publish the -// round number, so the chaser threads learn the strand is about to drain. -// 2. `kChasers` threads spin on that publication and post the instant it -// flips -- that is, while the drain block following the pilot's body is -// deciding "keep running vs. erase". A per-thread stagger walks each post -// across the handful of instructions that decision spans, so the window is -// sampled at many offsets rather than one. -// 3. The round ends only once every one of its tasks has run, so the strand -// really does empty before the next pilot. The gap is the point of the -// test, and is exactly what sustained saturation destroys. -// -// Three detectors, because the defect and its symptom are not the same event: -// -// * `maxInFlight` -- the *symptom*, as in the case above: two tasks for one -// key running at the same wall-clock moment. -// * plain, non-atomic state touched by every task -- the *defect*. Two strands -// for one key leave those accesses unordered by any happens-before edge, -// which ThreadSanitizer reports whether or not the two tasks ever overlap in -// wall clock. Lost updates to the same state are visible without a sanitizer -// at all, which is why the cells are checked as well as raced on. -// * per-producer FIFO -- a strand orphaned mid-burst can run one producer's -// later task before its earlier one, and an ordinary build sees that too. -// -// The tasks deliberately do a little plain work rather than none: an empty task -// gives an overlap a window a few instructions wide, which is why the case above -// can be wrong about serialisation and still report `maxInFlight == 1`. -TEST_CASE("StrandExecutor keeps one strand per key when a post races the drain", "[strand][race]") { - constexpr int kChasers = 4; - constexpr int kBurst = 3; - constexpr int kRounds = 600; - constexpr int kIterations = 6; - constexpr int kCells = 24; - constexpr int kPerRound = 1 + (kChasers * kBurst); - - morph::exec::detail::ModelId const key{7}; - - for (int iter = 0; iter < kIterations; ++iter) { - // Same ordering rule as the case above: the pool outlives the strand. - morph::exec::ThreadPoolExecutor pool{4}; - - // Deliberately plain -- no atomic, no mutex. Under the invariant this - // file exists for, the strand *is* the synchronisation: every handoff - // between two tasks for one key passes through `_mapMtx`/`strand->mtx`, - // so each task's writes happen-before the next task's reads and these - // are data-race-free. A second strand for the same key breaks that - // chain, and then they are not. - std::vector cells(static_cast(kCells), 0); - std::vector lastSeq(static_cast(kChasers) + 1, -1); - long long executedPlain = 0; - - std::atomic inFlight{0}; - std::atomic maxInFlight{0}; - std::atomic outOfOrder{0}; - std::atomic completed{0}; - // Round number whose pilot task has finished its body. Release/acquire: - // the chasers must not start posting for round r before it is set. - std::atomic gate{0}; - - auto body = [&](int producer, int seq) { - int const cur = inFlight.fetch_add(1) + 1; - int prev = maxInFlight.load(); - while (cur > prev && !maxInFlight.compare_exchange_weak(prev, cur)) { - } - auto const slot = static_cast(producer); - if (seq <= lastSeq[slot]) { - outOfOrder.fetch_add(1); - } - lastSeq[slot] = seq; - for (auto& cell : cells) { - cell += 1; - } - ++executedPlain; - inFlight.fetch_sub(1); - completed.fetch_add(1, std::memory_order_release); - }; - - { - morph::exec::detail::StrandExecutor strand{pool}; - - std::vector chasers; - chasers.reserve(kChasers); - for (int chaser = 0; chaser < kChasers; ++chaser) { - chasers.emplace_back([&, chaser] { - int const producer = chaser + 1; - // Per-thread LCG, so the stagger below differs per thread - // and per round without pulling in or a shared - // engine that would itself synchronise the threads. - auto rng = (static_cast(chaser) * 2654435761U) + 1U; - for (int round = 0; round < kRounds; ++round) { - // Spin rather than yield: the window this case aims at - // is a few instructions wide, and a yield overshoots it - // by orders of magnitude. The periodic yield is only a - // starvation guard for hosts with fewer cores than this - // case has threads (a CI runner has four); it fires once - // per 4096 spins, so it costs the rendezvous nothing. - for (unsigned spins = 0; gate.load(std::memory_order_acquire) <= round; ++spins) { - if ((spins & 0xFFFU) == 0xFFFU) { - std::this_thread::yield(); - } - } - for (int post = 0; post < kBurst; ++post) { - rng = (rng * 1664525U) + 1013904223U; - int const stagger = static_cast((rng >> 16U) & 0x3FU); - for (int step = 0; step < stagger; ++step) { - // Busy work, folded back into `rng` so it cannot - // be optimised away, walking this post to a - // different offset inside the drain window. - rng = (rng * 1103515245U) + 12345U; - } - int const seq = (round * kBurst) + post; - strand.post(key, [&body, producer, seq] { body(producer, seq); }); - } - } - }); - } - - for (int round = 0; round < kRounds; ++round) { - strand.post(key, [&body, &gate, round] { - body(0, round); - // Published last: the chasers' posts have to arrive while - // the drain that follows this body is running, not before. - gate.store(round + 1, std::memory_order_release); - }); - // Wait out the round rather than pipelining it. This is the - // quiet moment -- the strand drains to empty here, which is the - // only state from which the erase can fire at all. - while (completed.load(std::memory_order_acquire) < (round + 1) * kPerRound) { - std::this_thread::yield(); - } - } - - for (auto& chaser : chasers) { - chaser.join(); - } - // Closing this scope runs `~StrandExecutor`, which blocks until - // `_inFlight == 0`; see the case above for why that is a complete - // drain and not a deadline. - } - - constexpr int kExpected = kRounds * kPerRound; - INFO("iteration " << iter << ": completed " << completed.load() << " of " << kExpected); - // `CHECK`, not `REQUIRE`, for everything but the last line: each of - // these answers a different question about the same run and stopping at - // the first one would hide the others (see the case above). - CHECK(completed.load() == kExpected); - // Lost updates to the plain state: the sanitizer-free reading of the - // same defect the TSan legs see as a data race on it. - CHECK(executedPlain == static_cast(kExpected)); - auto const [lowest, highest] = std::ranges::minmax_element(cells); - CHECK(*lowest == kExpected); - CHECK(*highest == kExpected); - // FIFO per key is part of the contract, and an orphaned strand breaks it - // without any two tasks having to overlap. - CHECK(outOfOrder.load() == 0); - REQUIRE(maxInFlight.load() == 1); - } -} - -// The recycled map node, which neither case above can be wrong -// about. -// -// When a strand drains, `scheduleNext` no longer `erase`s the map entry: it -// `extract`s it into a single-slot `_spare`, and the next `post()` that misses -// re-keys that node and inserts it back. Re-keying is the new step, and it is -// the one a functional test does not see. An entry left under the *previous* -// key still serialises every task that reaches it, still runs them in order, -// and still completes them all; what it corrupts is which key the map answers -// for, and that only becomes a serialisation failure two posts later: -// -// 1. Key A drains, parking a node still keyed A. -// 2. Key B misses and takes that node -- which, unkeyed, goes back into the -// map under A. B's first task starts running on a strand the map calls A, -// so `find(B)` still misses. -// 3. B's *next* post therefore misses too, and installs a second strand for -// B while the first is still running its task. Two strands for one key: -// the invariant the two cases above exist for, reached through a door -// neither of them opens. -// -// Step 3 is what the shape below is for, and it is why this case is not simply -// "post to several keys and let them drain". The mis-key is only observable -// while a task is *still running* on the mis-keyed strand, so each round posts -// a short burst back-to-back -- the second and third posts of a burst arrive -// while the first is running, which on the correct code is an ordinary re-arm -// and on the mutant is a second strand. Between rounds the key is allowed to -// go quiet, which is what produces the drain step 1 needs; several keys -// running this cycle out of phase is what carries a parked node from one key -// to another. Measured, not assumed: with `_spare.key() = key;` deleted, this -// case fails 10/10 under ThreadSanitizer, while a variant that drained between -// every single post (no burst) passed 10/10 against the same mutant. -// -// Same three detectors as the case above, kept per key: an in-flight counter -// for the symptom, plain (non-atomic) per-key state for the data race a -// sanitizer sees whether or not the two tasks overlap in wall clock, and a -// per-key FIFO check for an ordering break that needs no overlap at all. -TEST_CASE("StrandExecutor recycles a drained strand under the key that asked for it", "[strand][race]") { - constexpr std::size_t kKeys = 3; - constexpr int kBurst = 4; - constexpr int kRounds = 900; - constexpr int kIterations = 6; - constexpr std::size_t kCells = 24; - - for (int iter = 0; iter < kIterations; ++iter) { - // Same ordering rule as the cases above: the pool outlives the strand. - morph::exec::ThreadPoolExecutor pool{4}; - - // Declared outside the strand's scope so the drain in `~StrandExecutor` - // runs their final updates against live objects. - std::array, kKeys> inFlight{}; - std::array, kKeys> maxInFlight{}; - std::array, kKeys> completed{}; - std::array, kKeys> outOfOrder{}; - // Plain, per key, and touched only by that key's tasks: distinct - // objects, so a sanitizer report here means two tasks for the *same* - // key raced, never two keys sharing a cache line. Under the invariant - // this file exists for, the strand is the synchronisation and these - // accesses are data-race-free. - std::array, kKeys> cells{}; - std::array lastSeq{}; - - { - morph::exec::detail::StrandExecutor strand{pool}; - - std::vector producers; - producers.reserve(kKeys); - for (std::size_t slot = 0; slot < kKeys; ++slot) { - lastSeq.at(slot) = -1; - producers.emplace_back([&, slot] { - // Distinct, non-zero ids: 0 is `ModelId`'s reserved - // "unbound" sentinel. - morph::exec::detail::ModelId const key{100 + slot}; - for (int round = 0; round < kRounds; ++round) { - for (int post = 0; post < kBurst; ++post) { - int const seq = (round * kBurst) + post; - strand.post(key, [&, slot, seq] { - int const cur = inFlight.at(slot).fetch_add(1) + 1; - int prev = maxInFlight.at(slot).load(); - while (cur > prev && !maxInFlight.at(slot).compare_exchange_weak(prev, cur)) { - } - if (seq <= lastSeq.at(slot)) { - outOfOrder.at(slot).fetch_add(1); - } - lastSeq.at(slot) = seq; - // A little plain work rather than none: an - // empty task gives an overlap a window a few - // instructions wide, which is how a broken - // strand can still report `maxInFlight == 1`. - for (auto& cell : cells.at(slot)) { - cell += 1; - } - inFlight.at(slot).fetch_sub(1); - completed.at(slot).fetch_add(1, std::memory_order_release); - }); - } - // Let this key go quiet before the next burst: the - // drain that parks a node in `_spare` only fires from - // an empty pending queue, and without this gap every - // post after the first re-arms a strand that is - // already in the map and the install path is never - // reached at all. - while (completed.at(slot).load(std::memory_order_acquire) < (round + 1) * kBurst) { - std::this_thread::yield(); - } - } - }); - } - for (auto& producer : producers) { - producer.join(); - } - // Closing this scope runs `~StrandExecutor`, which blocks until - // `_inFlight == 0`; see the first case for why that is a complete - // drain and not a deadline. - } - - constexpr int kPerKey = kRounds * kBurst; - for (std::size_t slot = 0; slot < kKeys; ++slot) { - INFO("iteration " << iter << ", key slot " << slot << ": completed " << completed.at(slot).load() << " of " - << kPerKey); - // `CHECK` for the counts, `REQUIRE` for the invariant: they answer - // different questions and stopping at the first would hide the - // others (see the first case). - CHECK(completed.at(slot).load() == kPerKey); - auto const [lowest, highest] = std::ranges::minmax_element(cells.at(slot)); - CHECK(*lowest == kPerKey); - CHECK(*highest == kPerKey); - CHECK(outOfOrder.at(slot).load() == 0); - REQUIRE(maxInFlight.at(slot).load() == 1); - } - } -} - -// Regression test for ThreadPoolExecutor(0): a zero-worker pool used to accept -// tasks that could never run, hanging every post() forever. The constructor now -// clamps the worker count to at least 1, so a pool built with 0 is still usable. -TEST_CASE("ThreadPoolExecutor(0) yields a usable pool", "[executor][race]") { - std::atomic ran{false}; - - // Scoped so ~ThreadPoolExecutor's own drain-before-join (executor.hpp's own - // doc comment on it) is the wait, not a fixed-iteration poll: the posted - // task is queued before this block ends, so the destructor's join is - // guaranteed not to return until it has run. - { - morph::exec::ThreadPoolExecutor pool{0}; - pool.post([&] { ran.store(true); }); - } - - REQUIRE(ran.load()); -} diff --git a/tests/test_support.hpp b/tests/test_support.hpp index 5dfc8bd06..f40b5475b 100644 --- a/tests/test_support.hpp +++ b/tests/test_support.hpp @@ -42,10 +42,10 @@ struct InlineExecutor : ::morph::exec::IExecutor { /// be driven with fully deterministic, hand-stepped task ordering by /// constructing it against a `StepExecutor` instead of a `ThreadPoolExecutor`. /// `RemoteServer` posts every dispatch (both the top-level `handle()` post and -/// the per-model strand dispatch its internal `StrandExecutor` performs) onto +/// the per-model strand dispatch its internal `ModelStrands` performs) onto /// whichever `IExecutor` it was constructed with, so controlling that one /// executor is enough to control ordering end-to-end — no need to name -/// `morph::exec::detail::StrandExecutor` or `morph::exec::detail::ModelId` to +/// `morph::exec::detail::ModelStrands` or `morph::exec::detail::ModelId` to /// get there. A test picks which of several pending tasks (e.g. two different /// models' queued work) to run next via `runOne()`, observing `RemoteServer`'s /// real per-model serialisation (a strand never posts its next task until the @@ -123,7 +123,7 @@ class StepExecutor : public ::morph::exec::IExecutor { /// Without this, strand-ordering bugs in code built over `IExecutor` (see /// `test_remote_execute_ordering.cpp`'s use of it against `RemoteServer`, or /// `examples/common/testkit/strand_interleaver.hpp`'s identical copy against -/// `StrandExecutor` in the ladder's own tests) are probabilistic stress runs +/// `ModelStrands` in the ladder's own tests) are probabilistic stress runs /// instead of reproducible interleavings: a test controls exactly which /// posted task runs next, rather than hoping real OS thread scheduling /// happens to hit the race on a given run. @@ -143,7 +143,7 @@ class StepExecutor : public ::morph::exec::IExecutor { /// codebase's established convention for small, self-contained internal /// details that would otherwise need new cross-module plumbing to share. /// -/// Unlike `ThreadPoolExecutor`/`StrandExecutor`, a task's exception is not +/// Unlike `ThreadPoolExecutor`/`ModelStrands`, a task's exception is not /// caught and logged here: it propagates straight out of `step()`/ /// `runSchedule()` to the caller. That is deliberate — the caller is a test, /// and the exception is often a `REQUIRE` failure the test needs to see diff --git a/tests/test_timeout_scheduler.cpp b/tests/test_timeout_scheduler.cpp index 794d2e080..c90f5c97c 100644 --- a/tests/test_timeout_scheduler.cpp +++ b/tests/test_timeout_scheduler.cpp @@ -1,22 +1,25 @@ // SPDX-License-Identifier: Apache-2.0 // -// Direct unit coverage for morph::async::detail::TimeoutScheduler (the -// threaded build, compiled whenever __EMSCRIPTEN__ without -// __EMSCRIPTEN_PTHREADS__ is not defined -- see timeout_scheduler.hpp's @file -// comment for the single-threaded browser build, which this file's own -// target never compiles and cannot exercise). Bridge::executeVia and -// RemoteServer only ever call schedule()/cancel() with callbacks that don't -// throw, so this file covers the case they don't: a scheduled callback that -// throws is logged and swallowed rather than propagating out of the -// scheduler's background thread. +// Direct unit coverage for morph::async::detail::TimeoutScheduler, in the +// build that owns a thread for its loop -- see timeout_scheduler.hpp's @file +// comment for the single-threaded WebAssembly build, which this file's own +// target never compiles. Bridge::executeVia and RemoteServer only ever call +// schedule()/cancel() with callbacks that don't throw, so this file covers +// the cases they don't: a callback that throws is logged and swallowed rather +// than propagating out of the loop's thread, cancel() releases what a +// callback captured before it returns, and the destructor drops what is still +// pending instead of waiting for it. #include #include #include +#include #include #include #include +#include "test_support.hpp" + using morph::async::detail::TimeoutScheduler; using namespace std::chrono_literals; @@ -99,6 +102,41 @@ TEST_CASE("TimeoutScheduler: cancel() before the deadline prevents the callback REQUIRE_FALSE(fired.load()); } +TEST_CASE("TimeoutScheduler: cancel() releases the callback's captures before it returns", "[timeout_scheduler]") { + TimeoutScheduler scheduler; + auto token = std::make_shared(0); + std::weak_ptr const observer = token; + + // Far enough out that nothing but cancel() can release it within the case. + auto const handle = scheduler.schedule(60s, [token = std::move(token)] { static_cast(token); }); + REQUIRE_FALSE(observer.expired()); + + scheduler.cancel(handle); + REQUIRE(observer.expired()); +} + +TEST_CASE("TimeoutScheduler: the destructor drops pending callbacks without firing them", "[timeout_scheduler]") { + std::atomic fired{false}; + std::weak_ptr observer; + auto const started = std::chrono::steady_clock::now(); + { + TimeoutScheduler scheduler; + auto token = std::make_shared(0); + observer = token; + scheduler.schedule(60s, [&fired, token = std::move(token)] { + static_cast(token); + fired = true; + }); + } + auto const elapsed = std::chrono::steady_clock::now() - started; + + // Dropped, not fired, and not waited for: a destructor that let the + // deadline run out would take the full minute and set `fired`. + REQUIRE_FALSE(fired.load()); + REQUIRE(observer.expired()); + REQUIRE(elapsed < 10s); +} + // ── What `cancel()` does about a callback that has already started ─────────── // // The header states the distinction these two cases make: @@ -152,3 +190,54 @@ TEST_CASE("TimeoutScheduler: the destructor -- unlike cancel() -- waits for a ru // than a timing accident: this one *is* "no callback in flight afterwards". REQUIRE(finished.load()); } + +TEST_CASE("TimeoutScheduler: a dropped callback's capture may call back into the scheduler it is dropped by", + "[timeout_scheduler]") { + // Cancels its own entry from its destructor: when the scheduler drops the + // callback holding the last reference to this, that destructor runs inside + // ~TimeoutScheduler and calls cancel() on the scheduler being destroyed. + struct CancelOnDestroy { + TimeoutScheduler* scheduler = nullptr; + TimeoutScheduler::Handle other{}; + std::atomic* destroyed = nullptr; + CancelOnDestroy() = default; + CancelOnDestroy(const CancelOnDestroy&) = delete; + CancelOnDestroy& operator=(const CancelOnDestroy&) = delete; + CancelOnDestroy(CancelOnDestroy&&) = delete; + CancelOnDestroy& operator=(CancelOnDestroy&&) = delete; + ~CancelOnDestroy() { + scheduler->cancel(other); + *destroyed = true; + } + }; + + std::atomic destroyed{false}; + { + TimeoutScheduler scheduler; + auto guard = std::make_shared(); + guard->scheduler = &scheduler; + guard->destroyed = &destroyed; + guard->other = scheduler.schedule(60s, [] {}); + scheduler.schedule(60s, [guard] { static_cast(guard); }); + guard.reset(); + REQUIRE_FALSE(destroyed.load()); + } + // The destructor released the capture, whose cancel() found the scheduler + // whole: its lock usable and the other entry already taken out. + REQUIRE(destroyed.load()); +} + +TEST_CASE("TimeoutScheduler: destroyed with a timer still armed, it retires the timer and never runs its callback", + "[timeout_scheduler]") { + std::atomic ran{false}; + { + TimeoutScheduler scheduler; + static_cast(scheduler.schedule(1h, [&] { ran = true; })); + // Requests reach the loop in order, so once this one has fired the one + // above is armed: the destructor then has a timer to retire. + std::atomic armed{false}; + static_cast(scheduler.schedule(0ms, [&] { armed = true; })); + REQUIRE(morph::testing::waitUntil([&] { return armed.load(); })); + } + REQUIRE_FALSE(ran.load()); +} diff --git a/tests/windows_dialog_canary.cpp b/tests/windows_dialog_canary.cpp new file mode 100644 index 000000000..d74261631 --- /dev/null +++ b/tests/windows_dialog_canary.cpp @@ -0,0 +1,77 @@ +// SPDX-License-Identifier: Apache-2.0 +// +// Fails in one way that can raise a Windows dialog, chosen by its argument, and +// must be seen to exit rather than wait for a click. tests/CMakeLists.txt +// registers one run per way, judged by the marker each prints before it fails, +// with a timeout: a run still waiting when the timeout expires is waiting on a +// dialog nobody will click. +// +// It calls nothing to suppress anything. What it proves is that linking +// core::testing_dialogs, as every morph test executable does on Windows, +// installs the suppression in an executable whose main() never asked for it. +// Modelled on core-cpp's tests/WindowsDialogCanary.cpp. + +#include +#include +#include +#include +#include +#include + +namespace { + +/// "This way of failing is not compiled into this build": a Release build has +/// no assert() to trip. ctest reports it as skipped. +[[maybe_unused]] constexpr int NotExercised = 77; + +/// "The failure was handled and execution continued", distinct from success. +constexpr int ContinuedAfterFailure = 3; + +/// Ends the process on SIGABRT with a plain exit status. Without it, abort() +/// ends in a way ctest reports as an exception whatever the test's properties +/// say, and no registration could judge the run by its output. +extern "C" void onAbort(int /*signal*/) { std::_Exit(1); } + +} // namespace + +int main(int argc, char* argv[]) { + if (argc != 2) { + std::fputs("usage: morph_windows_dialog_canary assert|abort|invalid-parameter\n", stderr); + return 2; + } + auto const mode = std::string_view{argv[1]}; + static_cast(std::signal(SIGABRT, onAbort)); + + std::fprintf(stderr, "morph_windows_dialog_canary: failing by %s\n", argv[1]); + std::fflush(stderr); + + if (mode == "assert") { +#ifdef NDEBUG + return NotExercised; +#else + [[maybe_unused]] auto const canaryHolds = false; + assert(canaryHolds && "morph_windows_dialog_canary asserts on purpose"); + std::fputs("morph_windows_dialog_canary: CONTINUED AFTER FAILURE\n", stderr); + return ContinuedAfterFailure; +#endif + } + if (mode == "abort") { + std::abort(); + } + if (mode == "invalid-parameter") { +#ifdef _WIN32 + // A null destination is an invalid parameter to the CRT: a dialog in a + // Debug CRT, Watson in a Release one, unless a handler was installed. + char* volatile destination = nullptr; + if (strcpy_s(destination, 1, "x") != 0) { + std::fputs("morph_windows_dialog_canary: CONTINUED AFTER FAILURE\n", stderr); + return ContinuedAfterFailure; + } + return 0; +#else + return NotExercised; +#endif + } + std::fprintf(stderr, "morph_windows_dialog_canary: unknown mode %s\n", argv[1]); + return 2; +}