diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f4a7e724..46a8efbc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -176,6 +176,49 @@ jobs: sudo apt-get install -y ninja-build catch2 libsqlite3-dev wget -qO- https://apt.llvm.org/llvm.sh | sudo bash -s -- ${{ env.CLANG_VERSION }} + # Only the coverage leg builds the ladder: examples/common's + # hand-written GUI/testkit code is real coverage of morph's client + # stack (Bridge, backends, QtExecutor, completions — see + # examples/TESTING.md's "round-7 T4 reframe"), so it belongs in the + # coverage number the same way the models it will host later do + # (examples/IMPLEMENTATION.md rule 5). asan/tsan/ubsan skip this, same + # as before — "a GUI stack under TSan is mostly noise" — coverage + # instrumentation carries none of that risk. + - name: Install ODBC + SQLite driver (coverage leg only) + if: matrix.preset == 'clang-coverage' + run: | + # unixodbc-dev + libsqliteodbc: the application ladder (built by this + # leg only) fetches the Lightweight ORM, whose CMake runs + # `pkg_check_modules(ODBC REQUIRED odbc)`, and whose ladder fixtures + # open a real `DRIVER=SQLite3` connection at test time. + # Named explicitly rather than relied on from the runner image. + # libyaml-cpp-dev + libzip-dev: Lightweight's own CMakeLists.txt + # does `find_package(yaml-cpp)`/`find_package(libzip)` as system + # CONFIG packages, not through CPM (examples/bank/CMakeLists.txt's + # comment on the identical fetch) — without these, configure fails + # the moment this leg's MORPH_BUILD_LADDER=ON pulls Lightweight in. + # Dropped from this step by mistake when it was renamed from + # "Install Qt6 WebSockets" to "Install ODBC + SQLite driver" — + # every other job that builds the ladder on Linux (Application + # ladder, all optional features) already carries this pair. + sudo apt-get install -y libgl1-mesa-dev unixodbc-dev libsqliteodbc libyaml-cpp-dev libzip-dev + + # Not the distro's Qt: examples/common/CMakeLists.txt requires 6.5+ + # unconditionally (not gated on MORPH_BUILD_FORMS_QML) and Ubuntu + # 24.04 still ships 6.4.2 -- the identical gap the "all optional + # features" and "Application ladder" jobs' own install-qt-action steps + # already document. Named qt6-base-dev/qt6-websockets-dev/qt6-tools-dev + # used to be installed above; replaced wholesale rather than kept + # alongside aqtinstall's Qt, which would leave two Qt6 installs on the + # same runner for find_package() to pick between. + - name: Install Qt ${{ env.QT_VERSION }} (coverage leg only) + if: matrix.preset == 'clang-coverage' + uses: jurplel/install-qt-action@v4 + with: + version: ${{ env.QT_VERSION }} + modules: qtwebsockets + cache: true + - name: Restore sccache uses: actions/cache/restore@v4 with: @@ -190,23 +233,45 @@ jobs: # morph::net and the SQLite offline queue are opt-in, but they are also # where the memory/threading/UB risk actually lives (raw sockets, an I/O # thread, a hand-rolled frame reader, a C API). Left off, the sanitizers - # and the coverage number both silently skipped them. Qt/QML and the - # fuzzers stay out of this matrix — they are covered by the - # linux-all-features job, and a GUI stack under TSan is mostly noise. + # and the coverage number both silently skipped them. QML and the + # fuzzers stay out of this matrix entirely — they are covered by the + # linux-all-features job, and a GUI stack under TSan is mostly noise; + # the ladder (Qt6::WebSockets, no QML) is the one exception, built only + # on the coverage leg, for the reason in the Qt install step above. - name: Configure run: | + EXTRA_ARGS=() + if [ "${{ matrix.preset }}" = "clang-coverage" ]; then + EXTRA_ARGS+=(-DMORPH_BUILD_QT=ON -DMORPH_BUILD_LADDER=ON -DMORPH_LADDER_RUNGS=all) + fi cmake --preset ${{ matrix.preset }} \ -DMORPH_BUILD_NET=ON \ -DMORPH_BUILD_OFFLINE_SQLITE=ON \ -DCMAKE_C_COMPILER=clang-${{ env.CLANG_VERSION }} \ -DCMAKE_CXX_COMPILER=clang++-${{ env.CLANG_VERSION }} \ -DCMAKE_C_COMPILER_LAUNCHER=sccache \ - -DCMAKE_CXX_COMPILER_LAUNCHER=sccache - + -DCMAKE_CXX_COMPILER_LAUNCHER=sccache \ + "${EXTRA_ARGS[@]}" + + # QT_QPA_PLATFORM=offscreen here too, not just on Test below: Catch2's + # catch_discover_tests() runs each Qt-linked test binary once at BUILD + # time to enumerate its cases, which can abort on this headless runner + # without it — see "Linux / all optional features"'s own Build step + # for the identical failure this leg's coverage build hit once the + # ladder actually started compiling (this leg has no QML, a narrower + # Qt surface, but ladder_common_tests still links Qt6::WebSockets). + # Harmless for the non-Qt legs (nothing reads it). - name: Build + env: + QT_QPA_PLATFORM: offscreen run: cmake --build --preset ${{ matrix.preset }} - name: Test + env: + # Harmless for the non-Qt legs (nothing reads it); required for the + # coverage leg's ladder tests, which open real Qt widgets/sockets + # on a runner with no display. + QT_QPA_PLATFORM: offscreen run: | if [ "${{ matrix.preset }}" = "clang-coverage" ]; then LLVM_PROFILE_FILE="build/clang-coverage/%p.profraw" ctest --preset clang-coverage @@ -311,6 +376,131 @@ jobs: path: /home/runner/.cache/sccache key: sccache-qt + # ── Linux: application ladder testkit (path-filtered) ───────────────── + ladder-tests: + name: Application ladder + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 # need history for the changed-paths diff below + + - name: Determine whether the ladder needs to run + id: filter + run: | + if [ "${{ github.event_name }}" = "pull_request" ]; then + base="${{ github.event.pull_request.base.sha }}" + else + base="${{ github.event.before }}" + fi + if [ -z "$base" ] || ! git cat-file -e "$base" 2>/dev/null; then + echo "run=true" >> "$GITHUB_OUTPUT" + exit 0 + fi + changed=$(git diff --name-only "$base" HEAD) + # src/qt/: the compiled bodies of morph_qt_impl — the very thing the + # testkit exists to conformance-test (and where finding 017's fix + # lands). CMakeLists.txt/cmake/ and this workflow itself: a change to + # any of them can break or silently skip this job. + if echo "$changed" | grep -qE '^(examples/(common|pastebin|bookmarks|polls|kanban)/|examples/CMakeLists\.txt$|include/morph/|src/qt/|cmake/|CMakeLists\.txt$|CMakePresets\.json$|\.github/workflows/ci\.yml$|examples/LADDER\.md|examples/IMPLEMENTATION\.md|examples/TESTING\.md)'; then + echo "run=true" >> "$GITHUB_OUTPUT" + else + echo "run=false" >> "$GITHUB_OUTPUT" + fi + + - name: Cache apt packages + if: steps.filter.outputs.run == 'true' + uses: actions/cache@v4 + with: + path: /var/cache/apt/archives + key: apt-qt-${{ hashFiles('.github/workflows/ci.yml') }} + restore-keys: apt-qt- + + - name: Install GCC 15, ninja, catch2 + if: steps.filter.outputs.run == 'true' + run: | + sudo apt-get update -q + sudo apt-get install -y software-properties-common + sudo add-apt-repository -y ppa:ubuntu-toolchain-r/test + sudo apt-get update -q + # unixodbc-dev + libsqliteodbc: the application ladder fetches the + # Lightweight ORM, whose CMake runs + # `pkg_check_modules(ODBC REQUIRED odbc)`, and whose ladder fixtures + # open a real `DRIVER=SQLite3` connection at test time. + # Named explicitly rather than relied on from the runner image. + # libyaml-cpp-dev + libzip-dev: Lightweight's own CMakeLists.txt + # does `find_package(yaml-cpp)`/`find_package(libzip)` as system + # CONFIG packages, not through CPM (examples/bank/CMakeLists.txt's + # comment on the identical fetch) — without these, configure fails + # the moment MORPH_BUILD_LADDER=ON pulls Lightweight in. + # Qt itself is installed by the aqtinstall step below, not apt: see + # that step's comment for why the distro package is unusable here. + sudo apt-get install -y gcc-15 g++-15 ninja-build catch2 \ + libyaml-cpp-dev libzip-dev libgl1-mesa-dev \ + unixodbc-dev libsqliteodbc + sudo update-alternatives --install /usr/bin/gcc gcc /usr/bin/gcc-15 15 + sudo update-alternatives --install /usr/bin/g++ g++ /usr/bin/g++-15 15 + + # Not the distro's Qt: examples/common/CMakeLists.txt requires 6.5+ + # (QQmlApplicationEngine::loadFromModule, used by MORPH_BUILD_FORMS_QML + # rungs) and Ubuntu 24.04 still ships 6.4.2 — the exact gap the "all + # optional features" job's identical step already documents. This job + # configures MORPH_BUILD_LADDER=ON without MORPH_BUILD_FORMS_QML, but + # examples/common/CMakeLists.txt's Qt6 6.5 REQUIRED applies unconditionally + # (it is not gated on MORPH_BUILD_FORMS_QML), so the floor still bites here. + - name: Install Qt ${{ env.QT_VERSION }} + if: steps.filter.outputs.run == 'true' + uses: jurplel/install-qt-action@v4 + with: + version: ${{ env.QT_VERSION }} + modules: qtwebsockets + cache: true + + - name: Cache sccache + if: steps.filter.outputs.run == 'true' + uses: actions/cache@v4 + with: + path: /home/runner/.cache/sccache + key: sccache-ladder-${{ github.sha }} + restore-keys: sccache-ladder- + + - name: Install sccache + if: steps.filter.outputs.run == 'true' + run: | + curl -sSL https://github.com/mozilla/sccache/releases/download/v0.9.1/sccache-v0.9.1-x86_64-unknown-linux-musl.tar.gz \ + | tar -xz --strip-components=1 -C /usr/local/bin sccache-v0.9.1-x86_64-unknown-linux-musl/sccache + + - name: Configure (gcc-debug, ladder + Qt on) + if: steps.filter.outputs.run == 'true' + run: | + cmake --preset gcc-debug \ + -DMORPH_BUILD_QT=ON \ + -DMORPH_BUILD_LADDER=ON \ + -DMORPH_LADDER_RUNGS=all \ + -DCMAKE_C_COMPILER_LAUNCHER=sccache \ + -DCMAKE_CXX_COMPILER_LAUNCHER=sccache + + # QT_QPA_PLATFORM=offscreen here too, not just on Test below: Catch2's + # catch_discover_tests() runs each Qt-linked test binary once at BUILD + # time to enumerate its cases, which aborts on a headless runner (no X + # server) without it — see "Linux / all optional features"'s own Build + # step for the identical note. This job has not hit it in practice + # (its ladder test binaries' discovery apparently succeeds without a + # platform anyway), but the risk is structurally identical, so it is + # set defensively rather than left to reappear the next time a rung + # adds a Qt Quick-linked test binary here. + - name: Build + if: steps.filter.outputs.run == 'true' + env: + QT_QPA_PLATFORM: offscreen + run: cmake --build --preset gcc-debug + + - name: Test (offscreen Qt platform, ladder tests only, stress excluded) + if: steps.filter.outputs.run == 'true' + env: + QT_QPA_PLATFORM: offscreen + run: ctest --preset gcc-debug -L ladder -LE stress --output-on-failure + # ── Linux: every optional feature enabled at once ───────────────────── # Every MORPH_BUILD_* option below is off by default, and until this job # existed no CI configuration turned any of them on — so several thousand @@ -350,8 +540,21 @@ jobs: sudo apt-get install -y software-properties-common sudo add-apt-repository -y ppa:ubuntu-toolchain-r/test sudo apt-get update -q + # unixodbc-dev + libsqliteodbc: the application ladder (enabled in + # the configure step below) fetches the Lightweight ORM, whose CMake + # runs `pkg_check_modules(ODBC REQUIRED odbc)`, and whose ladder + # fixtures open a real `DRIVER=SQLite3` connection at test time. + # Named explicitly rather than relied on from the runner image. + # libyaml-cpp-dev + libzip-dev: Lightweight's own CMakeLists.txt + # (examples/bank/CMakeLists.txt's comment on the same fetch) does + # `find_package(yaml-cpp)`/`find_package(libzip)` as system CONFIG + # packages, not through CPM — without these, Lightweight's configure + # fails with "could not find a package configuration file" the + # moment MORPH_BUILD_LADDER=ON pulls it in here. sudo apt-get install -y ninja-build catch2 \ libsqlite3-dev libsodium-dev libssl-dev \ + unixodbc-dev libsqliteodbc \ + libyaml-cpp-dev libzip-dev \ libgl1-mesa-dev libxkbcommon-x11-0 libxcb-cursor0 libxcb-icccm4 \ libxcb-keysyms1 libxcb-shape0 libxcb-xinerama0 if [ "${{ matrix.compiler }}" = "gcc" ]; then @@ -393,10 +596,22 @@ jobs: EXTRA="-DCMAKE_C_COMPILER=clang-${{ env.CLANG_VERSION }} -DCMAKE_CXX_COMPILER=clang++-${{ env.CLANG_VERSION }}" fi # shellcheck disable=SC2086 + # MORPH_BUILD_LADDER belongs in this job by its own charter ("every + # MORPH_BUILD_* option … enabling them together also proves they + # compose") and closes a real hole: until it was added here, *no* CI + # leg configured MORPH_BUILD_LADDER=ON together with + # MORPH_BUILD_FORMS_QML=ON. The ladder-tests job below cannot — its + # distro Qt is 6.4.2, under the 6.5 floor MORPH_BUILD_FORMS_QML + # requires — so each rung's QML module, desktop client and offscreen + # engine-load smoke test were built by nothing at all. This job has + # Qt ${{ env.QT_VERSION }} from aqtinstall, so here they are built, + # and the smoke test runs, on every push. cmake --preset ${{ matrix.preset }} \ -DMORPH_BUILD_NET=ON \ -DMORPH_BUILD_QT=ON \ -DMORPH_BUILD_FORMS_QML=ON \ + -DMORPH_BUILD_LADDER=ON \ + -DMORPH_LADDER_RUNGS=all \ -DMORPH_BUILD_OFFLINE_SQLITE=ON \ -DMORPH_BUILD_LOAD_TESTS=ON \ -DMORPH_BUILD_HMAC_EXAMPLES=ON \ @@ -406,7 +621,19 @@ jobs: -DCMAKE_C_COMPILER_LAUNCHER=sccache \ -DCMAKE_CXX_COMPILER_LAUNCHER=sccache + # QT_QPA_PLATFORM=offscreen here too, not just on Test below: Catch2's + # catch_discover_tests() runs each Qt-linked test binary once at BUILD + # time to enumerate its cases (CatchAddTests.cmake), not only when + # ctest later executes them — a ladder__tests binary aborts at + # that discovery step on this headless runner (no X server, xcb + # platform plugin fails to load) without it, before any real test ever + # runs. Only bites once MORPH_BUILD_LADDER=ON actually reaches a rung's + # own Qt-linked test binary, which is why this job's build only started + # failing here after the yaml-cpp/libzip configure gap (fixed earlier + # this branch) stopped masking it. - name: Build + env: + QT_QPA_PLATFORM: offscreen run: cmake --build --preset ${{ matrix.preset }} # Includes the fuzz *replay* tests on the clang leg: each committed seed diff --git a/.github/workflows/wasm-ladder.yml b/.github/workflows/wasm-ladder.yml new file mode 100644 index 00000000..dd34fce0 --- /dev/null +++ b/.github/workflows/wasm-ladder.yml @@ -0,0 +1,164 @@ +name: WASM ladder gate + +# Compile gate for the application ladder's WebAssembly clients — the one +# examples/TESTING.md's CI tiering promises ("the WASM compile gate for the +# affected rungs") and the only thing in this repository that can actually +# verify them: no Emscripten toolchain was available where rung 0's WASM-remote +# spike (examples/common/wasm_spike) or rung 1's WASM client +# (examples/pastebin/gui_wasm) were authored, so both shipped structurally +# complete and never compiled. Until this job runs green, treat every WASM +# target here as unverified. +# +# Deliberately separate from wasm-demo.yml (bank's WASM GUI): different sources, +# different path filter, and nothing here is deployed anywhere — this builds and +# stops. Single-threaded Qt-for-WASM, same as that workflow. + +on: + push: + branches: + - master + paths: + - 'examples/common/**' + - 'examples/pastebin/**' + - 'examples/bookmarks/**' + - 'examples/polls/**' + - 'examples/kanban/**' + - 'examples/CMakeLists.txt' + - 'cmake/**' + - 'include/morph/**' + - 'src/qt/**' + - 'CMakeLists.txt' + - '.github/workflows/wasm-ladder.yml' + pull_request: + branches: + - master + paths: + - 'examples/common/**' + - 'examples/pastebin/**' + - 'examples/bookmarks/**' + - 'examples/polls/**' + - 'examples/kanban/**' + - 'examples/CMakeLists.txt' + - 'cmake/**' + - 'include/morph/**' + - 'src/qt/**' + - 'CMakeLists.txt' + - '.github/workflows/wasm-ladder.yml' + +concurrency: + group: wasm-ladder-${{ github.ref }} + cancel-in-progress: true + +env: + QT_VERSION: 6.8.3 + EMSDK_VERSION: 3.1.56 # the emscripten Qt 6.8 was built against + +jobs: + build-ladder-wasm: + name: Build the ladder's WASM clients + runs-on: ubuntu-24.04 + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Install build tools + run: | + sudo apt-get update -q + sudo apt-get install -y ninja-build + + # aqtinstall gives a matched host + wasm Qt pair (same cmake glue), so no + # host/target version skew. qtwebsockets on *both*: morph::qt links + # Qt6::WebSockets, and a ladder WASM client is a remote client by rule + # (examples/IMPLEMENTATION.md rule 4's WASM clause), so the transport is + # not optional here the way it is for bank's local-only demo. + - name: Install Qt (host desktop) + uses: jurplel/install-qt-action@v4 + with: + version: ${{ env.QT_VERSION }} + host: linux + target: desktop + arch: linux_gcc_64 + modules: qtwebsockets + dir: ${{ runner.temp }}/qt + + - name: Install Qt (wasm, single-threaded) + uses: jurplel/install-qt-action@v4 + with: + version: ${{ env.QT_VERSION }} + host: all_os + target: wasm + arch: wasm_singlethread + modules: qtwebsockets + dir: ${{ runner.temp }}/qt + + - name: Set up emsdk + uses: mymindstorm/setup-emsdk@v14 + with: + version: ${{ env.EMSDK_VERSION }} + actions-cache-folder: emsdk-ladder-cache + + # MORPH_CLIENT_ONLY is mandatory, not a tuning knob: a rung's presenters + # are BridgeHandler templates, so the client names its model type + # even though it never hosts one — and without this option morph still + # emits the registrars that closure over that model's ODBC-backed + # execute() bodies, which cannot link in a browser + # (docs/spec/core/registry.md). morph_add_rung() fails the configure with + # that explanation if it is missing. + # + # MORPH_BUILD_TESTS=OFF: Catch2 binaries are not browser artifacts, and + # examples/common/CMakeLists.txt returns before its Catch2/Lightweight + # section under Emscripten for exactly that reason. + - name: Configure + run: | + export EM_CACHE="$PWD/.emcache" + mkdir -p "$EM_CACHE" + HOST=${{ runner.temp }}/qt/Qt/${{ env.QT_VERSION }}/gcc_64 + WASM=${{ runner.temp }}/qt/Qt/${{ env.QT_VERSION }}/wasm_singlethread + # The all_os/wasm package extracts its scripts without the exec bit. + chmod +x "$WASM"/bin/* || true + "$WASM/bin/qt-cmake" -S . -B build-wasm-ladder -G Ninja \ + -DQT_HOST_PATH="$HOST" \ + -DMORPH_BUILD_QT=ON \ + -DMORPH_BUILD_FORMS_QML=ON \ + -DMORPH_BUILD_LADDER=ON \ + -DMORPH_LADDER_RUNGS=all \ + -DMORPH_CLIENT_ONLY=ON \ + -DMORPH_BUILD_TESTS=OFF \ + -DMORPH_BUILD_EXAMPLES=OFF + + # The rung-0 spike and rungs 1-3's clients, built by name so a target + # that silently stops being generated (morph_add_rung() skips a rung's + # gui_wasm when its prerequisites are missing, announcing why) fails this + # job instead of passing it vacuously. The plain build that follows + # covers any further rung automatically, so this file does not need + # editing again just to add another named target. + # + # Each named target is gated on its rung's directory actually existing + # in the checkout: rungs land as their own PRs against the shared + # foundation this workflow lives in (see LADDER.md/the PR history for + # why), so a foundation-only checkout has none of them yet -- that is + # the "no rung exists" case morph_add_rung.cmake's own header comment + # already documents as a silent, expected no-target outcome, not the + # regression this tripwire exists to catch. Once a rung's directory is + # present, the tripwire is back in force: morph_add_rung() skipping its + # gui_wasm target for any *other* reason (missing gui_lib, missing QML + # module, MORPH_BUILD_FORMS_QML off) still fails this job, unchanged. + - name: Build the WASM-remote spike and every rung's WASM client + run: | + export EM_CACHE="$PWD/.emcache" + cmake --build build-wasm-ladder --target morph_ladder_wasm_spike + for rung in pastebin bookmarks polls; do + if [ -d "examples/$rung" ]; then + cmake --build build-wasm-ladder --target "ladder_${rung}_gui_wasm" + else + echo "::notice::examples/$rung not present in this checkout -- skipping ladder_${rung}_gui_wasm (expected on the rung-0 foundation PR; see wasm-ladder.yml's comment)" + fi + done + # Catches any further rung's WASM client too, without editing this + # file again -- closing the gap rung 1's own final review flagged. + cmake --build build-wasm-ladder + + # Informational: the build steps above are the gate. Listed rather than + # asserted by path, since where Qt drops a wasm bundle is Qt's business. + - name: Show the produced artifacts + run: find build-wasm-ladder -name '*.wasm' -o -name '*.html' | sort diff --git a/.gitignore b/.gitignore index 711498a7..902ecf24 100644 --- a/.gitignore +++ b/.gitignore @@ -3,9 +3,13 @@ /build-wasm/ /out/ *.db +*.profraw /.cache/ /compile_commands.json *.user *.suo .vs/ bv-clang/ + +# superpowers subagent-driven-development scratch workspace (ledgers, briefs, review packages) +/.superpowers/ diff --git a/CMakeLists.txt b/CMakeLists.txt index 1fc6cc16..d4f077c3 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -29,15 +29,24 @@ option(MORPH_BUILD_BANK_GUI "Build the Qt 6 GUI for the bank example" OFF) option(MORPH_BUILD_HMAC_EXAMPLES "Build vetted-HMAC adapter examples (libsodium/OpenSSL, heavy deps)" OFF) option(MORPH_BUILD_FORMS_QML "Build the shipped Qt/QML forms renderer module (MorphForms) and its demo" OFF) +# The application ladder (examples/LADDER.md): a shared testkit + GUI +# architecture consumed by every ladder rung. Off by default like the other +# heavy-dependency example options; needs MORPH_BUILD_QT and MORPH_BUILD_TESTS +# (checked inside examples/common/CMakeLists.txt with a clear FATAL_ERROR). +option(MORPH_BUILD_LADDER "Build the application ladder's shared testkit/GUI infrastructure and enabled rungs" OFF) + +# Cache list of rungs to build when MORPH_BUILD_LADDER=ON. "all" builds every +# rung with a CMakeLists.txt under examples//; a semicolon-separated +# subset (e.g. "pastebin;bookmarks") builds only those. Rung 0 has no rung +# folders yet, so this option exists but has nothing to select until rung 1 +# lands (see examples/TESTING.md, "Build system and CI"). +set(MORPH_LADDER_RUNGS "all" CACHE STRING "Semicolon-separated list of ladder rungs to build, or \"all\"") + if(MORPH_BUILD_HMAC_EXAMPLES AND NOT MORPH_BUILD_EXAMPLES) message(WARNING "MORPH_BUILD_HMAC_EXAMPLES is ignored: it lives under examples/vetted_hmac, " "which needs MORPH_BUILD_EXAMPLES=ON.") endif() -if(MORPH_BUILD_FORMS_QML AND EMSCRIPTEN) - message(WARNING "MORPH_BUILD_FORMS_QML is ignored: the Qt/QML forms renderer needs a " - "non-Emscripten toolchain.") -endif() option(MORPH_BUILD_QT "Build Qt6 WebSocket backend and tests" OFF) option(MORPH_BUILD_NET "Build the morph::net raw-socket WebSocket transport (POSIX only; see docs/spec/core/backend.md)" OFF) option(MORPH_BUILD_FUZZERS "Build libFuzzer harnesses over wire::decode/dispatchExecute (Clang only)" OFF) @@ -214,7 +223,15 @@ target_sources(morph # deferred to just after the "Tests" section further below, since Catch2 is # only found/fetched there and its test executable names Catch2::Catch2 # directly. -if(MORPH_BUILD_FORMS_QML AND NOT EMSCRIPTEN) +# Emscripten builds this too. MorphForms is a plain Qt Quick QML module over +# header-only morph code — nothing in it is host-only — and a WASM ladder +# client has to render the *same* schema-driven Main.qml the desktop client +# does (examples/TESTING.md's "same client code"), which imports MorphForms. +# This block used to carry a `NOT EMSCRIPTEN` guard plus a "needs a +# non-Emscripten toolchain" warning, written when no WASM target consumed the +# renderer; that claim was never tested. Its one host-only piece, the QuickTest +# suite, is guarded inside src/qt/forms/CMakeLists.txt instead. +if(MORPH_BUILD_FORMS_QML) # 6.5 is a hard floor, not a preference: qt_standard_project_setup's # REQUIRES keyword and QQmlApplicationEngine::loadFromModule (used by the # demo) both arrive in 6.5. Stating it here turns "your Qt is too old" into @@ -285,6 +302,19 @@ if(MORPH_BUILD_TESTS) add_subdirectory(tests) endif() +# ── Application ladder (optional) ─────────────────────────────────────────── +# Deferred to here (after the Tests section above), the same way +# MORPH_BUILD_FORMS_QML's src/qt/forms subdirectory is deferred further below: +# examples/common/CMakeLists.txt calls find_package(Catch2 3 CONFIG QUIET) and +# 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 +# no-system-Catch2 case even though MORPH_BUILD_TESTS=ON. +if(MORPH_BUILD_LADDER) + add_subdirectory(examples) +endif() + # ── Qt/QML forms renderer (optional) ───────────────────────────────────────── # The actual MorphForms module/plugin (src/qt/forms), deferred to here (after # Catch2 is found/fetched above) since its own CMakeLists.txt links a Catch2 @@ -294,7 +324,7 @@ endif() # examples/forms/gui_qml (a consumer, added above) only forward-references # the plain (non-namespaced) morph_forms_moduleplugin target this creates, # which CMake resolves once this subdirectory is processed. -if(MORPH_BUILD_FORMS_QML AND NOT EMSCRIPTEN) +if(MORPH_BUILD_FORMS_QML) add_subdirectory(src/qt/forms) endif() diff --git a/cmake/compiler_options.cmake b/cmake/compiler_options.cmake index a8a9de7e..19d88f01 100644 --- a/cmake/compiler_options.cmake +++ b/cmake/compiler_options.cmake @@ -21,6 +21,9 @@ function(apply_warnings target) target_compile_options(${target} PRIVATE # ── MSVC ────────────────────────────────────────────────────────────── $<$: + /bigobj # heavy template instantiation (BRIDGE_REGISTER_ACTION chains, + # examples/forms/main.cpp) exceeds the default object-file + # section limit (C1128) without this /W4 /permissive- /w14062 # enumerator not handled in switch diff --git a/cmake/morph_add_rung.cmake b/cmake/morph_add_rung.cmake new file mode 100644 index 00000000..04c5aed7 --- /dev/null +++ b/cmake/morph_add_rung.cmake @@ -0,0 +1,494 @@ +# SPDX-License-Identifier: Apache-2.0 +# +# morph_add_rung(NAME ): scaffolds the standard target set for one +# ladder rung, per examples/TESTING.md "Build system and CI". Convention +# over configuration: every target below is created only if its source +# directory (relative to the caller's CMAKE_CURRENT_SOURCE_DIR, i.e. +# examples//) actually has files — a rung with no gui_wasm/ yet simply +# gets no ladder__gui_wasm target, silently, so this one function +# serves every rung from pastebin (rung 1) onward unchanged as each rung +# grows into more of the target set. +# +# Directory -> target convention: +# src/models/*.cpp, src/db/*.cpp, src/app/*.cpp -> ladder__lib STATIC (morph + Lightweight) +# gui_lib/*.cpp -> ladder__gui_lib STATIC (Qt6::Core only, no Catch2) +# gui/qml/*.qml -> ladder__qml STATIC (QML module, URI = capitalised rung name; needs MORPH_BUILD_FORMS_QML) +# gui/*.cpp -> ladder__gui EXE (desktop client; skipped under Emscripten) +# gui_wasm/*.cpp -> ladder__gui_wasm EXE (Emscripten only; needs MORPH_CLIENT_ONLY) +# src/server/*.cpp -> ladder__server EXE (standalone server; skipped under Emscripten) +# tests/*.cpp -> ladder__tests EXE (Catch2; skipped under Emscripten) +# src/headless/*.cpp -> ladder__headless EXE (QProcess test-client binary, rung 4+) +# +# Every ctest case discovered from ladder__tests gets labels "ladder" +# and "ladder-" (the CI path-filter unit — see .github/workflows/ci.yml, +# job ladder-tests) via the same two-step catch_discover_tests + file(GENERATE) +# shape examples/common/CMakeLists.txt uses (catch_discover_tests cannot carry +# a multi-value LABELS directly — see that file's own comment on why). +# +# RESOURCE_LOCK is the literal string "morph_ladder_test_db" for every rung's +# tests, matching examples/common's own ladder_common_tests — deliberately +# the *same* name across every rung/binary, not a per-rung one: ctest's +# RESOURCE_LOCK serializes any two ctest cases sharing a lock name even +# across different test *binaries*, which is exactly what's needed if two +# rungs' test binaries ever point at the same on-disk database file (e.g. a +# shared ODBC_CONNECTION_STRING override in some future CI leg) — harmless +# extra serialization if they don't. +# +# CONFIGURE_DEPENDS: every file(GLOB_RECURSE ...) below passes it so a newly +# added source file re-triggers CMake's configure step on the next build +# without an explicit reconfigure. This is a Ninja/Makefiles-generator +# feature (silently a no-op elsewhere, per CMake's own docs); every preset in +# this repo's CMakePresets.json inherits from base-linux or base-vcpkg, both +# of which pin "generator": "Ninja", so this is safe repo-wide today. If a +# non-Ninja/Makefiles preset is ever added, new ladder source files added +# under that preset would need an explicit reconfigure (`cmake --preset ...`) +# before they show up in the build — CONFIGURE_DEPENDS would silently not +# catch them. +function(morph_add_rung) + set(options "") + set(oneValueArgs NAME) + set(multiValueArgs "") + cmake_parse_arguments(RUNG "${options}" "${oneValueArgs}" "${multiValueArgs}" ${ARGN}) + + if(NOT RUNG_NAME) + message(FATAL_ERROR "morph_add_rung() requires NAME ") + endif() + # examples/common/CMakeLists.txt returns early under Emscripten, right + # after defining morph_ladder_gui/morph_ladder_app but before + # morph_ladder_testkit (Catch2 + the Lightweight/ODBC-backed testkit have + # no place in a browser build — see that file's own "WebAssembly build" + # comment). So the "was common added" check below must not require + # morph_ladder_testkit under Emscripten, or every rung's WASM configure + # (ladder__gui_wasm) fails here even though common/ was added + # correctly and every target this function actually needs exists. + if(EMSCRIPTEN) + if(NOT TARGET morph_ladder_app) + message(FATAL_ERROR "morph_add_rung(NAME ${RUNG_NAME}) called before examples/common was added " + "(morph_ladder_app does not exist yet) — add_subdirectory(common) first.") + endif() + elseif(NOT TARGET morph_ladder_testkit) + message(FATAL_ERROR "morph_add_rung(NAME ${RUNG_NAME}) called before examples/common was added " + "(morph_ladder_testkit does not exist yet) — add_subdirectory(common) first.") + endif() + + set(_dir "${CMAKE_CURRENT_SOURCE_DIR}") + set(_rung "${RUNG_NAME}") + + # examples/common/CMakeLists.txt already calls find_package(Qt6 ... + # COMPONENTS Core WebSockets) and qt_standard_project_setup(), but that + # call's IMPORTED targets (Qt6::Core etc.) and qt_standard_project_setup's + # directory-scoped defaults are visible only in common/'s own directory + # scope and its subdirectories — CMake does not propagate find_package() + # imported targets sideways to sibling directories. examples// is a + # *sibling* of common/ (both are add_subdirectory()'d from + # examples/CMakeLists.txt), not a descendant of it, so without this, + # ladder__lib's `target_link_libraries(... Qt6::Core)` below fails + # with "target was not found" the first time this function is actually + # exercised (verified empirically: pastebin, the first real rung, hits + # exactly this). Calling both again here is cheap and, per Qt's own docs, + # idempotent/harmless if some ancestor scope already ran them — this is + # the one place in the whole rung that needs it, since every target below + # is created in *this* function's (i.e. the calling rung directory's) scope. + find_package(Qt6 6.5 REQUIRED COMPONENTS Core) + qt_standard_project_setup(REQUIRES 6.5) + + # ── 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 + # 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 + # to build under Emscripten at all. + if(NOT EMSCRIPTEN) + file(GLOB_RECURSE _lib_sources CONFIGURE_DEPENDS + "${_dir}/src/models/*.cpp" "${_dir}/src/db/*.cpp" "${_dir}/src/app/*.cpp") + # The rung's public headers are listed as target sources purely so + # AUTOMOC sees them. AUTOMOC looks for a Q_OBJECT header next to the + # .cpp of the same basename, and a rung's layout deliberately splits + # those apart (include//app/app.hpp vs src/app/app.cpp), so a + # QObject declared in include/ gets no moc output at all otherwise — + # which a static library happily builds and only fails at the first + # link that actually needs the vtable (pastebin::app::App, hit the + # moment ladder_pastebin_tests linked it). Header entries are not + # compiled; they only join the AUTOMOC scan. + file(GLOB_RECURSE _lib_headers CONFIGURE_DEPENDS "${_dir}/include/*.hpp") + if(_lib_sources) + add_library(ladder_${_rung}_lib STATIC ${_lib_sources} ${_lib_headers}) + add_library(morph::ladder_${_rung}_lib ALIAS ladder_${_rung}_lib) + # examples/common (PROJECT_SOURCE_DIR, not a "../common" relative + # path — see examples/CMakeLists.txt's own comment on why: robust to + # morph being embedded via add_subdirectory() in a parent project) + # is on the include path for clock.hpp, the ladder-wide injectable + # "now()" every rung's time-dependent model logic reads instead of + # DateTime::now() directly (examples/common/clock.hpp's own doc + # comment). Discovered as a real gap, not present in the original + # sketch: unlike morph_ladder_gui/_app/_testkit (which each add + # examples/common to their own PUBLIC include path), + # ladder__lib links none of those three — it is the one target + # in this function with model/app code that needs clock.hpp but no + # other reason to depend on morph::ladder_gui and its Qt-Core-only + # constraint, so its own include path needs common added directly. + target_include_directories(ladder_${_rung}_lib PUBLIC "${_dir}/include" "${PROJECT_SOURCE_DIR}/examples/common") + target_link_libraries(ladder_${_rung}_lib PUBLIC morph::morph Lightweight::Lightweight Qt6::Core) + target_compile_features(ladder_${_rung}_lib PUBLIC cxx_std_23) + set_target_properties(ladder_${_rung}_lib PROPERTIES AUTOMOC ON) + apply_bigobj(ladder_${_rung}_lib) + # Lightweight's headers are not -Werror clean (bank's own caveat, + # examples/bank/CMakeLists.txt) — no apply_warnings() here. + if(AF_COVERAGE) + apply_coverage(ladder_${_rung}_lib) + endif() + endif() + endif() + + # ── ladder__gui_lib: presenters + forms-controller glue ─────── + file(GLOB_RECURSE _gui_lib_sources CONFIGURE_DEPENDS "${_dir}/gui_lib/*.cpp") + if(_gui_lib_sources) + add_library(ladder_${_rung}_gui_lib STATIC ${_gui_lib_sources}) + add_library(morph::ladder_${_rung}_gui_lib ALIAS ladder_${_rung}_gui_lib) + target_include_directories(ladder_${_rung}_gui_lib PUBLIC "${_dir}/include" "${_dir}/gui_lib") + target_link_libraries(ladder_${_rung}_gui_lib PUBLIC morph::morph morph::ladder_gui Qt6::Core) + if(TARGET ladder_${_rung}_lib) + target_link_libraries(ladder_${_rung}_gui_lib PUBLIC morph::ladder_${_rung}_lib) + # ladder_${_rung}_lib links Lightweight::Lightweight PUBLIC, and + # Lightweight's own target_include_directories() call is plain + # PUBLIC, not SYSTEM (its CMakeLists.txt) -- so without this, + # apply_warnings() below (-Werror included) applies in full to + # every Lightweight header this target transitively sees, not + # just this rung's own code. examples/bank/CMakeLists.txt's own + # workaround for the identical problem is to skip + # apply_warnings() entirely on the target that links Lightweight + # directly (ladder_${_rung}_lib does the same, just above); this + # target doesn't include any Lightweight header itself, so + # demoting the transitive include path to SYSTEM here — rather + # than also giving up apply_warnings() on it — keeps this rung's + # own gui_lib/*.cpp fully warned while silencing what is, + # from here, third-party noise. + get_target_property(_lightweight_includes Lightweight::Lightweight INTERFACE_INCLUDE_DIRECTORIES) + if(_lightweight_includes) + target_include_directories(ladder_${_rung}_gui_lib SYSTEM PUBLIC ${_lightweight_includes}) + endif() + unset(_lightweight_includes) + endif() + target_compile_features(ladder_${_rung}_gui_lib PUBLIC cxx_std_23) + set_target_properties(ladder_${_rung}_gui_lib PROPERTIES AUTOMOC ON) + apply_warnings(ladder_${_rung}_gui_lib) + apply_bigobj(ladder_${_rung}_gui_lib) + if(AF_COVERAGE) + apply_coverage(ladder_${_rung}_gui_lib) + endif() + endif() + + # ── ladder__qml: the rung's own QML module ───────────────────── + # gui/qml/*.qml becomes a proper QML module (URI = the rung name with its + # first letter capitalised, e.g. "Pastebin"), built as its own static + # library rather than folded into the gui executable — exactly the shape + # examples/forms/gui_qml uses (lab_forms_demo_module + the morph_forms_qml + # executable linking lab_forms_demo_moduleplugin). It has to be a separate + # target because *three* consumers need those QML files: the desktop + # client, the WASM client, and the rung's own offscreen engine-load smoke + # test (examples/TESTING.md, presenter rule 6), which lives in the test + # binary. Built under Emscripten too, for the WASM client's sake — the + # ladder's "same client code" rule means the browser loads the identical + # Main.qml, not a copy (contrast bank's gui_wasm, which re-declares its own + # QML module over the native GUI's files). + # + # Gated on morph_qt_forms (i.e. MORPH_BUILD_FORMS_QML=ON, which also builds + # the shipped MorphForms module the rung's Main.qml imports for + # DynamicForm). Without it there is no schema-driven renderer to compose, + # so the QML module, the desktop client, and the smoke test are all skipped + # together — announced, never silently: the ladder CI leg's distro Qt is + # 6.4.2, below the 6.5 floor MORPH_BUILD_FORMS_QML requires, so that leg + # legitimately configures without any of this. This block announces the + # half it owns (the QML module and, through it, the smoke test); the + # desktop client's block below announces its own skip, for this and every + # other reason it can be skipped. + # + # morph_forms_moduleplugin is forward-referenced: add_subdirectory(src/qt/forms) + # runs *after* add_subdirectory(examples) in the root CMakeLists.txt (both + # deferrals are documented there). A plain, non-namespaced target name may + # be named before it exists; morph_qt_forms — the thing this gates on — is + # created earlier, before the examples, so the guard itself is sound. + set(_qml_plugin "") + file(GLOB_RECURSE _qml_files CONFIGURE_DEPENDS "${_dir}/gui/qml/*.qml") + if(_qml_files AND NOT TARGET morph_qt_forms) + message(STATUS "morph_add_rung: rung '${_rung}' has gui/qml/ but MORPH_BUILD_FORMS_QML is OFF " + "— skipping ladder_${_rung}_qml and the QML smoke test") + endif() + if(_qml_files AND TARGET morph_qt_forms) + find_package(Qt6 6.5 REQUIRED COMPONENTS Gui Qml Quick QuickControls2) + string(SUBSTRING "${_rung}" 0 1 _uri_head) + string(SUBSTRING "${_rung}" 1 -1 _uri_tail) + string(TOUPPER "${_uri_head}" _uri_head) + set(_qml_uri "${_uri_head}${_uri_tail}") + # GLOB_RECURSE yields absolute paths, which qt_add_qml_module + # refuses to place in a resource without an explicit alias. Alias + # each file to its bare name so the module's resource layout is + # flat (qrc:/qt/qml//Main.qml) and independent of where inside + # gui/qml/ the file happens to live. + foreach(_qml_file IN LISTS _qml_files) + cmake_path(GET _qml_file FILENAME _qml_name) + set_source_files_properties("${_qml_file}" PROPERTIES QT_RESOURCE_ALIAS "${_qml_name}") + endforeach() + qt_add_library(ladder_${_rung}_qml STATIC) + qt_add_qml_module(ladder_${_rung}_qml + URI ${_qml_uri} + VERSION 1.0 + QML_FILES ${_qml_files} + ) + target_link_libraries(ladder_${_rung}_qml PUBLIC morph_forms_moduleplugin Qt6::Quick Qt6::Qml) + target_compile_features(ladder_${_rung}_qml PUBLIC cxx_std_23) + set(_qml_plugin ladder_${_rung}_qmlplugin) + endif() + + # ── ladder__gui: desktop client (native only) ────────────────── + # + # Absence of gui/*.cpp is the silent, expected case — that is just the + # convention this file's header describes ("a rung with no gui_wasm/ yet + # simply gets no ladder__gui_wasm target"). But a rung that *has* + # gui/*.cpp clearly wants a desktop client, so every reason this target + # can then fail to appear is announced instead: the alternative is the + # target silently vanishing from an otherwise successful configure, which + # surfaces only as a "no such target" much later. Each reason is collected + # rather than short-circuited so a rung missing two prerequisites hears + # about both in one pass. + # + # The `NOT _qml_files` branch is the forward-looking one: no rung today + # ships gui/*.cpp without gui/qml/, but a future rung that builds its UI + # with QtWidgets, or reuses another module's QML files, would land exactly + # there — and would otherwise get no diagnostic at all, since the QML + # block above only speaks up when gui/qml/ exists and morph_qt_forms does + # not. + if(NOT EMSCRIPTEN) + file(GLOB_RECURSE _gui_sources CONFIGURE_DEPENDS "${_dir}/gui/*.cpp") + set(_gui_skips "") + if(_gui_sources AND NOT TARGET ladder_${_rung}_gui_lib) + list(APPEND _gui_skips "it has no gui_lib/*.cpp, so there is no ladder_${_rung}_gui_lib to link") + endif() + if(_gui_sources AND NOT _qml_plugin) + if(NOT _qml_files) + list(APPEND _gui_skips "it has no gui/qml/*.qml, so there is no ladder_${_rung}_qml module to link") + else() + list(APPEND _gui_skips "MORPH_BUILD_FORMS_QML is OFF, so ladder_${_rung}_qml was not built") + endif() + endif() + if(_gui_skips) + list(JOIN _gui_skips "; and " _gui_skip_why) + message(STATUS "morph_add_rung: rung '${_rung}' has gui/*.cpp but ladder_${_rung}_gui is skipped " + "— ${_gui_skip_why}") + endif() + if(_gui_sources AND NOT _gui_skips) + find_package(Qt6 6.5 REQUIRED COMPONENTS Gui Qml Quick QuickControls2) + qt_add_executable(ladder_${_rung}_gui ${_gui_sources}) + target_link_libraries(ladder_${_rung}_gui PRIVATE + morph::ladder_${_rung}_gui_lib morph::ladder_app ${_qml_plugin} + Qt6::Core Qt6::Gui Qt6::Qml Qt6::Quick Qt6::QuickControls2) + target_compile_definitions(ladder_${_rung}_gui PRIVATE MORPH_LADDER_QML_URI="${_qml_uri}") + target_compile_features(ladder_${_rung}_gui PRIVATE cxx_std_23) + set_target_properties(ladder_${_rung}_gui PROPERTIES AUTOMOC ON) + apply_bigobj(ladder_${_rung}_gui) + if(AF_COVERAGE) + apply_coverage(ladder_${_rung}_gui) + endif() + endif() + endif() + + # ── ladder__gui_wasm: Emscripten client ──────────────────────── + # + # Same shape as the desktop client above, and deliberately so: it links the + # same gui_lib, the same morph::ladder_app (AppContext), and the same + # ladder__qml module, so the only file that differs between the two + # clients is main()/main_wasm.cpp (examples/TESTING.md, "same client code"; + # bank's shadow-header pattern is explicitly banned there). Its skip + # reasons are announced for the same reason the desktop block announces + # its own. + if(EMSCRIPTEN) + file(GLOB_RECURSE _gui_wasm_sources CONFIGURE_DEPENDS "${_dir}/gui_wasm/*.cpp") + set(_gui_wasm_skips "") + if(_gui_wasm_sources AND NOT TARGET ladder_${_rung}_gui_lib) + list(APPEND _gui_wasm_skips "it has no gui_lib/*.cpp, so there is no ladder_${_rung}_gui_lib to link") + endif() + if(_gui_wasm_sources AND NOT _qml_plugin) + if(NOT _qml_files) + list(APPEND _gui_wasm_skips "it has no gui/qml/*.qml, so there is no ladder_${_rung}_qml module to load") + else() + list(APPEND _gui_wasm_skips "MORPH_BUILD_FORMS_QML is OFF, so ladder_${_rung}_qml was not built") + endif() + endif() + if(_gui_wasm_skips) + list(JOIN _gui_wasm_skips "; and " _gui_wasm_skip_why) + message(STATUS "morph_add_rung: rung '${_rung}' has gui_wasm/*.cpp but ladder_${_rung}_gui_wasm " + "is skipped — ${_gui_wasm_skip_why}") + endif() + # A ladder WASM client is a *pure remote client* (IMPLEMENTATION.md + # rule 4's WASM clause: persistence lives server-side), but it still + # has to name its rung's model type — BridgeHandler is a + # template over it. Without MORPH_CLIENT_ONLY the registrars that + # closure over Model's constructor and execute() bodies are still + # 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. + 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 " + "-DMORPH_CLIENT_ONLY=ON. A WASM client dispatches every action to a server and " + "never hosts a model, but without that option morph still emits the model-owning " + "registrars, whose closures reference the model's ODBC-backed execute() bodies — " + "unlinkable in a browser. See docs/spec/core/registry.md, \"MORPH_CLIENT_ONLY\".") + endif() + if(_gui_wasm_sources AND NOT _gui_wasm_skips) + find_package(Qt6 REQUIRED COMPONENTS Gui Qml Quick QuickControls2) + qt_add_executable(ladder_${_rung}_gui_wasm ${_gui_wasm_sources}) + target_link_libraries(ladder_${_rung}_gui_wasm PRIVATE + morph::morph morph::qt morph_qt_impl + morph::ladder_${_rung}_gui_lib morph::ladder_app ${_qml_plugin} + Qt6::Core Qt6::Gui Qt6::Qml Qt6::Quick Qt6::QuickControls2) + target_compile_definitions(ladder_${_rung}_gui_wasm PRIVATE MORPH_LADDER_QML_URI="${_qml_uri}") + target_compile_features(ladder_${_rung}_gui_wasm PRIVATE cxx_std_23) + set_target_properties(ladder_${_rung}_gui_wasm PROPERTIES AUTOMOC ON) + endif() + endif() + + # ── ladder__server: standalone server binary (native only) ──── + if(NOT EMSCRIPTEN) + file(GLOB_RECURSE _server_sources CONFIGURE_DEPENDS "${_dir}/src/server/*.cpp") + if(_server_sources AND TARGET ladder_${_rung}_lib) + add_executable(ladder_${_rung}_server ${_server_sources}) + target_link_libraries(ladder_${_rung}_server PRIVATE + morph::ladder_${_rung}_lib morph::qt morph_qt_impl Qt6::Core) + target_compile_features(ladder_${_rung}_server PRIVATE cxx_std_23) + apply_bigobj(ladder_${_rung}_server) + if(AF_COVERAGE) + apply_coverage(ladder_${_rung}_server) + endif() + endif() + endif() + + # ── ladder__tests: Catch2 model + presenter tests ────────────── + if(NOT EMSCRIPTEN) + file(GLOB_RECURSE _test_sources CONFIGURE_DEPENDS "${_dir}/tests/*.cpp") + if(_test_sources) + # examples/common/testkit/testkit_main.cpp is compiled into every + # rung's test binary rather than linked from morph_ladder_testkit: + # that library links Catch2::Catch2 (the no-main variant), so a + # rung whose tests/ holds only TEST_CASE translation units has no + # `main` at all and fails to link. The main is Qt-owning (a + # QCoreApplication that outlives every QObject Catch2 constructs — + # see that file's own comment), which every rung needs anyway the + # moment it touches BackendRig's Socket mode. It stays a compiled + # source rather than a library member so ladder_common_tests, which + # already compiles the same file directly, keeps exactly one + # definition of `main`. + add_executable(ladder_${_rung}_tests + ${_test_sources} + "${PROJECT_SOURCE_DIR}/examples/common/testkit/testkit_main.cpp") + target_link_libraries(ladder_${_rung}_tests PRIVATE morph::ladder_testkit) + # ctest runs a rung's test binary from its own build directory, so + # repo-relative test data (e.g. tests/fuzz/findings/*, replayed as + # hostile paste content by pastebin's model suite) cannot be found + # by a relative path. Compile the source root in instead — the same + # thing tests/fuzz/CMakeLists.txt does by passing absolute corpus + # paths on the command line, expressed here as a macro because a + # Catch2 binary takes no such arguments. + target_compile_definitions(ladder_${_rung}_tests + PRIVATE MORPH_LADDER_SOURCE_ROOT="${PROJECT_SOURCE_DIR}") + if(TARGET ladder_${_rung}_lib) + # WHOLE_ARCHIVE, not a plain link: a rung's schema TU + # (src/db/schema.cpp) contributes nothing but static-init + # side effects — LIGHTWEIGHT_SQL_MIGRATION registers the + # rung's tables with the process-wide MigrationManager from a + # namespace-scope initializer. No test references a symbol in + # that TU, so an ordinary static-library link never pulls the + # object in and DbFixture::ApplyPendingMigrations() finds no + # migrations at all ("no such table: pastes"). Pulling the + # whole archive is the standard fix and keeps the schema + # exactly where IMPLEMENTATION.md rule 4 puts it, instead of + # making every rung's test suite name a dummy symbol to force + # the link. + target_link_libraries(ladder_${_rung}_tests PRIVATE + "$") + # Same SYSTEM-include demotion as ladder_${_rung}_gui_lib's own + # identical block above, and for the identical reason: + # Lightweight's target_include_directories() call is plain + # PUBLIC, not SYSTEM, so apply_warnings() below (-Werror + # included) would otherwise apply in full to every Lightweight + # header a test TU reaches (directly, by testing the model + # layer, or transitively through template instantiation). + get_target_property(_lightweight_includes Lightweight::Lightweight INTERFACE_INCLUDE_DIRECTORIES) + if(_lightweight_includes) + target_include_directories(ladder_${_rung}_tests SYSTEM PRIVATE ${_lightweight_includes}) + endif() + unset(_lightweight_includes) + endif() + if(TARGET ladder_${_rung}_gui_lib) + target_link_libraries(ladder_${_rung}_tests PRIVATE morph::ladder_${_rung}_gui_lib) + endif() + # The rung's QML module, so its offscreen engine-load smoke test + # (examples/TESTING.md, presenter rule 6) can load the *same* + # Main.qml the desktop client ships — not a copy. + # + # MORPH_LADDER_QML_URI is what makes that test compile at all: it is + # `#ifdef`-guarded on this macro, so a configure without the QML + # module (see the ladder__qml block above) simply compiles it + # to an empty translation unit instead of failing on a missing + # . + # + # MORPH_LADDER_TESTKIT_GUI_APP switches testkit_main.cpp's owned + # application object from QCoreApplication to QGuiApplication for + # this one binary. Qt Quick cannot instantiate an ApplicationWindow + # under a plain QCoreApplication — QWindow needs a platform + # integration, which only QGuiApplication creates — so without this + # the smoke test aborts rather than failing. Presenter rule 1 + # ("presenters must instantiate under a plain QCoreApplication") + # keeps its teeth where it is actually enforced: ladder__gui_lib + # links Qt6::Core and nothing else, and ladder_common_tests still + # runs its presenter suite under a bare QCoreApplication. + if(_qml_plugin) + target_link_libraries(ladder_${_rung}_tests PRIVATE + ${_qml_plugin} Qt6::Gui Qt6::Qml Qt6::Quick Qt6::QuickControls2) + target_compile_definitions(ladder_${_rung}_tests PRIVATE + MORPH_LADDER_QML_URI="${_qml_uri}" MORPH_LADDER_TESTKIT_GUI_APP) + endif() + target_compile_features(ladder_${_rung}_tests PRIVATE cxx_std_23) + set_target_properties(ladder_${_rung}_tests PROPERTIES AUTOMOC ON) + apply_warnings(ladder_${_rung}_tests) + apply_bigobj(ladder_${_rung}_tests) + if(AF_COVERAGE) + apply_coverage(ladder_${_rung}_tests) + endif() + + include(Catch) + get_target_property(_qt_core_dll Qt6::Core IMPORTED_LOCATION) + cmake_path(GET _qt_core_dll PARENT_PATH _qt_bin_dir) + catch_discover_tests(ladder_${_rung}_tests + DISCOVERY_MODE POST_BUILD + DL_PATHS "${_qt_bin_dir}" + PROPERTIES LABELS ladder TIMEOUT 120 RESOURCE_LOCK morph_ladder_test_db + ) + file(GENERATE + OUTPUT "${CMAKE_CURRENT_BINARY_DIR}/ladder_${_rung}_tests_rung_label.cmake" + CONTENT "foreach(_ladder_test IN LISTS ladder_${_rung}_tests_TESTS) + if(NOT _ladder_test MATCHES \"\\\"class-name\\\"\") + set_tests_properties(\"\${_ladder_test}\" PROPERTIES LABELS \"ladder;ladder-${_rung}\") + endif() +endforeach() +" + ) + set_property(DIRECTORY APPEND PROPERTY TEST_INCLUDE_FILES + "${CMAKE_CURRENT_BINARY_DIR}/ladder_${_rung}_tests_rung_label.cmake") + endif() + endif() + + # ── ladder__headless: QProcess test-client binary (rung 4+) ──── + file(GLOB_RECURSE _headless_sources CONFIGURE_DEPENDS "${_dir}/src/headless/*.cpp") + if(_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) + target_compile_features(ladder_${_rung}_headless PRIVATE cxx_std_23) + apply_bigobj(ladder_${_rung}_headless) + endif() + + message(STATUS "morph_add_rung: registered rung '${_rung}'") +endfunction() diff --git a/codecov.yml b/codecov.yml index 539354a7..bf0bbf1d 100644 --- a/codecov.yml +++ b/codecov.yml @@ -1,13 +1,23 @@ # Codecov configuration. # -# Coverage is produced by the `clang-coverage` CI job via scripts/coverage.sh, -# restricted to the library headers under include/morph (tests, demo src/ and -# fetched dependencies are excluded by the positional source filter to llvm-cov). +# Coverage is produced by the `clang-coverage` CI job via scripts/coverage.sh: +# always include/morph (the library), plus examples/common (the ladder's +# hand-written GUI/testkit code — real coverage of morph's own client stack, +# not app-specific logic; see examples/TESTING.md's "round-7 T4 reframe") and +# every built rung's own models/app/presenter code, whenever that leg's +# configure also builds the ladder. Tests, a rung's `main()` shells +# (`gui/`, `gui_wasm/`), demo src/, fetched dependencies, and +# AUTOMOC-generated files (which live under the build tree, never under a +# source-tree path this config names) are excluded. coverage: - # Statuses are informational so a coverage delta never blocks a PR; they still - # render the project/patch numbers on the checks list. status: + # Default (include/morph, i.e. everything not claimed by a component + # below): informational only, unchanged from before this file started + # tracking the ladder. This project's own IMPLEMENTATION.md rule 5 has + # never claimed the whole library is 100% covered — only "models" (and + # now the ladder's hand-written GUI/testkit code, see the component + # below) carry that promise, so only that promise is a blocking gate. project: default: informational: true @@ -15,18 +25,148 @@ coverage: default: informational: true +# The ladder's hand-written GUI/testkit code is held to the same 100% bar +# examples/IMPLEMENTATION.md rule 5 sets for models (there are no rung +# models yet — rung 0 ships no app — so this component is the whole of that +# promise today; src/models/ and include//models/ join it as rungs +# land). Scoped to examples/common specifically, not project-wide: a +# blocking gate over the *entire* codebase is a much bigger, unverified +# claim this repo has never made and this change does not attempt. +# +# Target is 98%, not a literal 100%, for a measurement-tooling reason rather +# than an intentional gap: llvm-cov's source-based coverage places a +# "control reached past this block" counter on the closing brace of certain +# blocks (a switch-case's `}` after `break;`, a scope's `}` after its one +# statement calls a `std::function`), and that counter can read 0 +# even though the statement immediately above it — proven by its own hit +# count — ran. There is no llvm-cov equivalent of gcov's inline +# `LCOV_EXCL_LINE` to suppress just those lines. Confirmed present-day +# instances, all in hand-written (non-test) files, each already directly +# exercised by an existing test per llvm-cov's own count on the preceding +# line: backend_rig.hpp's three switch-case closing braces (BackendRig's +# constructor, one per Mode), strand_interleaver.hpp's two post-`task()` +# closing braces (`step()`, `runSchedule()`), and fault_proxy.cpp's one +# integration-unreachable line pair (onClientConnection's null-guard — +# Qt's own newConnection contract guarantees a valid pointer in practice; +# the underlying decision, isValidIncomingConnection, is unit-tested +# directly). Together these put today's real ceiling at 478/485 = 98.56% +# lines — re-measured at rung 1's close, when `examples/common` gained +# `db_busy_fixture.hpp` and `backend_rig.hpp`'s executor-liveness guard. The +# artifact *list* above is unchanged (the same seven lines); only the +# denominator moved. 98% leaves a small margin below that measured ceiling +# rather than sitting exactly on it, while still failing the gate long before +# a real, newly-introduced gap could hide behind this handful of known +# artifacts. +# +# Per-rung components, one per rung, rather than one component spanning the +# whole ladder: each rung's real ceiling is set by its own handful of known +# artifacts, and folding them together would mean re-deriving a single number +# every time a rung lands. A rung's component simply appears when its +# directory does. +component_management: + individual_components: + - component_id: ladder + name: "application ladder (examples/common)" + paths: + - examples/common/** + statuses: + - type: project + target: 98% + informational: false + - type: patch + target: 98% + informational: false + + # Rung 1, pastebin. + # + # What is actually measured, precisely — the `paths` glob below is + # `examples/pastebin/**`, but a component can only score files the + # uploaded report contains, and that report is whatever + # `scripts/coverage.sh` names in its `SOURCES` array. For this rung that + # is `include/`, `src/` and `gui_lib/`: the DTOs, the model and app + # bootstrap, and the hand-written presenter/QML-adapter layer. It is + # **not** `gui/` or `gui_wasm/` — those are `main()` shells (engine setup, + # argv parsing, `setInitialProperties`) with no unit-testable seam, + # exercised by the offscreen QML engine-load smoke test and by hand, and + # they are named in `ignore:` below so their absence is a decision rather + # than an accident. `tests/` is excluded for the same reason + # examples/common's is: a suite scoring its own test code inflates the + # number it is supposed to police. + # + # Same reasoning as the component above for the target: 96%, not a + # literal 100%, because of a measured ceiling rather than an intentional + # gap. Measured with `llvm-cov report` over that denominator: + # 442/450 lines = 98.22%. Every one of the eight missed lines is + # accounted for: + # * units.hpp (2) — the `default:` arm of `UnitTraits::meta`'s + # switch. `Unit` has exactly one enumerator, so that arm is + # unreachable without undefined behavior; it exists because the + # repo's warning policy requires a switch default. + # * src/app/app.cpp (4) — `sweepExpiredOnce()`'s `.onError` branch, + # which logs an `ExpirePaste` that failed to dispatch. Provoking a + # dispatch failure through a `SimulatedRemoteBackend` needs the + # fault-injection proxy that lands at rung 4; until then there is no + # honest way to reach it. + # * src/models/paste_model.cpp (2) — the `rows.empty()` guard in + # `execute(GetPaste)`'s read-back, taken when the row vanishes + # between an `UPDATE` that just matched it and a `SELECT` in the same + # transaction, while that transaction holds the write lock. The + # source documents it as unreachable in practice and treats it as + # "gone" rather than asserting. + # `gui_lib/` itself is fully covered: `paste_presenter.cpp`, + # `paste_qml_bridges.cpp`, `paste_forms_controller.cpp` and both headers' + # inline bodies are at 100% lines, by `tests/test_paste_presenter.cpp` and + # `tests/test_paste_qml_bridges.cpp`. + # + # 96%, not something nearer the 98.22% ceiling, for two reasons: it leaves + # a margin below that ceiling rather than sitting on it, and the ceiling + # is not perfectly stable — `paste_model.cpp` scores 2 or 3 missed lines + # depending on the run, because the two `DbBusyFixture` store-error cases + # race a real SQLite lock and which classifier branch they land in is + # genuinely timing-dependent. A target within a line or two of the ceiling + # would flake on that alone. 96% still fails long before a real, + # newly-introduced gap could hide behind these eight lines. + - component_id: pastebin + name: "application ladder rung 1 (examples/pastebin)" + paths: + - examples/pastebin/** + statuses: + - type: project + target: 96% + informational: false + - type: patch + target: 96% + informational: false + # Always post the coverage-comparison comment on a PR, even on the first upload # after activation and even when the base report is still processing. comment: - layout: "reference, diff, flags, files" + layout: "reference, diff, flags, files, components" behavior: default require_base: false require_head: true require_changes: false -# Only library headers carry coverage; make the exclusion explicit for Codecov's -# own file walking so tests/ and the demo never dilute the reported number. +# Nothing in examples/ other than examples/common and the built rungs ever +# gets compiled by the coverage job's configure (MORPH_BUILD_LADDER builds +# examples/common's targets plus each rung named by MORPH_LADDER_RUNGS and, +# under Emscripten only, wasm_spike — which this job never reaches), so +# bank/forms/concepts/etc. never produce coverage data here in the first +# place; excluding them explicitly documents the intent rather than relying +# on that as an accident of what happens to be built. A rung's own test +# sources are excluded for the same reason examples/common's are: a suite +# scoring its own test code inflates the number it is supposed to police. ignore: - "tests/**" - "src/**" - - "examples/**" + - "examples/bank/**" + - "examples/forms/**" + - "examples/concepts/**" + - "examples/vetted_hmac/**" + - "examples/qt_tls_client/**" + - "examples/common/testkit/test_*.cpp" + - "examples/common/testkit/testkit_main.cpp" + - "examples/common/wasm_spike/**" + - "examples/pastebin/tests/**" + - "examples/pastebin/gui/**" + - "examples/pastebin/gui_wasm/**" diff --git a/docs/spec/core/backend.md b/docs/spec/core/backend.md index 13f83a13..829fc1d1 100644 --- a/docs/spec/core/backend.md +++ b/docs/spec/core/backend.md @@ -241,6 +241,17 @@ leaving nothing to deregister — the same division of responsibility attach", for the caller-visible story and the `_attachMtx` locking rule these two `Bridge` methods must obey. +A backend may invoke either callback **inline**, from inside the dispatch call +itself — `QtWebSocketBackend`'s `!_connected` branch does exactly that, and +this pair's contract does not forbid it on the success path either. +`Bridge::attachHandlerAsync`/`ensureBoundAsync` handle that case explicitly +(they defer the outcome out of the dispatch frame rather than acting on it +under `_attachMtx`), so an inline completion is legal, not merely tolerated. + +`assignPrimary` — the *promote* half of a result-keyed action — has **no** +async counterpart and is not covered here: it is still synchronous on every +backend, so a result-keyed creating action still blocks at that step. + ## Error types Five exception types are thrown into in-flight `Completion`s. The first four are diff --git a/docs/spec/core/shared_instances.md b/docs/spec/core/shared_instances.md index 98221eeb..47f3489b 100644 --- a/docs/spec/core/shared_instances.md +++ b/docs/spec/core/shared_instances.md @@ -331,6 +331,48 @@ action, and a result-keyed dispatch promotes its binding through `assignHandlerPrimary`, which takes `_attachMtx` itself. It is the same rule `registerHandlerImpl` already follows for `_mtx`. +The rule holds unconditionally, including for a backend that completes its +callback **inline** — synchronously, from inside `attachModelAsync` / +`registerModelSharedAsync`, while the dispatching frame still holds the lock. +`QtWebSocketBackend` does this today on its `!_connected` branch (it reports +`onError("disconnected")` and returns `true`), and nothing in `IBackend` +forbids a backend from doing it on the *success* path too. An inline callback +therefore parks its outcome instead of acting on it, and the dispatching frame +applies it after its own dispatch call returns: publish under the lock it +already holds, release, then report. See +[bridge.md](bridge.md), "Thread safety", for the mechanism. + +**Known gap: no in-flight attach dedup.** Two calls for the *same* key issued +before the first one's reply arrives are not coalesced. Both +`attachHandlerAsync` and `ensureBoundAsync` guard on binding state +(`primary`/`currentId`) that is only updated when the reply lands, so both +calls pass the guard and both dispatch. This is a real behaviour difference +from the synchronous predecessors, not merely something inherent to asynchrony: +`attachHandler` held `_attachMtx` across the whole blocking round trip, which +serialised concurrent callers for free. It takes no second thread to hit — +two `handler.execute(...)` calls in one event-loop turn are enough. The server +answers both with the same `ModelId` but records two attachments, so one +server-side attach reference leaks. The leak is **bounded, not unbounded**: the +connection scope releases every reference it holds when the connection closes +(see "Lifetime and the A7 connection-scope change" below). Closing it properly +needs in-flight tracking on the binding, so a second caller rides the first +dispatch's completion instead of issuing its own; tracked as a follow-up. +Until then, a caller should not fire the same keyed action twice back-to-back +before the first settles. + +**Not covered: the result-keyed *promote* step is still synchronous.** This +section made the **bind** half of a result-keyed action async +(`ensureBoundAsync` → `registerModelSharedAsync`). The **promote** half did +not change: `Bridge::assignHandlerPrimary` still calls the synchronous +`IBackend::assignPrimary`, which on `QtWebSocketBackend` is a `sendSync` — +a nested `QEventLoop`. There is no `assignPrimaryAsync`. So a **WASM client +dispatching a result-keyed creating action** (a `CreatePoll`-shaped action: +create the entity, adopt the key its result carries) still blocks, and still +aborts the page, at the promote step — after the bind step this section fixed +already succeeded. Payload-keyed actions (`OpenPoll{pollId}`-shaped, the +attach path) are fully covered and do not block. Giving `assignPrimary` an +async form is a separate follow-up. + ## Ownership and authorization `RemoteServer` records an `ownerPrincipal` for each instance at register time @@ -377,8 +419,10 @@ attached to, so a scope entry is a **reference**, not ownership: - The instance is destroyed when the count reaches zero, at which point it leaves the directory. - `closeConnection` remains idempotent and still bypasses `IAuthorizer`; it - decrements once per scope entry regardless of how many handlers a single - connection had attached. + decrements once per attach a connection made (`noteScopeAttachLocked` + tracks a per-`(connection, instance)` count, so a connection that attached + the same instance from two handlers releases two references, not one) — + a duplicate attach never leaks, it always unwinds fully at connection close. Unshared instances have exactly one attacher by construction, so their lifetime is unchanged: count reaches zero on the same event that erases them today. @@ -404,7 +448,7 @@ strictly reduces pressure on it. | `handler.attach(key)` | `void` | Attaches (or re-points) without executing an action. Synchronous and throwing, by design — see [Async register-or-attach and attach](#async-register-or-attach-and-attach). | | `handler.primary()` | `std::optional` | The handler's current primary; empty if unattached. | | `handler.instances()` | `Completion>` | Snapshot of live shared keys for this model type. | -| `handler.execute(keyedAction)` | `Completion` | Unchanged signature and contract. Its attach (payload-keyed) or bind-and-promote (result-keyed) step takes the backend's async path when one exists, so the call no longer blocks on a round-trip — visible only as *not aborting a WASM main thread*. See [Async register-or-attach and attach](#async-register-or-attach-and-attach). | +| `handler.execute(keyedAction)` | `Completion` | Unchanged signature and contract. Its **attach** step (payload-keyed) and the **bind** step of the result-keyed path take the backend's async path when one exists, so neither blocks on a round-trip — visible only as *not aborting a WASM main thread*. The result-keyed path's **promote** step (`assignPrimary`) is still synchronous and still blocks. See [Async register-or-attach and attach](#async-register-or-attach-and-attach). | | `IBackend::registerModelSharedAsync` / `attachModelAsync` | `bool` | Opt-in non-blocking counterparts to `registerModelShared`/`attachModel`; `false` by default, and callers then fall back to the synchronous method unchanged. | ## Design decisions diff --git a/docs/spec/forms/forms.md b/docs/spec/forms/forms.md index cf9af73e..6582bfff 100644 --- a/docs/spec/forms/forms.md +++ b/docs/spec/forms/forms.md @@ -1615,6 +1615,10 @@ precision is enforced on dispatch" above for why `reconcileDeclaredPrecision` is likewise skipped on that path), so a `Quantity` a caller constructs directly carries whatever value the caller gave it, unchecked at this seam. +### Sum types not in the forms palette — multi-field encoding by design + +The forms vocabulary provides no native sum-type (tagged union, discriminated union) support. When an action field must express *one of several alternatives* (e.g. a measurement that is "a quantity, or below limit-of-detection, or above upper detection limit"), encode it as a **multi-field structure glued by cross-field rules**: one field for the quantity, one boolean or enum for the state (measured/below/above), and a `RequiredWhen`/`VisibleWhen` rule that gates each based on the others. This is by design: sum types are rare in domain models that already use `hasValue()` optionality and `Choice` enums, and the rule-based multi-field encoding is expressive enough for the rungs' needs while keeping the schema and validation machinery focused. + ### One cached schema per type — no localisation Each type's schema is memoised in a function-local `static const std::string` diff --git a/examples/CMakeLists.txt b/examples/CMakeLists.txt new file mode 100644 index 00000000..2dbb111f --- /dev/null +++ b/examples/CMakeLists.txt @@ -0,0 +1,38 @@ +# SPDX-License-Identifier: Apache-2.0 +# +# The application ladder (examples/LADDER.md). Orchestrates the shared +# infrastructure (common/) and, once MORPH_LADDER_RUNGS names them, the +# individual rung apps. Reached only when MORPH_BUILD_LADDER=ON (see the root +# CMakeLists.txt). + +cmake_minimum_required(VERSION 3.25) + +if(NOT TARGET morph::morph) + message(FATAL_ERROR + "examples/ (the ladder) expects the morph::morph target. Configure from the " + "repository root with -DMORPH_BUILD_LADDER=ON instead of configuring " + "examples/ directly.") +endif() + +# PROJECT_SOURCE_DIR, not CMAKE_SOURCE_DIR: the latter is the *top-level* +# source dir, which is not morph's own root when morph is embedded via +# add_subdirectory() in a parent project. +include(${PROJECT_SOURCE_DIR}/cmake/morph_add_rung.cmake) + +add_subdirectory(common) + +# Rung directories register themselves here as they gain CMakeLists.txt files +# (rung 1 onward). MORPH_LADDER_RUNGS == "all" or a semicolon list selects +# which are configured — see examples/TESTING.md, "Build system and CI". +# No rung exists yet at rung 0, so this loop currently has nothing to do; it +# is real, working selection logic (not a placeholder) that the first rung's +# CMakeLists.txt addition activates without needing to touch this file again. +set(_morph_known_rungs pastebin bookmarks polls kanban) +foreach(_rung ${_morph_known_rungs}) + if(NOT EXISTS "${CMAKE_CURRENT_SOURCE_DIR}/${_rung}/CMakeLists.txt") + continue() + endif() + if(MORPH_LADDER_RUNGS STREQUAL "all" OR _rung IN_LIST MORPH_LADDER_RUNGS) + add_subdirectory(${_rung}) + endif() +endforeach() diff --git a/examples/FINDINGS.md b/examples/FINDINGS.md new file mode 100644 index 00000000..e3d1e352 --- /dev/null +++ b/examples/FINDINGS.md @@ -0,0 +1,86 @@ +# The finding pipeline + +The ladder's product is **findings fixed, not apps shipped**. The holistic +(round-7) review found the "framework-gap ledger" load-bearing in every +governing document yet defined nowhere — so success would have defaulted to +the only thing definitions-of-done measure: apps built. This document +defines the pipeline. + +## What a finding is + +A finding is one of: + +1. **A minimal failing test** checked into `tests/` (preferred — a finding + that cannot be expressed as a failing test is not yet understood), or +2. **A spec-cited impossibility** — a short write-up citing the spec/header + that shows the capability structurally cannot exist today (e.g. "no + holder-swap primitive for in-place undo on a shared instance"). + +Each finding is a file under `docs/findings/` named +`NNN-.md` with: + +```markdown +--- +id: NNN +title: +subsystem: +severity: blocker | major | minor | paper-cut +source: +disposition: open | fix-scheduled | documented-limitation | wontfix +test: +--- + + +``` + +## Triage and dispositions + +Every finding gets a disposition within one triage pass (the repo owner +decides; the ladder never self-triages): + +- **fix-scheduled** — a framework change is planned; the finding's test + stays red-listed (tagged `[finding]`, excluded from the green gate) until + the fix lands, then joins the regression suite permanently. +- **documented-limitation** — the behavior is accepted and the relevant + `docs/spec/` file is updated to say so; the test asserts the *documented* + behavior and turns green. +- **wontfix** — recorded with rationale. + +## Fix budget + +Discovery already outruns repair (the six detail review rounds produced +~40 findings before any rung code existed). The binding ratio: **for every +month of rung construction, at least one week of framework-fix time** is +spent draining `fix-scheduled` findings — including their full docs tax +(spec file, Doxygen, pinned facts). If the open `fix-scheduled` count grows +two rungs in a row, rung construction pauses. + +## Rung exit criteria + +A rung is **done** when: + +1. its README's design questions are resolved in writing, +2. every named strain test exists — passing, or filed as a finding, +3. its findings are triaged (no `open` dispositions left). + +**Feature completeness is explicitly not an exit criterion.** A rung may +exit half-built; Kanboard's remaining thirty tables exert no gravity here. + +## Back-fill + +The ~40 findings from review rounds 1–7 (preserved in the session review +reports and folded into the governing docs) are the program's entire +current output. Back-filling them as `docs/findings/` entries — failing +tests where expressible — is **the first task of rung 0**, before any app +code. The four LADDER prerequisites and the forms-gap ledger entries are +findings 001–0NN. + +## Demotion policy (the ladder must never tax the framework) + +Once a rung exits, it **demotes** in per-PR CI to compile-only plus one +smoke test; its full matrix moves to the weekly tier (see +[`TESTING.md`](TESTING.md), "Build system and CI") instead of running on +every push; its 100%-coverage gate freezes at its exit commit and does not +bind future framework PRs. The instrument built to motivate framework +change must never become the reason a framework fix is too expensive to +land. diff --git a/examples/IMPLEMENTATION.md b/examples/IMPLEMENTATION.md new file mode 100644 index 00000000..09ee1695 --- /dev/null +++ b/examples/IMPLEMENTATION.md @@ -0,0 +1,265 @@ +# Implementation rules for ladder applications + +Binding rules for building every rung of the [application ladder](LADDER.md). +[`TESTING.md`](TESTING.md) governs how the apps are tested; this document +governs how they are *written*. The rules exist to keep the ladder honest: +these applications exist to **stress-test morph**, not to be products. + +**The prime directive: every line of custom code that morph (or Lightweight) +could have provided is a defect in the stress test.** If the framework can't +provide it, that inability is a *finding* — record it per +[`FINDINGS.md`](FINDINGS.md), don't quietly code around it. + +**The promotion rule (rule-of-three, from the round-7 review):** an +app-built answer to a framework gap (the polling helper with its timeout, +an op-id ledger, epoch tokens, a recursive validator, redaction-on-serve) +may be built twice in `examples/`. The moment a **third** rung consumes it, +it must either be **promoted into `include/morph`** (with its full docs +tax, drawn from the fix budget) or **explicitly dispositioned in the spec +as app-layer by design**. Without this rule the ladder ends with a shadow +framework living in `examples/common` — which would be the program's +biggest finding, permanently unfiled. + +## 1. Models are the application + +The user-code contract is: **you implement Models; morph exposes them.** + +- All business logic, all invariants, and all persistence access live in + plain, single-threaded model classes with typed actions — nothing + domain-shaped may live in presenters, QML, `main()`, or free functions. + If logic can't be expressed in a model, that is a finding. +- Follow [`bank`](bank/README.md)'s established shape: `BRIDGE_REGISTER_*` + macros in the model header so every call site sees the `ActionTraits` + specialisation; stateful models keyed with `BRIDGE_KEY_FROM`/ + `BRIDGE_MODEL_KEY` where the domain has identity (account, poll, board, + sample); the model instance is a cache with identity — hydrated on first + use, written through on every mutation, dropped when the instance dies; + the store stays authoritative. +- Models must re-check their own preconditions and authorization + (`Context::principal`) — the schema's `required` and the client gates are + UX, not security (`docs/spec/security.md`). +- Action failures are thrown as the app's typed error set (one + `core/errors.hpp`-style header per rung, as bank does) and surface through + `Completion::onError`; never encode failure as a magic value in a result + DTO. + +## 2. GUI minimalism + +The GUI is deliberately the *least* interesting part of every rung. We are +not building UIs; we are proving morph can drive them. + +- **Schema-driven first, always.** Every form is rendered from + `morph::forms::schemaJson()` through the shipped renderer + (`MorphForms` QML / `FormsControllerCore`); every list/table goes through + `morph::forms` views; navigation uses the workflows/app-shell machinery. + Hand-built input widgets, hand-built tables, and hand-rolled layouts are + **forbidden by default**. +- **A custom GUI element requires a written justification** in the rung + README, and the only two acceptable justifications are: (a) the generated + UI *cannot* express the interaction — which is precisely a forms-subsystem + finding, so file it on the gap ledger (this is how the ladder found the + missing explicit-submit mode, the child-table renderer gap, and the + sum-type gap — see [`LADDER.md`](LADDER.md)); or (b) pure glue with no + domain logic (an app shell frame, a connection-status indicator). +- Presenters follow [`TESTING.md`](TESTING.md) exactly: Qt-Core-only + `gui_lib`, thin QObject presenters over `BridgeHandler`s, QML + bindings-only, timers in the view layer. Presenters translate and route; + they never decide. +- **Zero styling effort.** Default Qt Quick controls, default fonts, no + theming, no animations, no custom drawing. A rung that looks pretty has + spent effort in the wrong place. + +## 3. Type discipline: strong types only + +Action and result DTOs are the library's public stress surface — every field +must exercise morph's typed machinery. + +**The only plain type permitted in an action/result field is +`std::string`** (for genuinely textual data: names, descriptions, paste +content, URLs). Everything else is a strong type: + +| Data | Required type | +|---|---| +| Money, measurements, counts, durations | `morph::units::Quantity` over the rung's unit system (consteval algebra, `UnitTraits` relations for entry units) | +| Exact unitless numbers | `morph::math::Rational` | +| Points in time | `morph::time::Timestamp` / `DateTime` | +| Foreign keys / lookups chosen by a user | `morph::forms::Choice` | +| Entity identity | A per-entity strong id type (e.g. `struct PasteId`) exposing `hasValue()` so it joins the forms palette as an empty-capable field | +| Closed sets of states/options | `enum class` (never a bare integer, never `bool` — a two-state flag is a two-enumerator `enum class`, per the readability rule that call sites must not read `f(true)`) | +| Optional fields | empty-capable state (`hasValue()` / empty `Quantity`) or the action's `optionalFields` opt-out — not `std::optional`, which silently loses schema annotations (see the round-5 review finding in [`LADDER.md`](LADDER.md)) | +| Line items / sub-objects | nested aggregates of the same palette | +| Protocol scalars — pagination cursors, event ids / epoch tokens, op-ids / idempotency keys, base versions, job ids, capability & confirmation tokens | A named opaque newtype per role (e.g. `struct EventId`, `struct Cursor`), `hasValue()`-capable, serialising as its underlying scalar — **never** a bare `int64_t` and never a loose `std::string`. If morph offers no cheap `Tagged` helper that joins glaze and the forms palette, that is a **day-one finding filed once**, not eight hand-rolled wrapper sets (round-7 T2). | + +**Forbidden in any DTO field: `int`, `int64_t`, `double`, `float`, `bool`, +raw enums.** This deliberately supersedes bank's DTO style (integer minor +units, integer ids, enums-as-integers) — bank predates this rule; the +ladder exists to stress the exact-value and schema machinery, and every +bare `int` in a DTO is a missed stress test. Where a strong type doesn't +fit the palette, that is a finding, not a license for `int64_t`. + +Each rung defines its unit system once (`/include//units.hpp`, +modelled on `examples/forms/lab_units.hpp`): the enum, `UnitTraits` +metadata, the consteval algebra, and the exact entry-unit relations. Money +is a unit system too (currency units with per-currency `dp` — respecting +the `DecimalPlaces >= 1` floor and the documented JPY/KRW convention from +the ledger rung). + +Every action declares `validate()` (via `allRequiredEngaged` + +domain checks) and carries `fieldMetadata`/`formRules` where the form needs +them — the DTO *is* the form definition; there is no second source of +truth. + +## 4. Persistence: Lightweight, exclusively + +All persistence goes through the +[Lightweight](https://github.com/LASTRADA-Software/Lightweight) ORM, the +same way [`bank`](bank/README.md) does. **No rung implements any database +code itself.** + +- **Entities** are Lightweight `Field<>`-wrapped records in + `include//db/*_entity.hpp`, kept strictly separate from the wire + DTOs; the model maps DTO ⇄ entity (bank's two-type-layer architecture). +- **Access** is through `Lightweight::DataMapper`: a model holds no + connection of its own — each `execute()` acquires one from + `Lightweight::GlobalDataMapperPool()` for its own duration and returns it + before returning, rather than a model owning a permanent connection for + its whole lifetime. Still correct without locks: morph runs each model on + its own strand, so no two `execute()` calls on the same instance ever + overlap, and each acquisition is entirely self-contained within one call. + The database is an on-disk SQLite file, never `:memory:` (private per + connection). +- **Schema** is owned by `LIGHTWEIGHT_SQL_MIGRATION` definitions (bank's + `src/db/schema.cpp` pattern). Migrations are the *only* DDL mechanism — + no `PRAGMA user_version` scheme, no hand-run SQL scripts. +- **Relations** use `BelongsTo`/`HasMany` with declared foreign-key + constraints, and ownership authorization is expressed *through the + relation* (bank's `loadOwned` pattern), not by string-building WHERE + clauses. +- **Transactions**: cross-row atomicity uses `SqlTransaction`; the + cross-*instance* caveat and row-version re-hydration pattern are + documented in bank's README ("The honest edge") and apply unchanged. +- **Forbidden**: direct `sqlite3_*` calls; hand-written SQL strings outside + Lightweight's facilities; custom connection pools, caches, retry + wrappers, or ORM-lookalike helper layers. If Lightweight cannot express + something a rung needs (a query shape, a constraint, a quirk like bank's + documented `HasMany` ordinal-index and `Update`/`Query` limitations), + **record it as a finding and work within Lightweight's own documented + idioms** (e.g. bank's relation-free projection rows). +- **The sanctioned escape tier (round-7 T1)**: where `DataMapper` cannot + express a *required mechanism*, the rung may use **Lightweight's own + raw-query facility, invoked from inside the model, with a mandatory + finding entry** — never the sqlite3 API, never a parallel helper layer. + Known escapees, pre-enumerated so nobody relitigates them: conditional + atomic updates with `RETURNING` (pastebin's burn-atomicity answer), FTS5 + virtual tables (forge search fallback), and WAL-read-transaction snapshot + pinning (ledger reports). Without this tier, rung 1's *recommended* + design was illegal under this rule — rule erosion or silent workarounds + would have followed, both defects by the prime directive's own standard. +- **WASM**: Lightweight (ODBC) cannot run in the browser, and no + browser-side substitute store may be written. The ladder's WASM clients + are **remote clients** — persistence lives server-side, behind the model. + (Bank's local-only in-memory WASM store predates this rule and is not the + ladder pattern.) +- The framework's own durable stores are unaffected by this rule: morph's + `SqliteOfflineQueue`, journal logs, etc. are library code under test, not + app database layer. + +## 5. Testing: models are 100% unit tested + +- **Every model is 100% unit tested** — line and branch coverage of + `src/models/` + `include//models/` at 100%, enforced as a + **blocking `codecov.yml` component gate** scoped to those paths (the + recipe rung 0 proved out on `examples/common`: a + `component_management.individual_components` entry naming the paths, + `informational: false`, wired to the `clang-coverage` CI leg's + `scripts/coverage.sh` output — see [`TESTING.md`](TESTING.md)'s "Build + system and CI"). The DTO⇄entity mapping and error paths count as model + code. **The store-error half is covered honestly, not excluded** + (round-7 T3): branches reachable only through database failure + (`SQLITE_BUSY`, constraint violations, `SqlTransaction` rollback) are + exercised by provoking each failure class *for real, through the schema* + — `db_busy_fixture.hpp` for `SQLITE_BUSY` (a genuine, uncommitted `BEGIN + IMMEDIATE` write transaction on a second connection), a conflicting row + or a dropped table for the rest (see [`TESTING.md`](TESTING.md)'s testkit + section). There is no injectable seam between Lightweight's `DataMapper` + and the ODBC driver, so a mock failing driver is not on offer and not + planned — only a real, schema-level failure counts. The escape hatch is + unchanged and still narrow — a + per-line exclusion tag is legitimate only for a branch no such fixture can + provably reach, which is the outcome round-7 T3 rejected being reopened by + the back door. +- **The gate's numeric target is the measured ceiling, not a blind + 100%** (rung-0 finding, `examples/common`'s coverage work): llvm-cov's + source-based coverage places its own counters on constructs that are not + really branches — a `switch`/`case` block's closing `}` after `break;`, + or the closing `}` of a scope whose one statement is a + `std::function` call — and those counters can read 0 even though + the statement immediately above them, per its own hit count, ran. There + is no llvm-cov equivalent of gcov's inline `LCOV_EXCL_LINE` to suppress + a single line. When every remaining "missed" line is one of these + (verified, not assumed, by reading the hit count on the preceding + statement) or Qt AUTOMOC-generated code, compute the real ceiling + (`covered / total` from `llvm-cov export`'s JSON, not the rounded + percentage in the human-readable report) and set the component's + `target:` a small margin below it, with a comment enumerating every + known-artifact line and why it's benign. A rung that hits this should + not spend further cycles chasing a display artifact — reroute that + effort at genuinely uncovered logic instead. +- **Before writing a test to chase an apparently-unreachable branch, + trace it into the vendored library first** — the branch may be + genuinely dead, not just hard to trigger. Rung 0's `db_fixture.hpp` + shipped a `sqlite_sequence`-skip guard in its table-drop sweep, believed + to be a genuine (if hard-to-exercise) edge case, until reading + Lightweight's own `SqlSchema.cpp` showed that `ReadAllTables()` already + filters that table out before any caller ever sees it — the guard could + not execute under any input. The fix was deleting the dead branch, not + writing a test for it. Coverage tooling cannot tell "hard to reach" apart + from "impossible to reach"; only reading the dependency's source can. +- **Use dependency injection to make hard-to-trigger branches directly + testable, rather than reaching for a process/subprocess harness.** + Two recurring shapes in rung 0's own testkit, both reusable for model + code: (1) a `static const X = [...]()` once-per-process env-var read + (e.g. a connection-string override) — no two tests in the same binary + can ever be first to observe a different value once an earlier test has + already forced the guard's decision. Extract the parsing/branching logic + into a small, pure, `noexcept`-where-possible function taking the raw + value as a plain parameter (`computeConnectionString(const char*)`, + `computeDeadlineScale(const char*)`); the `static const` site becomes a + one-line, branch-free delegation, and the function is tested directly + with whatever inputs a test likes. (2) A throw-on-I/O-failure branch + (`listen()` returned false, `waitForConnected()` timed out) that can't + be forced deterministically without flakiness or a test-only seam on a + third-party class (`QWebSocketServer`, an ODBC driver). Extract the + decision (`throwIfListenFailed(bool)`) so the *decision* is what's + tested with a plain `bool`, and the real I/O call site becomes a + trivial, branch-free one-liner. Reach for this before building a + subprocess helper or a mock layer — it is less machinery, and it is what + rung 0 was redirected toward after first trying the subprocess route. +- Model tests run the full backend-mode matrix (`Local` / + `LocalSingleThread` / `Socket`) per [`TESTING.md`](TESTING.md); every + invariant named in the rung README ("required tests", DoD) exists as a + named test before the feature is called done. +- Invariants are tested property-style where the README says so (ledger's + per-currency zero-sum, kanban's dense-unique positions) with seeds + printed on failure. +- GUI/presenter testing follows `TESTING.md`; there is no separate GUI + logic to test if rule 2 was followed — presenter tests verify routing, + error surfacing, and quiescence, not business behavior. + +## 6. Rung pull-request checklist + +Every rung PR states, in its description: + +1. No domain logic outside models (rule 1) — where it was tempting, the + finding filed instead. +2. Custom GUI elements present, each with its written justification and + gap-ledger entry (rule 2) — ideally none. +3. `grep`-clean DTO surface: no `int`/`double`/`bool`/raw-enum fields + (rule 3); `std::string` only where the data is text. +4. No database code outside Lightweight entities/migrations/mappers + (rule 4) — `grep sqlite3_` returns nothing in the rung. +5. Model coverage gate green (rule 5) — the blocking `codecov.yml` + component, target set from a measured ceiling with every known-artifact + line documented, matrix green. +6. The rung README's design questions are resolved in writing + ([`LADDER.md`](LADDER.md) discipline rule). diff --git a/examples/LADDER.md b/examples/LADDER.md new file mode 100644 index 00000000..5bd444f5 --- /dev/null +++ b/examples/LADDER.md @@ -0,0 +1,308 @@ +# The application ladder + +A sequence of stateful applications of gradually increasing complexity, each +anchored to existing open source software, designed to stress-test every +morph subsystem and find the framework's limits. Persistence is SQLite via +the Lightweight ORM throughout; clients are Qt (desktop + WASM), as in +[`bank`](bank). + +**Program scope (round-7 holistic review):** the committed build is +**rung 0 through rung 4** plus the no-app spikes below — that is where the +unproven seams live (first WASM-remote, first shared-over-socket, offline +replay, exactly-once, SQLite contention) and where reviews locate peak +findings-per-week. **Rungs 5–8 are a design annex**: their READMEs are +finished deliverables (requirements studies whose sharpest content the +spikes convert into CI at a fraction of construction cost); building any of +them is a separate decision taken *after* rung 4 with the +[finding pipeline](FINDINGS.md) scoreboard in hand. Ledger (rung 5) is the +strongest candidate to build — the only annex rung with a genuinely +app-shaped core; forge's framework content ships as its load script against +synthetic models, and crm's as the extension-bag spike. The program's +product is **findings fixed, not apps shipped** — see +[`FINDINGS.md`](FINDINGS.md) for what counts, triage, the fix budget, exit +criteria, and the demotion policy. + +**The no-app spikes** (start immediately, in parallel with rungs 0–1; each +files findings, none builds an app): + +1. **Forms conformance suite** — the round-5 D1–D8 test constructions + (retag-vs-round, clamped-wire, nested enforcement, render-old/validate-new + skew, locale, stale Choice, auto-fire, rules parity); needs no socket. +2. **Rational property/fuzz harness** at ledger-realistic magnitudes + (intermediate overflow, checked-arithmetic case). +3. **Journal payload-evolution spike** — replay across a renamed/retyped + action field; the versioning/migration design input for the annex. +4. **Extension-bag spike (7b)** — one model with a runtime custom field + through schema, forms, validation, journal; answers the crm endgame + without the CRM. +5. **Forge load script** — synthetic notification/poll models, 500–2,000 + sockets, hardened configuration, epoch resync across restart. + +**Audience decision:** the primary audience of every rung is morph's own +regression suite and finding ledger. The single polished showcase is +**kanban** (mid-ladder, every subsystem load-bearing, visually legible); +every other rung takes rule 2's zero-styling literally, no guilt. + +Each rung's folder contains a README describing what to implement, the open +source reference implementations to study, and the framework limits the rung +is expected to hit. Two binding companion documents: +[`IMPLEMENTATION.md`](IMPLEMENTATION.md) — how the apps are written +(models are the application; minimal schema-driven GUIs; strong types only +in DTOs, `std::string` the sole plain type; persistence exclusively through +the Lightweight ORM; models 100% unit tested) — and +[`TESTING.md`](TESTING.md) — how they are tested: every rung's GUI is +presenter-shaped and unit tested in **both deployment modes** (in-process +`LocalBackend`, and `QtWebSocketBackend` against an in-test `RemoteServer` +with N clients) plus a WASM-shaped single-thread mode, via the shared +`examples/common/testkit`. + +Discipline rule: each rung names explicit **design questions**; they must be +resolved *in writing* (in that rung's README) before the next rung starts — +later rungs consume earlier answers (5 reuses 4's cascade-journaling answer, +7 reuses 4's board pieces, 8 reuses 2's job pattern and 3's event pattern). + +| # | App | Anchor project(s) | New subsystems under stress | +|---|-----|-------------------|-----------------------------| +| 1 | [`pastebin`](pastebin) | [MicroBin](https://github.com/szabodanika/microbin) | Full loop smoke test; journal semantics of state-mutating reads and expiry | +| 2 | [`bookmarks`](bookmarks) | [linkding](https://github.com/sissbruecker/linkding) | Multi-entity CRUD, bulk actions, sessions/authz, background jobs | +| 3 | [`polls`](polls) | [Rallly](https://github.com/lukevella/rallly) | Shared instances, anonymous principals, undo, event polling | +| 4 | [`kanban`](kanban) | [Kanboard](https://github.com/kanboard/kanboard) | Strand ordering under concurrency, RBAC, offline queue + replay, action cascades | +| 5* | [`ledger`](ledger) | [Firefly III](https://github.com/firefly-iii/firefly-iii), [Actual Budget](https://github.com/actualbudget/actual) | Exact `Rational` arithmetic under invariants, multi-currency, sync-philosophy benchmark | +| 6* | [`lims`](lims) | [SENAITE](https://github.com/senaite/senaite.core), [InvenTree](https://github.com/inventree/InvenTree), [ODK Central](https://github.com/getodk/central) | Unit algebra, versioned schema-driven forms, offline entities with conflict detection | +| 7* | [`crm`](crm) | [EspoCRM](https://github.com/espocrm/espocrm), [Tryton](https://github.com/tryton/tryton), [Frappe](https://github.com/frappe/frappe) | Metadata-driven forms, dynamic logic, per-field authz; **7b** (gated): runtime custom fields | +| 8* | [`forge`](forge) | [Gogs](https://github.com/gogs/gogs), [Gitea/Forgejo](https://github.com/go-gitea/gitea), GitLab architecture | Everything at once: orgs/permissions, notifications at scale, webhooks, out-of-protocol sidecars | + +\* = design annex: README is the deliverable; construction is a post-rung-4 +decision (ledger first in line; forge → load script; crm → 7b spike). + +## Cross-cutting stress map + +Every subsystem is hit by at least two rungs: + +- **Per-model strands** — 4 (concurrent board moves), 7 (multi-model lead conversion) +- **Shared instances** — 3, 4, 6, 8 (rung 3 is also the framework's *first + ever* `AllowShared`-over-WebSocket coverage — a scope-heavy rung, like 1) +- **Journal / undo / audit** — 1, 3, 4, 5, 6 (payload evolution), 7 +- **Offline queue + replay** — 4, 5, 6, 7 +- **Forms + exact values / units** — 5, 6, 7 +- **Sessions / authorization** — 2, 3, 4, 5–6 (empty-principal refusal), 7, 8 +- **Application version skew** (old client binary vs. new server, via + `MORPH_CLIENT_ONLY`) — 6 (owner), re-run at 8 across its own releases +- **Remote transport and its limits** — all + +## The six recurring strains + +These needs recur across the researched projects and deserve one +framework-level answer each, introduced at a specific rung and reused +afterwards: + +1. **Background jobs** (rung 2) — work triggered by an action but completing + later, mutating the model outside any client request. **Correction from + verification: a typed in-process path exists today** — + `SimulatedRemoteBackend` is a shipped public backend that routes through + the complete server pipeline (authorizer, journal log provider, + per-instance strand), so a server-side worker *can* be built as an + internal client with a service principal. The genuine gap is narrower + but real: no *sanctioned* seam, no defined service-principal convention, + the simulated path is connection-unscoped (`ConnectionId` 0), and + `handleInline` rejects `execute`. Rung 2's design discussion starts from + the internal-client option and decides whether a first-class framework + seam is still warranted; rungs 4, 5, and 8 consume the answer. + **Time-*scheduled* jobs are a distinct shape with their own owner — + rung 5** (recurring transactions): who ticks, on what thread, under what + principal, journaled how. Forge's webhook retry loop assumes that answer + exists. +2. **Event polling** (rung 3; rung 2's DoD includes a minimal + changes-since poll as its preview) — the Zulip-style + `getEventsSince(lastEventId)` action that substitutes for server push + everywhere. See + [Zulip's events system](https://zulip.readthedocs.io/en/stable/subsystems/events-system.html). + Two hard requirements from review: event sequences must survive + instance destruction (shared instances die *immediately* at refcount + zero — persist events or issue epoch tokens forcing full resync), and + the client polling helper must wrap **its own timeout** around every + call (a rate-limited server drops frames silently and morph has no + client-side execute deadline — the completion would hang forever). +3. **File/blob attachments** (rungs 4 and 8) — payloads that should not travel + the JSON action protocol; side channel must share the authorizer's token + discipline. +4. **Document generation** (rung 5) — reports/invoices/statements as + long-running submit-then-poll jobs with defined snapshot semantics. +5. **Exactly-once delivery** (rung 4, re-tested with money in rung 5) — the + wire `Envelope` has **no idempotency-key field** and the server cannot + recognize a replayed operation; a reply frame lost after commit means a + retry double-applies. The answer (an op-id inside action payloads plus a + server-side applied-ops ledger in the model) is established in rung 4 + and reused everywhere writes are retried. +6. **Journal payload evolution** (rung 6, bites rungs 5 and 7 too) — replay + decodes stored payloads with the *current* action structs; renaming a + field silently drops recorded data. Versioned catalogs need per-entry + schema pinning and a migration story. + +## Journal honesty (decided at rung 1, in writing) + +Review verdict: the later rungs' claims oversell `morph::journal`, which is +an **audit trail** whose replay is exact only for pure, deterministic, +single-instance, in-memory models — not an event-sourcing engine. Known +hard limits: `undoLast()` returns a *detached* holder (no API installs it +into a live server registry, so in-place undo of a shared instance is not +possible today) and pops the newest entry *regardless of principal*; +cascaded mutations get no causal link to their trigger; there are no +cross-model transactions or correlated entries, so multi-model actions +(`ConvertLead`) cannot be replayed consistently; `entries()` re-reads the +whole file. Rung 1 must write the ladder-wide position: what the journal is +used for (audit, history rendering), what it is not (undo on shared +instances — use compensating actions; cross-model replay), and which +framework growth (replay-mode signaling, causal parent ids, per-principal +undo, indexed reads) the ladder should propose instead of assuming. + +## Rung 0, scope, and sequencing (from delivery review) + +Verification found rung 1 had accreted ~twelve deliverables under a "smoke +test" label. The infrastructure is now split out as **rung 0**: the testkit +subset (`pump.hpp`, `backend_rig.hpp`, Qt-owning test `main`), the shared +presenter architecture (`examples/common/gui`), the `ladder-tests` CI job +with path-filtered `MORPH_LADDER_RUNGS`, and the **WASM-remote spike** (with +a written fallback if it bounces off framework work). Rung 1 is then the +pastebin app plus its own tests and design records. + +Honest effort accounting (baseline: one "bank" = `examples/bank`, ≈9k LOC): +the full eight rungs would sum to **~19–25 bank-equivalents plus the +framework prerequisites** — a multi-year solo effort, which is why the +committed scope is rungs 0–4 (+ spikes): ~8–10 bank-equivalents, a +6-month-scale solo horizon, and where adversarial review expects peak +findings-per-week. Deferral decisions recorded in the rung READMEs: kanban +defers automation rules and attachments to a "later" section (ledger needs +only the cascade *decision*, writable from a spike); the annex rungs keep +their internal gates (7a/7b, forge phase 3 per-item) for whenever they are +green-lit. The **fault-injection wire proxy and the strand interleaver are +pulled forward to rung 0–1** (round-7: they outperform whole rungs on +finding yield; scheduling them at rung 4 delayed the program's +highest-value instruments behind three rungs of CRUD). + +Parallelization: hard sequence **0 → 1 → 2 → 3 → 4**; after rung 4's +written answers, **5, 6, and 7a are mutually independent** (three +contributors can run them concurrently), and **8 phase 1 needs only 2's job +answer and 3's event pattern** so it can start alongside 4. The coupling +point is `examples/common` — it needs an owner and an **additive-only API +discipline** after rung 3. + +**License hygiene (binding):** morph is Apache-2.0; several anchors are +AGPL/GPL (Rallly, Firefly III, EspoCRM, Tryton, SENAITE). Anchors are +studied for *requirements, data-model shapes, and behavior only* — no +source code, comments, or substantial expressive structure is ported from +copyleft projects; all ladder implementation is original. Where a README +says "model on"/"transliterate", it means the observable API surface and +semantics, never the code. + +## Framework prerequisites (schedule as issues now, not rung discoveries) + +Adversarial review found four items that invalidate rung definitions-of-done +as written; they are prerequisites to schedule against the framework, not +things to trip over mid-rung: + +1. **Async shared/keyed attach for WASM** (before rung 3's WASM story) — + `registerModelShared`/`attachModel` are synchronous and nest an event + loop, which aborts the page on the WASM main thread; + `registerModelAsync` covers only the plain path. +2. **Client-side execute deadline** (before rung 3's polling helper) — no + timeout exists on a `Completion`; silently dropped frames (rate limiter) + or a black-holed server hang the client forever. +3. **Injectable time source usable by remotely-constructed models** (before + rung 1's expiry semantics) — `LogEntry` timestamps are hard-wired to the + system clock, and registry-constructed models are default-constructed, + so tests need a process-global now-provider convention. +4. **The fault-injection wire proxy** (rung 0–1, pulled forward by the + round-7 review) — scriptable drop/delay/duplicate/kill between client + and server; without it the exactly-once, dead-letter, and + reconnect-mid-replay scenarios are demos, not CI tests. The + deterministic strand interleaver ships alongside it. See + [TESTING.md](TESTING.md). + +Also queued deliberately: the **offline queue has no depth bound** (a week +offline grows it without limit; note the linear-scan/quadratic enqueue +applies to `FileOfflineQueue` only — `SqliteOfflineQueue`'s key dedup is +index-backed), the **SyncWorker's hard-coded 5-attempt cap dead-letters +legitimate writes after five flaky reconnects** (rung 4 must surface +dead-letters in the UI, not logs), and **`SQLITE_BUSY` waits occupy pool +threads** (K writing models on a 2–4-thread pool can starve every strand, +fire `executeTimeout`, and still commit — the timeout-then-committed +double-apply is rung 4's sharpest data-corruption test). + +**Forms-subsystem gaps** (from the round-5 deep review; owners in the +lims/crm/ledger READMEs): no sum types in the forms palette (the +`quantity | belowLOD | aboveUDL` result is a *multi-field encoding* glued by +`x-rules`, by design); rule vocabulary is closed single-node conditions (no +`and`/`or`/`not` — EspoCRM-class logic maps onto it or becomes a framework +proposal); schemas-as-data render old versions but **validation always runs +against the current compiled struct**; no per-caller schema shaping; nested +aggregates get schemas but **no enforcement recursion and no child-table +renderer**; no pre-decode wire validation seam (clamped `Rational`s reach +`validate()` as plausible numbers); `reconcileDeclaredPrecision` **retags +rather than rounds** (spec text and code disagree — rung 6 owns the +decision); the shipped renderer **auto-fires on validity with no submit +button** (explicit-submit mode needed before any side-effectful rung form); +`DecimalPlaces` has a floor of 1 (zero-decimal currencies need an app +convention). + +## Operations and security (binding conventions) + +- **Security opt-in matrix** (everything in `docs/spec/security.md` + defaults fail-open): rung 1 deliberately tests the *unhardened* default + (a test asserts the fail-open delta) and owns the `hello` + version-negotiation test; rung 2 must exercise `authorizeRegister` + + `authorizeInstance` (not just `SigningAuthorizer`); rung 3 runs its + harness with the rate limiter ON (the polling helper's timeout is + untested otherwise); rung 4's HTTP side channel reuses `TokenVerifier` + and joins the fuzz corpus; rungs 5–6 get a CI leg with + `MORPH_REQUIRE_VETTED_HMAC=ON`; **rung 8 is the hardened-configuration + demonstration** — TLS, vetted HMAC, register/instance authorization, + full `LimitPolicy` and server bounds, negotiation — and its load script + runs against that config (its README's non-goal is public *exposure*, + not hardened configuration). +- **Observability**: every rung's server installs a logging + `morph::observe::MetricSink`; rung 4 asserts `queueDepth`/reconnect + metrics in its offline tests; rung 8's load script consumes + `executeLatencyMs`/`executeInFlight` and drives the drain via + `RemoteServer::health()`/`beginShutdown()`. +- **Persistence & migrations**: all app persistence goes through the + Lightweight ORM per [`IMPLEMENTATION.md`](IMPLEMENTATION.md) — schema is + owned by `LIGHTWEIGHT_SQL_MIGRATION` definitions (bank's pattern), which + is the migration story lims's replay-across-migration DoD presupposes. + No rung writes database code itself. +- **Demo seeding**: every rung ships a `--seed` path implemented on the + testkit's `action_driver` generators (deterministic demos, screenshots, + Playwright). +- **Docs tax**: framework prerequisites land in `include/morph` and pay the + full spec + Doxygen (`WARN_AS_ERROR`) + pinned-facts cost — budget + +30–50% over code cost per item. Example code is exempt. + +## Known limits the ladder is designed to hit + +- **No server-initiated push.** Mitigated by the event-polling pattern + (precedented: Zulip is long-poll only; Gitea's own UI polls; EspoCRM polls). + Rung 8's many-clients-polling is the scale test — at **500–2,000 + concurrent sockets at ~1 poll/s** (the single Qt receive/reply thread is + the ceiling, not the worker pool), including during a graceful drain. + Sub-second collaborative text editing (Etherpad-class OT) is explicitly + *out of scope* for the whole ladder — it is the one workload that + genuinely requires push. +- **`Completion` is not composable** — long-running operations (merge, + report generation) need a submit → job-id → poll-status idiom; nested + execute-and-wait orchestration can deadlock the worker pool (rung 7 tests + this deliberately). +- **Compiled C++ action types vs. runtime-defined entities** — rung 7's + endgame (Salesforce-style custom fields) decides how far served JSON-Schema + forms can stretch without runtime type creation. +- **Authorization is per-execute, attachments are ownerless** — revoking a + principal does not detach it from shared instances or cut off reads unless + the authorizer distinguishes them (rungs 4 and 8 test revocation + mid-session); a token expiring between authorize and authenticate + dispatches with an **empty principal**, which regulatory rungs (5, 6) must + refuse at the model. +- **WASM ≠ desktop.** The shipped WASM pattern is single-threaded and + local-only: `NetworkMonitor` (probe thread) and `SqliteOfflineQueue` + (filesystem) do not run in the browser, and a WASM client over + `QtWebSocketBackend` has never been exercised. Rung 1 proves WASM-remote; + rung 4 scopes offline to desktop or builds browser-native equivalents + (IndexedDB queue, online/offline events). diff --git a/examples/TESTING.md b/examples/TESTING.md new file mode 100644 index 00000000..42c3f2c8 --- /dev/null +++ b/examples/TESTING.md @@ -0,0 +1,456 @@ +# Ladder testing strategy — GUIs, dual deployment modes, multi-client stress + +Every rung of the [application ladder](LADDER.md) ships GUIs that are unit +tested in **both deployment modes** — in-process (GUI + `LocalBackend` in one +process) and client/server (GUI over `QtWebSocketBackend` against a +`RemoteServer`), including **N clients against one server** for stress tests. +This document is the binding convention; rung READMEs reference it instead of +restating it. + +**What this machinery actually is (round-7 T4 reframe):** since +[`IMPLEMENTATION.md`](IMPLEMENTATION.md) rule 2 makes presenters +deliberately contentless ("translate and route, never decide"), the +BackendRig / client-pool / convergence stack is not really GUI testing — +it is **a conformance harness for morph's client-side stack** (`Bridge`, +backends, `QtExecutor`, completions, attach/reconnect under a real Qt +event loop), which has zero coverage in the repo today. It is therefore +**owned by the testkit as framework coverage**: the full matrix runs once +per framework surface it conforms, and each rung runs a *thin +instantiation* (its presenters through the rig, one suite per model — not +a per-screen × 3-mode combinatorial matrix). This reframing is also what +keeps the CI cost curve flat. It was derived from what already exists and is proven in the +repo: the recipe in `tests/qt/test_qt_websocket.cpp` (in-test +`QtWebSocketServer` on port 0, `pumpUntil`, N=4 concurrent backends, the +QProcess client harness, the Qt-owning Catch2 `main()`), the pump helpers in +`examples/bank/tests/bank_test_support.hpp`, and the presenter shape of +`examples/bank/gui/controllers/`. + +## Current state (verified, 2026-08) + +- There are **zero GUI tests** in the repo today. Bank's controllers are + presenter-shaped but compile only into `bank_gui`, never into `bank_tests`; + `BankClient` hard-wires `LocalBackend` (`gui/BankClient.cpp`), so the same + GUI cannot be constructed over a socket; the only GUI check is a + sleep-pumped screenshot smoke inside `gui/main.cpp`. +- `examples/bank/tests/test_remote.cpp` uses `SimulatedRemoteBackend`, not a + real socket — and `SimulatedRemoteBackend` dispatches with `ConnectionId 0` + (no connection scope), so **connection-drop refcounting, `closeConnection` + semantics, and shared-instance lifetime across disconnect are untestable in + that mode**. Tests about connection lifetime must run over the real + WebSocket loopback (or the testkit grows a connection-scoped simulated + client via `RemoteServer::openConnection()` — a small, recommended + addition that also makes refcount tests deterministic). +- **No existing test exercises `AllowShared` over the Qt WebSocket + transport.** The polls rung's harness will be the first — that is itself + coverage the framework needs. +- Bank is not built in `ci.yml` at all (only `wasm-demo.yml`, tests OFF). The + ladder needs a `ladder-tests` CI job: `MORPH_BUILD_QT=ON`, rung examples + on, `QT_QPA_PLATFORM=offscreen ctest` — every mechanism already exists in + `ci.yml`. + +## Presenter architecture (every rung) + +1. **Presenters live in a Qt-Core-only static library** — + `examples//gui_lib/` links `Qt6::Core` and morph only; `gui/` + (QML/Widgets app), `gui_wasm/`, and `tests/` all link `gui_lib`. + Presenters must instantiate under a plain `QCoreApplication`. +2. **Backend-parameterized app context.** A shared + `examples/common/gui/AppContext` replaces bank's hard-wired + `LocalBackend`: `Mode = variant`; it owns + (in order) the optional worker pool, the `QtExecutor`, and the `Bridge`, + and exposes `login(principal)` → `setDefaultSession`. Presenters take + `(Bridge&, IExecutor*)` and **never construct executors or backends + themselves.** `Remote` is asynchronously connected and exposes + `ready()`/`onReady(cb)`: presenters (which build `BridgeHandler`s, and a + `BridgeHandler` constructor registers) **must** be constructed from inside + `onReady`. `QtWebSocketBackend::registerModelAsync()` queues a + registration issued before the socket connects and retries it once the + connection comes up (`docs/spec/core/backend.md`, "Asynchronous + registration"), so this is no longer the correctness hazard it once was + — but building presenters/`BridgeHandler`s from inside `onReady` stays the + simpler ordering to reason about, and is what every rung does. + `Local` is ready on construction and runs `onReady` inline, so mode-blind + code can always route through `onReady`. +3. **Observable quiescence.** A common `Presenter` base tracks in-flight + completions (`track(completion, onOk)` wraps `.then/.onError` in + begin/end counters) and exposes `bool busy()` + an `idle()` signal. + Tests never sleep; they wait for `busy() == false`. +4. **Timers live in the view layer.** Presenters expose an explicit + `poll()`; the QML/Widgets shell owns the `Timer`. Tests call `poll()` + directly — this is what makes `GetEventsSince` loops deterministic. +5. **Canonical state fingerprint.** Each rung's presenter set exposes + `stateFingerprint()` (a comparable snapshot) and `lastEventId()`. These + two hooks are the ladder-wide convention the convergence assertion + templates over. +6. **QML is bindings-only**; every conditional, format, and validation lives + in the presenter. Per rung: one offscreen engine-load smoke test (engine + creates root object, no errors) registered in ctest — not Qt Quick Test, + and no synthesized-mouse-event flows. + +## The dual-mode fixture + +`examples/common/testkit/backend_rig.hpp` provides +`BackendRig{Mode, nClients, authorizer, serverConfig}` with three modes, +selected by Catch2 `GENERATE` so **one test body runs in every mode**. The +last two arguments are optional and apply to `Socket` mode only: `authorizer` +is threaded into the `RemoteServer`, `serverConfig` is the +`QtWebSocketServerConfig` handed to the `QtWebSocketServer` (frame-size cap, +connection cap, rate limit, timeouts) — how a rung tests a transport-enforced +limit without standing up a second server beside the rig's own. + +- **`Local`** — one `ThreadPoolExecutor{4}`, one + `Bridge{LocalBackend}`; N "clients" are N presenter sets over the shared + bridge (morph's in-process multi-handler semantics). +- **`LocalSingleThread`** — `LocalBackend` running models on the GUI + executor itself: the **WASM constraint-parity mode** (exactly bank's + `__EMSCRIPTEN__` wiring). Catches models that block the UI thread and + single-thread re-entrancy bugs in every ordinary test run. +- **`Socket`** — `ThreadPoolExecutor{2–4}` → `RemoteServer` (authorizer + injectable) → `QtWebSocketServer{*server, 0}` (ephemeral port via + `.port()`) → per client: `QtWebSocketBackend` + `waitForConnected()` + + its **own `Bridge`**. All clients on the one Qt main thread — proven at + N=4 in `tests/qt/test_qt_websocket.cpp`. + +Caveats the fixture encodes: only `Socket` mode exercises the server-side +shared-instance directory and connection scopes — tests asserting directory +behavior are tagged `[socket-only]`; N-threads-hosting-backends is not +possible today (`QtExecutor` posts to `QCoreApplication::instance()` only); +true process separation reuses the QProcess pattern +(`tests/qt/qt_test_client_main.cpp`) via `process_pool.hpp`, with each rung +shipping a small headless-client binary that drives its *presenters*, not +raw handlers. + +`rig.socketBackend(i)` hands out the raw `QtWebSocketBackend` for a client, +for the handful of transport-level operations that have no `Bridge`-level +equivalent — `negotiateProtocolVersion()` (the `hello` handshake) is the +motivating one. Everything that merely dispatches actions should use +`client()` / `bridge()` instead. + +Teardown order (encoded in `~BackendRig`): presenters → client bridges → +`wsServer.closeGracefully(2s)` → server → **pools, and only then the +client-facing executors**. That last step is load-bearing rather than +cosmetic: in `Local` mode a worker thread resolves a `Completion` by posting +to the client executor, so an executor destroyed while the pool still has +threads running leaves the next completion posting through a dangling +`IExecutor*`. The crash surfaces nowhere near the rig — the stale callback +sits on the Qt event loop and detonates inside whatever later test pumps it. +Any object that owns both a pool and an executor the pool's completions +target (a rung's app bootstrap, for instance) needs the same ordering, plus a +way for a test to observe that its dispatches have *settled* — not merely +that their effect is visible — before it is destroyed. + +## Pumping discipline — no sleeps + +The Qt event loop is the single pump for GUI tests (`QtWebSocketBackend` +requires the Qt loop thread; `MainThreadExecutor::runFor` blocks for its +full wall-clock step even when idle). `examples/common/testkit/pump.hpp` is +the **only** sanctioned wait surface: + +- `pumpUntil(pred, deadline)` — bounded `processEvents` slices; deadline + defaults to 5 s, scaled by `MORPH_LADDER_DEADLINE_MS`. +- `awaitQt(Completion)` — resolve one completion via the pump, + rethrow errors. +- `settle(presenter)` — `pumpUntil(!busy())`. + +A `sleep_for` outside `pump.hpp` is a review-rejectable defect. The test +binary uses the Qt-owning `main()` (QCoreApplication + `Catch::Session` + +DeferredDelete drain) copied from `tests/qt/test_qt_websocket.cpp`. + +## Multi-client stress harness + +Testkit components, with the rung that **first needs** each (this ordering +is load-bearing — earlier rungs must not claim later components in their +DoD): + +| Component | First needed by | +|---|---| +| `testkit_main.cpp`, `pump.hpp`, `backend_rig.hpp`, `db_fixture.hpp`, `db_fault_fixture.hpp`, **fault proxy + strand interleaver** (pulled forward, round-7) | rung 0/1 | +| `client_pool.hpp`, `convergence.hpp` | rung 3 | +| `action_driver.hpp`, `process_pool.hpp`, `offline_rig.hpp` | rung 4 | + +- `db_fault_fixture.hpp` — holds a real `Lightweight::SqlScopedLock` on a + second, independent `SqlConnection` to the shared test database, producing + genuine cross-session contention for code that itself takes the *same + named* advisory lock on a different connection. **This is not a failing + ODBC-level driver, and cannot fault an ordinary `DataMapper` call**: + `Create`/`Update`/`Query`/`Delete` and a plain `SqlTransaction` commit sit + entirely outside the advisory-lock protocol, so this fixture is + transparent to them — no `SQLITE_BUSY`, no constraint violation, no + rollback. There is no injectable seam between Lightweight's `DataMapper` + and the ODBC driver (no `SqlConnection` interface to substitute, no + statement hook to fail), so a driver-level fault fixture is not on offer; + see `IMPLEMENTATION.md` rule 5 for what the 100%-coverage rule actually + requires instead. +- `db_busy_fixture.hpp` — the `SQLITE_BUSY` answer: a genuine, uncommitted + `BEGIN IMMEDIATE` write transaction held open on a second `SqlConnection`, + so a concurrent write from the connection under test collides for real + and SQLite returns a real `SQLITE_BUSY` — no mock driver, the failure + happens in the same call path production takes. Two empirically-verified + gotchas its own doc comment records: `BEGIN IMMEDIATE` is required (a + plain `Lightweight::SqlTransaction` only flips `SQL_ATTR_AUTOCOMMIT` and + defers lock acquisition, producing no contention), and Lightweight's + unconditional `PRAGMA busy_timeout = 60000` in `PostConnect()` means the + *other* connection must re-issue a small timeout of its own or the + "failure" is a sixty-second block instead of an immediate error. + **Store-error coverage is obtained per failure class, through the real + schema, by whichever fixture can genuinely provoke that class** — not from + one failing driver. Constraint violations and mid-transaction rollback + still have no general fixture; extending `db_busy_fixture.hpp`'s pattern + (a conflicting row for a `UNIQUE`/FK violation, a dropped table for a + query error) is the next step whenever a rung's model needs that + coverage. + +- `db_fixture.hpp` — one real, on-disk database shared per test *binary* + (`morph_ladder_test.db` in the binary's working directory, or + `ODBC_CONNECTION_STRING` if set), reset between test cases by dropping every + table and re-applying the registered migrations. This mirrors Lightweight's + own `SqlTestFixture` and bank's `ensureDatabase()`; a `DataMapper` needs a + real connection, so a per-fixture temp file would buy isolation at the cost + of re-opening and re-migrating a database per test case. Isolation across + *binaries* comes from ctest's per-target working directory; isolation within + a binary comes from the drop-and-reset, which is why the ladder's + `catch_discover_tests` calls give their tests a `RESOURCE_LOCK` — two + DB-touching cases from one binary must never run concurrently under + `ctest -j`. +- `client_pool.hpp` — typed pool constructing each client's presenters + against `rig.client(i)`; test bodies are mode-blind. +- `convergence.hpp` — `requireConverged(clients, deadline)`: round-robin + `poll()`, wait all-idle, compare `stateFingerprint()` across clients + (optionally against an oracle client's server truth); on deadline, dump + every client's fingerprint diff. **Honesty note**: in `Local`/ + `LocalSingleThread` modes all "clients" share one bridge — there is no + staleness to converge from, so convergence is effectively + `[socket-only]` coverage; don't count Local-mode runs. The + `poll()`/`lastEventId()` hooks it needs exist only from rung 3 on — + rungs 0–2 use `settle()` + fingerprint equality without event cursors. +- `action_driver.hpp` — `SeededScript`: seed from `MORPH_STRESS_SEED` + (always printed on failure), weighted action generators, schedule computed + up front; per-burst invariant hooks (kanban: positions dense/unique; + ledger: legs sum zero; polls: counts match the event log). +- **N = 4–8 in-process clients** is the meaningful range (beyond ~8 sockets + on one pumped thread you add queueing latency, not new interleavings); + scale via `MORPH_LADDER_CLIENTS` / `MORPH_LADDER_ACTIONS` env vars + (soak-suite convention) — same CI run, no separate schedule. Kanban's + stress case runs under ThreadSanitizer + at N=4 — **in `Local` rig mode on `ThreadPoolExecutor`**: the repo's CI + deliberately keeps Qt stacks out of the sanitizer matrix ("a GUI stack + under TSan is mostly noise"), so the TSan leg exercises models + strands, + not sockets. Server-scale load (hundreds–thousands of sockets) is rung + 8's load *script*, not a unit test. +- `offline_rig.hpp` — scripted connectivity: drop by closing/destroying the + in-test `QtWebSocketServer`, revive on the same port (proven pattern); + hand-cranked signals into `ReconnectCoordinator`; queue inspection. +- `process_pool.hpp` — QProcess clients for rung-8 scale **and for + client-crash tests**: kill a client process mid-execute / mid-attach and + assert connection-scope reclamation under abnormal teardown (distinct + from graceful disconnect). + +Per-rung test naming: `test_model_.cpp` (full mode matrix), +`test_gui_.cpp` (presenter tests, full matrix), +`test_gui_qml_smoke.cpp`, `test_multiclient.cpp` `[stress]`, +`test_offline.cpp` (rungs 4/6/7). + +## The fault-injection wire proxy (and the strand interleaver) + +The single highest-yield harness the ladder needs and the repo lacks: an +in-process WebSocket proxy between `QtWebSocketBackend` and +`QtWebSocketServer` with scriptable rules — *drop exactly the reply frame of +call k*, delay, duplicate, kill mid-replay. Exactly-once tests (kanban, +ledger), dead-letter tests, and reconnect-mid-replay tests are demos, not CI +tests, without it. `SimulatedRemoteBackend` is lossless and unscoped; the +soak tests flap a boolean, not a socket. **Built at rung 0–1** (pulled +forward by the round-7 review — it outperforms whole rungs on finding +yield), so rung 1's "duplicate create on retry" test can use true +reply-frame loss from the start; the double-execute approximation is only +the fallback if the proxy slips. + +Companion harness from adversarial review: a **deterministic-schedule +strand interleaver** — without it, strand-ordering bugs (kanban's +`MoveTaskPosition` centerpiece) remain probabilistic stress runs rather +than reproducible interleavings. + +## WASM reality + +Honest position: **WASM GUIs cannot be unit-tested in CI today.** The +three-layer answer, per rung: + +1. **`LocalSingleThread` mode natively** — same presenters, WASM-shaped + wiring, every test run. +2. **Compile gate** — CI builds the rung's client for wasm32-emscripten so + shared GUI code can't drift. Shipped as `.github/workflows/wasm-ladder.yml` + (emsdk + a Qt-for-wasm kit, `-DMORPH_CLIENT_ONLY=ON`); the per-rung target + wiring is `morph_add_rung()`'s `gui_wasm` block, not a per-rung + `CMakeLists.txt` the way bank's is. +3. **One scripted browser smoke** (emrun + Playwright against the built + demo) as an optional stage in the same CI run. + +Open framework facts every rung must respect (verified): + +- Bank's WASM build is **local-only** — a WASM client over + `QtWebSocketBackend` has still never been *run*. Rung 0 wrote the spike and + rung 1 wrote a real client over it (`examples/pastebin/gui_wasm`), but + neither was ever compiled: no Emscripten toolchain existed in either + authoring environment. The compile gate above is what will change this + sentence; until it has run green, treat both as unverified. +- The plain registration path is only WASM-safe with + **`asyncRegistrationEnabled = true`, which is opt-in and off by + default**; with defaults, the first `registerModel` aborts the page. +- **`waitForConnected()` hangs the page on WASM** — the WASM client must + use the `setConnectHandler` pattern (#39) instead; the Socket rig's + `waitForConnected()` recipe is for *native* tests only. +- The **synchronous shared/keyed attach path + (`registerModelShared`/`attachModel`) nests an event loop that aborts the + page on WASM** — `registerModelAsync` does not cover it. Async attach is + a framework prerequisite for rung 3's WASM story — **and pulls forward to + rung 1 if pastebin resolves burn atomicity via a shared keyed instance** + (the coupling is called out in the pastebin README). + +## Build system and CI (proven by rung 0) + +Build wiring (from delivery review; today each example is hand-added in the +root `CMakeLists.txt` — don't repeat that eight times): + +- One `examples/CMakeLists.txt`; one `MORPH_BUILD_LADDER` bool plus a + `MORPH_LADDER_RUNGS` cache list (`"all"` or `"pastebin;kanban"`) — no + per-rung booleans; the list maps 1:1 to CI path filters. +- `examples/common/` declares exactly three consumable targets: + `morph_ladder_testkit` (morph + Catch2 + Qt), `morph_ladder_gui` (STATIC, + `Qt6::Core` only, **no Catch2**, **no `Qt6::WebSockets`** — presenter rule + 1), and `morph_ladder_app` (STATIC, `AppContext` only: the deployment-mode + layer, which needs `morph::qt`/`Qt6::WebSockets` for `Remote` and is + therefore kept out of `morph_ladder_gui`). A rung's `gui_lib` links + `morph::ladder_gui`; the shells that choose a backend (`gui/`, `gui_wasm/`, + `tests/`) also link `morph::ladder_app`. Rungs link targets, never paths; + the testkit never grows per-rung options. +- A `morph_add_rung()` function creates `ladder__{lib,gui_lib,gui, + gui_wasm,tests,headless}` with `catch_discover_tests` + ctest labels + (`ladder`, `ladder-`, `stress`, `socket-only`), warnings and + sanitizers **applied to all app code** (bank skips both repo-wide because + its ORM headers aren't `-Werror`-clean — the ladder scopes any such + relaxation to the `db/` entity targets only, since persistence goes + through the same Lightweight ORM per + [`IMPLEMENTATION.md`](IMPLEMENTATION.md)), AUTOMOC, and a TIMEOUT on + every binary. Lightweight's `FetchContent` 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 through a `-D VAR=a;b;c` command line + where no escaping survives, so `LABELS "x;y"` does not make a two-label + test — it shifts every following name/value pair by one, silently dropping + the rest. `examples/common/CMakeLists.txt` shows the working shape: one + value per property name in the `catch_discover_tests` call, plus a + generated `TEST_INCLUDE_FILES` post-pass for the extra labels. +- Do **not** copy bank's `gui_wasm` shadow-header pattern — with the + `gui_lib` split it is unnecessary, and copying it makes the WASM and + native builds different programs, silently falsifying the "same client + code" DoD. One WASM configure builds all rungs' `gui_wasm` targets + (`.github/workflows/wasm-ladder.yml`, which also builds rung 0's spike; it + caches emsdk but has no compiler cache yet). + + **What rung 1 learned doing this for real** (the `gui_lib` split is + necessary but not sufficient): a client's presenters are + `BridgeHandler` templates, so a WASM client still *names* its rung's + model type and therefore still includes its model header. Every rung's + models acquire their `Lightweight::DataMapper` connection per `execute()` + call from `Lightweight::GlobalDataMapperPool()` (rather than a model + owning one via a `WithMapper`-style mixin member — the pattern this + section used to document before that mixin was removed in favor of the + pool), so the model *header* itself has no Lightweight/ODBC dependency to + begin with — only the model's `.cpp` (where the real query/transaction + bodies live) does. Configure the WASM build with + **`-DMORPH_CLIENT_ONLY=ON`** (removes the registrars that closure over the + model's ODBC-backed bodies — `docs/spec/core/registry.md`; + `morph_add_rung()` fails the configure with that explanation if it is + missing) and that `.cpp` is never compiled for Emscripten at all + (`cmake/morph_add_rung.cmake`'s `if(NOT EMSCRIPTEN)` guard around + `ladder__lib`'s own creation) — no header-level stub or branch is + needed on top of that. `include/morph/core/registry.hpp`'s + `BRIDGE_REGISTER_ACTION_FOR_CLIENT(M, A, RESULT, NAME, ...)` remains + available for a client willing to make `M` a declaration-only facade type + instead, closing the header dependency for cases where a model's own + entity types still need a persistence-free stand-in on the WASM include + path (see `polls::db::PollRecord` et al.'s own `#ifndef __EMSCRIPTEN__` + branch, `poll_entity.hpp`) — no rung's *model* header needs this today. +- **Coverage wiring (proven by rung 0, on `examples/common`; the same + recipe applies to every future rung's `src/models/`/`include//models/` + per [`IMPLEMENTATION.md`](IMPLEMENTATION.md) rule 5).** The `clang-coverage` + CI leg is the only *sanitizer-matrix* leg that installs + `qt6-base-dev`/`qt6-websockets-dev`/`qt6-tools-dev`/`libgl1-mesa-dev` and + configures with + `-DMORPH_BUILD_QT=ON -DMORPH_BUILD_LADDER=ON -DMORPH_LADDER_RUNGS=all` + (asan/tsan/ubsan never build the ladder at all, so this cost is paid once); + its `ctest` invocation runs + with `QT_QPA_PLATFORM=offscreen` since the runner has no display. Every + ladder CMake target (`morph_ladder_gui`, `morph_ladder_app`, + `morph_ladder_testkit`, and each rung's own targets) wraps its definition + in `if(AF_COVERAGE) apply_coverage() endif()`, the same guard + `include/morph`'s own targets use. `scripts/coverage.sh` merges multiple + instrumented binaries into one report via llvm-cov's `-object` flag: one + `TEST_EXE` positional (the library's `morph_tests`) plus an `OBJECT_ARGS` + array populated with every other binary that exists in the build + (`ladder_common_tests` today; a future rung's own test binary joins the + same array the same way, guarded the same way — `if [ -x "$BINARY" ]` so + the script keeps working unchanged for a configure that didn't build + that rung) — and adds `examples/common` (and, per rung once it ships + models, `examples//src/models` + `include//models`) to the + positional source-path filter alongside `include/morph`. AUTOMOC's + generated `mocs_compilation.cpp` lives under the build tree, never under a + source-tree path this filter names, so moc output is excluded for free — + no separate exclusion mechanism needed. The blocking gate itself lives in + `codecov.yml`'s `component_management.individual_components`: one + component per path set, `informational: false`, with its `target:` set + from the measured ceiling per rule 5's coverage-artifact guidance (not a + blind 100%) — scoped to that component's paths so it never becomes an + unverified whole-repo claim, leaving the project-wide default status + `informational: true` as before. + +CI tiers (grounded in the existing workflows; unmanaged, the ladder +dominates CI minutes by rung 3). No separate nightly schedule: everything +below that isn't in the weekly tier runs in the ordinary per-push/per-PR +`ladder-tests` job, same as the rest of this repo's CI — a rung's cost is +managed by path-filtering (`MORPH_LADDER_RUNGS` computed from changed paths: +`examples//**` → that rung; `examples/common/**` or +`include/morph/**` → all rungs), not by deferring work to an off-hours run: + +1. **CI (every push/PR)**: one `ladder-tests` job (clone of `linux-qt`: + gcc-debug, offscreen, sccache), path-filtered per the `MORPH_LADDER_RUNGS` + rule above. `ctest -L ladder` — full ladder, all modes, including + `[stress]` (scaled via `MORPH_LADDER_CLIENTS`/`ACTIONS` on the affected + rungs), the kanban TSan leg (Local mode), and one Playwright browser smoke. + One Windows compile-only build (never 8 rungs × 4 MSVC presets) runs + alongside it. ASan is scoped to changed rungs. + + Two pieces of this live outside that job as shipped, for reasons of + toolchain rather than design. **The GUI half** — each rung's QML module, + desktop client and offscreen engine-load smoke test — needs + `MORPH_BUILD_FORMS_QML=ON`, whose Qt 6.5 floor the `ladder-tests` runner's + distro Qt (6.4.2) does not clear, so it is the `linux-all-features` job + (Qt 6.8 via aqtinstall) that configures `MORPH_BUILD_LADDER=ON` together + with `MORPH_BUILD_FORMS_QML=ON`. `morph_add_rung()` announces every target + it skips on the leg that cannot build them, so the omission is never + silent. **The WASM compile gate** needs emsdk plus a Qt-for-wasm kit, and + lives in its own workflow, `.github/workflows/wasm-ladder.yml`. +2. **Weekly**: rung-8 load script (large runner) only — a genuinely + separate concern from the rest of this tiering (hundreds–thousands of + sockets, a large self-hosted-class runner), not something that can run + on every push. Everything else the ladder needs, including sanitizer and + fuzz-style coverage, runs in the CI tier above; `ci.yml`'s existing + `valgrind`/fuzz jobs are themselves triggered on every push/PR today + (there is no scheduled workflow in this repo yet), so nothing in the + ladder should assume a cadence the rest of the project doesn't have. + +## Framework gaps this strategy exposes (candidate issues) + +1. Client-side execute deadline — no timeout on `Completion`; a + rate-limited/black-holed call hangs forever (`messagesPerSecond` drops + frames silently). Every polling helper must wrap its own timer until the + framework provides one. +2. `Bridge::pendingCalls()` (client-side quiescence observability) — makes + `settle()` exact; today presenter-level counters substitute. +3. `MainThreadExecutor::runOnce()/drain()` — a step, not a wall-clock pump. +4. `QtExecutor` with an optional `QObject*` context target — per-thread + affinity for future N-thread client topologies. +5. Connection-scoped simulated client (via `RemoteServer::openConnection()`) + — deterministic connection-lifetime tests without sockets. +6. Injectable time source usable by *remotely-constructed* (registry + default-constructed) models — until then, rungs use a process-global + now-provider set by tests (`examples/common` clock interface). diff --git a/examples/common/CMakeLists.txt b/examples/common/CMakeLists.txt new file mode 100644 index 00000000..530c898c --- /dev/null +++ b/examples/common/CMakeLists.txt @@ -0,0 +1,247 @@ +# SPDX-License-Identifier: Apache-2.0 +# +# Shared ladder infrastructure: the presenter architecture (gui/) and the +# testkit (testkit/). See examples/TESTING.md. + +# MORPH_BUILD_QT is required in *every* configure, Emscripten included: +# morph_ladder_app below is AppContext, whose Remote mode is a +# QtWebSocketBackend, and a WASM client is remote-only by rule +# (examples/IMPLEMENTATION.md rule 4's WASM clause). Native configures +# additionally need it for the testkit's BackendRig Socket mode and the +# fault-injection proxy. +if(NOT MORPH_BUILD_QT) + message(FATAL_ERROR + "MORPH_BUILD_LADDER requires MORPH_BUILD_QT=ON: AppContext's Remote mode, " + "the testkit's BackendRig Socket mode and the fault-injection proxy all " + "need morph::qt (Qt6::WebSockets).") +endif() + +# Qt6::WebSockets is required under Emscripten too — morph::qt's own INTERFACE +# links it, so every consumer below (and the WASM spike) needs it present. An +# earlier revision of this file assumed the opposite ("not part of the standard +# Qt-for-WebAssembly module set") and returned before this call; that was never +# tested against a real Emscripten toolchain, and it only deferred the same +# failure to the link. Qt does ship QtWebSockets for wasm; a wasm Qt kit +# installed without that module now fails here, at configure time, with Qt's +# own clear message instead of an undefined-symbol wall. +find_package(Qt6 6.5 REQUIRED COMPONENTS Core WebSockets) +qt_standard_project_setup(REQUIRES 6.5) + +# ── morph_ladder_gui: presenters, Qt6::Core only, no Catch2 ───────────────── +# Deliberately does NOT link morph::qt/morph_qt_impl (and so not +# Qt6::WebSockets): examples/TESTING.md's "Presenter architecture" rule 1 +# requires presenters to instantiate under a plain QCoreApplication. The one +# piece of shared gui/ code that genuinely needs the WebSocket backend — +# AppContext, for its Remote mode — lives in morph_ladder_app below instead. +# +# apply_coverage() (every ladder target below, when AF_COVERAGE is ON — +# see IMPLEMENTATION.md rule 5): AUTOMOC's generated mocs_compilation.cpp +# lives under the build tree, so scripts/coverage.sh's source-path filter +# (which only ever names source-tree paths, e.g. examples/common) already +# excludes moc output from the completeness bar — nothing extra needed here. +add_library(morph_ladder_gui STATIC + gui/presenter.cpp + gui/event_poller.cpp +) +add_library(morph::ladder_gui ALIAS morph_ladder_gui) +target_include_directories(morph_ladder_gui PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}) +target_link_libraries(morph_ladder_gui PUBLIC morph::morph Qt6::Core) +target_compile_features(morph_ladder_gui PUBLIC cxx_std_23) +set_target_properties(morph_ladder_gui PROPERTIES AUTOMOC ON) +apply_warnings(morph_ladder_gui) +if(AF_COVERAGE) + apply_coverage(morph_ladder_gui) +endif() + +# ── morph_ladder_app: AppContext — the deployment-mode-choosing layer ─────── +# Split out of morph_ladder_gui so that target can stay Qt6::Core-only (see +# its comment above). A rung's gui_lib links morph::ladder_gui; the shells +# that actually pick a backend (gui/, gui_wasm/, tests/) also link this. +add_library(morph_ladder_app STATIC + gui/app_context.cpp +) +add_library(morph::ladder_app ALIAS morph_ladder_app) +target_include_directories(morph_ladder_app PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}) +target_link_libraries(morph_ladder_app PUBLIC morph::morph morph::qt morph_qt_impl Qt6::Core) +target_compile_features(morph_ladder_app PUBLIC cxx_std_23) +# No Q_OBJECT here today (AppContext is a plain class); AUTOMOC is set to match +# the other ladder targets' convention so adding one later needs no CMake edit. +set_target_properties(morph_ladder_app PROPERTIES AUTOMOC ON) +apply_warnings(morph_ladder_app) +if(AF_COVERAGE) + apply_coverage(morph_ladder_app) +endif() + +# ── WebAssembly build ──────────────────────────────────────────────────────── +# Everything above this line builds under Emscripten and is exactly what a WASM +# client needs: the presenter base (morph_ladder_gui) and the deployment-mode +# layer (morph_ladder_app, i.e. AppContext in its Remote shape). Everything +# below does not and never will — morph_ladder_testkit and ladder_common_tests +# need Catch2 (MORPH_BUILD_TESTS is never part of a WASM configure, mirroring +# examples/bank/CMakeLists.txt's own EMSCRIPTEN early return) and the +# Lightweight ORM speaks ODBC, which does not exist in a browser +# (examples/IMPLEMENTATION.md rule 4's WASM clause). +# +# Rung 0 returned *before* the two targets above as well, which left every +# rung's gui_wasm target with no morph::ladder_gui/morph::ladder_app to link — +# flagged as a known gap when morph_add_rung() shipped (task 8) and closed +# here, when rung 1's WASM client became the first real consumer. +if(EMSCRIPTEN) + add_subdirectory(wasm_spike) + return() +endif() + +if(NOT MORPH_BUILD_TESTS) + message(FATAL_ERROR + "MORPH_BUILD_LADDER requires MORPH_BUILD_TESTS=ON: Catch2 backs the " + "ladder testkit (morph_ladder_testkit) and ladder_common_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) +set(LIGHTWEIGHT_BUILD_BENCHMARK OFF CACHE BOOL "" FORCE) +# Lightweight defaults to a shared library on Windows (LIGHTWEIGHT_BUILD_SHARED_DEFAULT +# in its own CMakeLists.txt), which means its classes need dll-interface annotations +# they don't have -- any target that both links Lightweight and calls apply_warnings() +# (ladder_common_tests, every rung's gui_lib, transitively through ladder__lib) +# fails under /WX on Lightweight's own C4251/C4275. Forcing a static build sidesteps +# 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) +FetchContent_Declare(Lightweight + GIT_REPOSITORY https://github.com/LASTRADA-Software/Lightweight.git + GIT_TAG v0.20260625.0 + GIT_SHALLOW TRUE +) +# 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 +# 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) +set(CMAKE_SKIP_INSTALL_RULES ${_morph_saved_skip_install_rules}) +unset(_morph_saved_skip_install_rules) + +find_package(Catch2 3 CONFIG QUIET) +if(NOT Catch2_FOUND) + message(FATAL_ERROR "Catch2 not found; MORPH_BUILD_TESTS=ON should have fetched it already (see root CMakeLists.txt).") +endif() + +# ── morph_ladder_testkit: pump/fixtures/rig/fault-proxy/interleaver ───────── +# strand_interleaver.hpp (DeterministicExecutor), db_fixture.hpp and +# db_fault_fixture.hpp are fully header-defined and have no .cpp: none is a +# QObject, none needs MOC, and the library already links a non-empty TU +# (fault_proxy.cpp), so content-free placeholder TUs would be dead weight. +add_library(morph_ladder_testkit STATIC + testkit/fault_proxy.cpp +) +add_library(morph::ladder_testkit ALIAS morph_ladder_testkit) +target_include_directories(morph_ladder_testkit PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}) +target_link_libraries(morph_ladder_testkit PUBLIC + morph::morph morph::qt morph_qt_impl morph::ladder_gui morph::ladder_app + Catch2::Catch2 Qt6::WebSockets Lightweight::Lightweight +) +target_compile_features(morph_ladder_testkit PUBLIC cxx_std_23) +set_target_properties(morph_ladder_testkit PROPERTIES AUTOMOC ON) +# Lightweight's headers are not -Werror clean (same caveat as bank/CMakeLists.txt) — +# do not apply_warnings() here. +if(AF_COVERAGE) + apply_coverage(morph_ladder_testkit) +endif() + +# ── ladder_common_tests: the testkit's own self-test suite ────────────────── +add_executable(ladder_common_tests + testkit/testkit_main.cpp + testkit/test_pump.cpp + testkit/test_clock.cpp + testkit/test_db_fixture.cpp + testkit/test_db_fault_fixture.cpp + testkit/test_db_busy_fixture.cpp + testkit/test_db_pool_drain.cpp + testkit/test_backend_rig.cpp + testkit/test_presenter.cpp + testkit/test_event_poller.cpp + testkit/test_fault_proxy.cpp + testkit/test_strand_interleaver.cpp + testkit/test_wasm_registration_path_native.cpp +) +target_link_libraries(ladder_common_tests PRIVATE morph::ladder_testkit) +# morph::ladder_testkit links Lightweight::Lightweight PUBLIC (above), and +# Lightweight's own target_include_directories() call is plain PUBLIC, not +# SYSTEM (its CMakeLists.txt) — so without this, apply_warnings() below +# (-Werror included) applies in full to every Lightweight header this +# target transitively sees. Same fix, same rationale, as +# cmake/morph_add_rung.cmake's identical block for ladder__gui_lib/ +# ladder__tests. +get_target_property(_lightweight_includes Lightweight::Lightweight INTERFACE_INCLUDE_DIRECTORIES) +if(_lightweight_includes) + target_include_directories(ladder_common_tests SYSTEM PRIVATE ${_lightweight_includes}) +endif() +unset(_lightweight_includes) +target_compile_features(ladder_common_tests PRIVATE cxx_std_23) +set_target_properties(ladder_common_tests PROPERTIES AUTOMOC ON) +apply_warnings(ladder_common_tests) +if(AF_COVERAGE) + apply_coverage(ladder_common_tests) +endif() + +include(Catch) +get_target_property(_qt_core_dll Qt6::Core IMPORTED_LOCATION) +cmake_path(GET _qt_core_dll PARENT_PATH _qt_bin_dir) +# RESOURCE_LOCK: catch_discover_tests registers every TEST_CASE as its own +# ctest test, so `ctest -j` would happily run two of them concurrently — and +# DbFixture resets *one* real, shared on-disk database by dropping its tables +# (testkit/db_fixture.hpp), which two concurrent cases would do to each other +# mid-test. No preset sets parallel jobs today, so this is prophylactic; the +# lock is on the whole binary rather than the DB-touching cases only because +# catch_discover_tests applies PROPERTIES uniformly and this suite is ~5s. +catch_discover_tests(ladder_common_tests + DISCOVERY_MODE POST_BUILD + DL_PATHS "${_qt_bin_dir}" + # Exactly one value per property name. catch_discover_tests forwards + # PROPERTIES as a flat CMake list through a `-D VAR=a;b;c` command line, + # where a list separator and a literal semicolon are indistinguishable and + # no escaping survives — so a multi-value `LABELS "ladder;ladder-0"` does + # not produce a two-label test, it shifts every following name/value pair + # by one. That is what this call used to do: `ladder-0` became a property + # *name* whose value was `TIMEOUT`, and neither the second label nor the + # timeout was ever applied (`ctest --show-only=json-v1` shows it). + PROPERTIES LABELS ladder TIMEOUT 120 RESOURCE_LOCK morph_ladder_test_db +) + +# The per-rung label (`ladder-0` here) has to be applied outside +# catch_discover_tests for the reason above. Tests only exist once ctest reads +# the generated file, so this runs as a second TEST_INCLUDE_FILES entry — +# appended after catch_discover_tests' own, hence processed after it. Three +# details are forced by ctest's script mode rather than chosen: +# * it iterates `_TESTS`, the variable the discovery file leaves +# behind — the DIRECTORY `TESTS` property is a configure-time property and +# reads back empty here; +# * that list interleaves per-test JSON metadata with the names, and +# `if(TEST ...)` always answers false in script mode, so the JSON entries +# are filtered by pattern instead; +# * it uses `set_tests_properties`, not `set_property(TEST ... APPEND ...)`, +# which errors with "TEST names that do not exist" here. That call +# *replaces* LABELS, so it restates `ladder` alongside `ladder-0`. The +# `LABELS ladder` above stays as the floor: CI filters on it, and it keeps +# working even if this post-pass is ever dropped. +file(GENERATE + OUTPUT "${CMAKE_CURRENT_BINARY_DIR}/ladder_common_tests_rung_label.cmake" + CONTENT [[ +foreach(_ladder_test IN LISTS ladder_common_tests_TESTS) + if(NOT _ladder_test MATCHES "\"class-name\"") + set_tests_properties("${_ladder_test}" PROPERTIES LABELS "ladder;ladder-0") + endif() +endforeach() +]] +) +set_property(DIRECTORY APPEND PROPERTY TEST_INCLUDE_FILES + "${CMAKE_CURRENT_BINARY_DIR}/ladder_common_tests_rung_label.cmake") diff --git a/examples/common/clock.hpp b/examples/common/clock.hpp new file mode 100644 index 00000000..e15a30d7 --- /dev/null +++ b/examples/common/clock.hpp @@ -0,0 +1,84 @@ +// SPDX-License-Identifier: Apache-2.0 +#pragma once + +#include + +#include +#include +#include +#include + +/// @file +/// The ladder-wide injectable "now" (examples/TESTING.md's framework-gaps +/// item 6; examples/LADDER.md framework prerequisite 3). `morph`'s registry +/// now has a per-instance construction-hook seam +/// (`ModelRegistryFactory::registerModel(modelId, factory)`, +/// `include/morph/core/registry.hpp`) that a caller could use to inject a +/// clock per instance, but adopting it means bypassing +/// `BRIDGE_REGISTER_MODEL`'s default-construction auto-registration in favor +/// of a manual `registerModel` call at startup — no rung has made that +/// switch. Every rung's time-dependent model logic instead reads +/// `morph::ladder::now()` (this process-global provider) rather than +/// `Timestamp::now()`/`DateTime::now()` directly, and a test overrides the +/// provider for the span it needs. + +namespace morph::ladder { + +namespace detail { + +/// @brief Sentinel meaning "disabled, read the real wall clock". Not `-1` (or +/// any other small negative number): `-1` is a valid epoch-ms value +/// for an instant one millisecond before 1970-01-01, so a +/// `ScopedClockOverride` freezing time to a genuine pre-epoch instant +/// would collide with the sentinel and be silently ignored. +/// `INT64_MIN` is an instant roughly 292 million years before the +/// epoch — outside any instant a real `DateTime` in test code will +/// ever hold. +inline constexpr std::int64_t kOverrideDisabled = std::numeric_limits::min(); + +/// @brief Process-global override, in epoch milliseconds; `kOverrideDisabled` +/// means "disabled, read the real wall clock". +[[nodiscard]] inline std::atomic& overrideMillisSlot() noexcept { + static std::atomic slot{kOverrideDisabled}; + return slot; +} + +} // namespace detail + +/// @brief The ladder's injectable "now". +/// @return The real wall-clock instant, or the frozen instant a live +/// `ScopedClockOverride` installed. +[[nodiscard]] inline ::morph::time::Timestamp now() { + const std::int64_t overrideMs = detail::overrideMillisSlot().load(); + if (overrideMs == detail::kOverrideDisabled) { + return ::morph::time::Timestamp::now(); + } + return ::morph::time::Timestamp{::morph::time::DateTime{ + std::chrono::sys_time{std::chrono::milliseconds{overrideMs}}}}; +} + +/// @brief Freezes `morph::ladder::now()` at a fixed instant for the guard's +/// lifetime; restores the previous override (nests correctly) on +/// destruction. +/// +/// Cross-thread visible (a `std::atomic`, not `thread_local`): a model under +/// test runs on its own strand/pool thread, not the test thread that +/// constructs this guard. +class ScopedClockOverride { + public: + /// @param frozenAt The instant `now()` reads for the guard's lifetime. + explicit ScopedClockOverride(::morph::time::DateTime frozenAt) noexcept + : _previous{detail::overrideMillisSlot().exchange(frozenAt.value.time_since_epoch().count())} {} + + ~ScopedClockOverride() { detail::overrideMillisSlot().store(_previous); } + + ScopedClockOverride(const ScopedClockOverride&) = delete; + ScopedClockOverride& operator=(const ScopedClockOverride&) = delete; + ScopedClockOverride(ScopedClockOverride&&) = delete; + ScopedClockOverride& operator=(ScopedClockOverride&&) = delete; + + private: + std::int64_t _previous; +}; + +} // namespace morph::ladder diff --git a/examples/common/gui/app_context.cpp b/examples/common/gui/app_context.cpp new file mode 100644 index 00000000..0dcafd87 --- /dev/null +++ b/examples/common/gui/app_context.cpp @@ -0,0 +1,87 @@ +// SPDX-License-Identifier: Apache-2.0 +#include "gui/app_context.hpp" + +#include + +#include +#include + +namespace morph::ladder::gui { + +AppContext::AppContext(Mode mode) { + // Built first in both modes: callbacks are delivered on the Qt thread + // regardless of where the model work itself runs. + _qtExecutor = std::make_unique<::morph::qt::QtExecutor>(); + + if (auto* local = std::get_if(&mode)) { + _workerPool = std::make_unique<::morph::exec::ThreadPoolExecutor>(local->workers); + auto backend = std::make_unique<::morph::backend::LocalBackend>(*_workerPool); + _bridge = std::make_unique<::morph::bridge::Bridge>(std::move(backend)); + // No transport to wait for: handlers may be built immediately. + markReady(); + return; + } + + auto& remote = std::get(mode); + // asyncRegistrationEnabled: the synchronous registerModel path nests a + // QEventLoop, which aborts a WASM page outright (examples/TESTING.md, + // "WASM reality"). Registering before the socket connects now queues and + // retries once it does (finding 017), but this class still defers via + // setConnectHandler below rather than registering immediately — simpler + // to reason about than relying on the queue, and what makes the + // readiness contract in this class's doc comment necessary. + auto backend = std::make_unique<::morph::qt::QtWebSocketBackend>( + remote.url, +#ifndef QT_NO_SSL + std::nullopt, +#endif + ::morph::qt::QtWebSocketBackend::Config{.asyncRegistrationEnabled = true}); + auto* rawBackend = backend.get(); // stays valid: the Bridge below co-owns the same object + _bridge = std::make_unique<::morph::bridge::Bridge>(std::move(backend)); + + // setConnectHandler, never waitForConnected(): the latter nests an event + // loop and hangs a WASM page. Installed after the Bridge is built because + // Bridge only ever installs a *reconnect* handler (bridge.hpp), so this + // slot is ours; the handler fires on every successful connect, first one + // included (src/qt/qt_websocket_backend.cpp's `connected` slot). + // + // This captures `this` without a matching teardown, which is the same + // hazard `~Bridge()` (bridge.hpp) documents and clears for its own + // *reconnect* handler: a co-owned backend that outlives `this` — e.g. via + // a `shared_ptr` some other code captured from `loadBackend()` before + // `~AppContext()` ran — could fire this handler after destruction and + // dereference freed memory. Nothing in the ladder as shipped extends the + // backend's lifetime that way, so this is safe in practice today, not by + // construction; a future caller that does must not rely on this class to + // protect them. + rawBackend->setConnectHandler([this] { markReady(); }); +} + +void AppContext::onReady(std::function callback) { + if (!callback) { + return; + } + if (_ready) { + callback(); + return; + } + _pendingReadyCallbacks.push_back(std::move(callback)); +} + +void AppContext::login(const std::string& principal) { + _bridge->setDefaultSession(::morph::session::Context{.principal = principal}); +} + +void AppContext::markReady() { + _ready = true; + // Moved out before invoking: a callback is free to register another one + // (which, with `_ready` already true, now runs inline rather than landing + // in the vector this loop is iterating). + auto callbacks = std::move(_pendingReadyCallbacks); + _pendingReadyCallbacks.clear(); + for (auto& callback : callbacks) { + callback(); + } +} + +} // namespace morph::ladder::gui diff --git a/examples/common/gui/app_context.hpp b/examples/common/gui/app_context.hpp new file mode 100644 index 00000000..b0a7ac39 --- /dev/null +++ b/examples/common/gui/app_context.hpp @@ -0,0 +1,144 @@ +// SPDX-License-Identifier: Apache-2.0 +#pragma once + +#include +#include +#include +#include +#include + +#include + +#include +#include +#include +#include +#include +#include + +/// @file +/// Backend-parameterized app context (examples/TESTING.md, "Presenter +/// architecture" rule 2). Replaces bank's hard-wired LocalBackend +/// (gui/BankClient.cpp) with one type presenters can be built against +/// regardless of deployment mode. +/// +/// This header lives in its own link target, `morph_ladder_app` +/// (`morph::ladder_app`), rather than in `morph_ladder_gui`: `Remote` mode +/// needs `morph::qt`/`morph_qt_impl` and transitively `Qt6::WebSockets`, +/// while `morph_ladder_gui` (presenters) is `Qt6::Core`-only by rule +/// (examples/TESTING.md, "Presenter architecture" rule 1 — presenters must +/// instantiate under a plain `QCoreApplication`). A rung's `gui_lib` links +/// `morph::ladder_gui`; its `gui`/`gui_wasm`/`tests` shells, which are the +/// things that actually choose a deployment mode, additionally link +/// `morph::ladder_app`. + +namespace morph::ladder::gui { + +/// @brief In-process backend, @p workers threads. +struct Local { + std::size_t workers = 4; +}; + +/// @brief Remote backend over `QtWebSocketBackend` at @p url. +/// +/// @warning Asynchronously connected. A `Remote` context is **not** usable +/// the line after its constructor returns — see `AppContext`'s +/// readiness contract (`ready()`/`onReady()`) below. +struct Remote { + QUrl url; +}; + +/// @brief Owns, in destruction-safe order (worker pool -> executor -> bridge, +/// declared in reverse), everything a presenter set needs and nothing +/// a presenter should construct itself. +/// +/// @par Readiness contract (why `Remote` mode still defers registration) +/// `Local` mode has no network dependency: `ready()` is `true` the moment the +/// constructor returns and `onReady()` invokes its callback synchronously. +/// +/// `Remote` mode builds its `QtWebSocketBackend` with +/// `Config{.asyncRegistrationEnabled = true}` (the plain synchronous +/// `registerModel` nests a `QEventLoop` and aborts a WASM page — +/// examples/TESTING.md, "WASM reality"). `QtWebSocketBackend:: +/// registerModelAsync()` queues a registration issued before the socket +/// has finished connecting and retries it once the connection comes up +/// (`docs/spec/core/backend.md`, "Asynchronous registration"), so +/// building a `BridgeHandler` immediately after this constructor returns is +/// no longer the correctness hazard it once was. +/// +/// This class still detects readiness with `setConnectHandler` — not +/// `waitForConnected()`, which nests an event loop and hangs a WASM page — +/// and callers build their presenters (and therefore their `BridgeHandler`s) +/// from inside `onReady()`: +/// +/// ```cpp +/// AppContext ctx{Remote{url}}; +/// ctx.onReady([&] { presenters.emplace(ctx.bridge(), ctx.executor()); }); +/// ``` +/// +/// This is the same ordering `examples/common/wasm_spike/main_wasm.cpp` +/// demonstrates end-to-end — deferring to `onReady()` is simpler to reason +/// about than relying on the pre-connect queue, not a requirement for +/// correctness. +class AppContext { + public: + using Mode = std::variant; + + /// @brief Builds the backend/bridge/executor set for @p mode. + /// @param mode Deployment shape: `Local{workers}` or `Remote{url}`. + explicit AppContext(Mode mode); + + AppContext(const AppContext&) = delete; + AppContext& operator=(const AppContext&) = delete; + AppContext(AppContext&&) = delete; + AppContext& operator=(AppContext&&) = delete; + ~AppContext() = default; + + /// @brief The bridge every handler in this context is built against. + /// @return Reference to the owned `Bridge`. + [[nodiscard]] ::morph::bridge::Bridge& bridge() { return *_bridge; } + + /// @brief The Qt-thread executor every handler delivers callbacks on. + /// @return Non-owning pointer to the owned `QtExecutor`. + [[nodiscard]] ::morph::exec::IExecutor* executor() { return _qtExecutor.get(); } + + /// @brief Whether the transport is up and handlers may now be built. + /// + /// Always `true` for `Local` (no transport to wait for). For `Remote`, + /// `false` until the WebSocket's first successful connect — see the + /// class doc comment's readiness contract. + /// @return `true` once `BridgeHandler` construction against `bridge()` + /// is safe. + [[nodiscard]] bool ready() const noexcept { return _ready; } + + /// @brief Runs @p callback once the context is ready. + /// + /// Invoked immediately (synchronously, before returning) if `ready()` is + /// already `true` — which is always the case in `Local` mode. Otherwise + /// queued and invoked exactly once, from the backend's connect handler, + /// on the Qt event-loop thread. Registering several callbacks runs them + /// in registration order. + /// + /// @param callback Work to run once handlers may be built — typically + /// the construction of this context's presenters. + void onReady(std::function callback); + + /// @brief Sets the default session principal every handler built against + /// this context's bridge dispatches under. + /// @param principal Auth principal (user id) — becomes + /// `session::Context::principal` in the bridge's default session + /// (see `include/morph/session/session.hpp`). + void login(const std::string& principal); + + private: + /// @brief Flips `ready()` and drains the queued `onReady()` callbacks. + void markReady(); + + std::unique_ptr<::morph::exec::ThreadPoolExecutor> _workerPool; // Local only + std::unique_ptr<::morph::qt::QtExecutor> _qtExecutor; + std::unique_ptr<::morph::bridge::Bridge> _bridge; + bool _ready{false}; + std::vector> _pendingReadyCallbacks; +}; + +} // namespace morph::ladder::gui diff --git a/examples/common/gui/event_poller.cpp b/examples/common/gui/event_poller.cpp new file mode 100644 index 00000000..0c36b599 --- /dev/null +++ b/examples/common/gui/event_poller.cpp @@ -0,0 +1,32 @@ +// SPDX-License-Identifier: Apache-2.0 +#include "gui/event_poller.hpp" + +namespace morph::ladder::gui::detail { + +bool isClientTimeout(const std::exception_ptr& err) noexcept { + if (!err) { + return false; + } + try { + std::rethrow_exception(err); + } catch (const ::morph::backend::ClientTimeoutError&) { + return true; + } catch (...) { + return false; + } +} + +QString describeFailure(const std::exception_ptr& err) { + if (!err) { + return QStringLiteral("EventPoller: dispatch failed with no exception information"); + } + try { + std::rethrow_exception(err); + } catch (const std::exception& ex) { + return QString::fromStdString(ex.what()); + } catch (...) { + return QStringLiteral("EventPoller: dispatch failed with a non-std::exception"); + } +} + +} // namespace morph::ladder::gui::detail diff --git a/examples/common/gui/event_poller.hpp b/examples/common/gui/event_poller.hpp new file mode 100644 index 00000000..47f7bff9 --- /dev/null +++ b/examples/common/gui/event_poller.hpp @@ -0,0 +1,552 @@ +// SPDX-License-Identifier: Apache-2.0 +#pragma once + +#include +#include +#include + +#include +#include +#include + +#include +#include +#include +#include +#include +#include + +/// @file +/// This rung's framework-level deliverable (Task 15) — "every later rung +/// inherits this helper; get it right here" (this rung's README). A +/// Zulip-pattern event poller: on a fixed interval, ask "everything since my +/// last cursor", apply what comes back, and either keep going or stop. +/// +/// @par Design choice: a template, not a `polls`-specific class +/// The task brief explicitly allows either "a fully generic template" or "a +/// narrower, polls-specific-but-easily-generalized type", left to +/// implementation judgment, since a template can be awkward to write cleanly +/// for a first use. This file goes with the template +/// (`EventPoller`), for one concrete reason: +/// `examples/common/gui/` cannot depend on `examples/polls/` (rung 3 code +/// building on rung-0/shared infrastructure, never the reverse — see +/// `examples/common/CMakeLists.txt`), so a class living here can never name +/// `polls::PollEvent`/`polls::PollEventId`/`polls::gui::PollPresenter` +/// directly. The two type parameters are the only polls-shaped facts this +/// class actually needs to know about at compile time; everything else — +/// how to dispatch `GetEventsSince`, how to detect the two Bridge error +/// families, what "success" and "one tick" mean operationally — is captured +/// once, here, so kanban's own event feed does not have to re-derive the +/// retry-vs-fatal decision tree from scratch. What kanban supplies per its +/// own rung is a `Dispatch` closure (see below) that knows how to reach +/// *its* presenter; `EventPoller` itself never needs to know that type. +/// +/// @par Why dispatch is a caller-supplied closure, not a stored `Presenter&` +/// A `polls::gui::PollPresenter::getEventsSince(GetEventsSince)` call is +/// `void` and reports its outcome through Qt signals shared with every other +/// action that presenter exposes — there is no direct +/// `Completion` handed back to a caller sitting outside +/// the presenter. A concrete `EventPoller` could special-case one presenter's +/// signal shape, but a *generic* one cannot assume any particular presenter's +/// signal surface at all. The `Dispatch` alias below is the seam: it hands +/// the whole "how do I actually reach the backend, and how do I learn what +/// happened" question back to the caller, once per tick. +/// +/// @warning Do **not** build a `Dispatch` closure out of a presenter's shared +/// error signal. `polls::gui::PollPresenter::failed(QString)` is *one* signal +/// for all nine `PollModel` actions — every method's `track()` call routes +/// its failure through the same `reportError()`, which emits that same +/// `failed`. A live poll view routinely has `submitVotes`, `addComment` or +/// `finalizePoll` in flight *concurrently* with a poll tick, so a `Dispatch` +/// listening on `failed` cannot tell whose failure it just saw: it will +/// attribute some other action's error to this tick (stopping the poller for +/// an unrelated reason) while this tick's real failure goes to whoever else +/// happened to be listening. Two further defects compound it: +/// `PollPresenter::reportError` catches only `std::exception`, so a +/// non-`std::exception` failure emits nothing at all — `Dispatch` then never +/// calls `onSuccess` *or* `onError`, wedging `_requestInFlight` forever with +/// no recovery — and `failed(QString)` has already stringified the exception, +/// so `ClientTimeoutError` can only be recovered by comparing that `QString` +/// against `ClientTimeoutError{}.what()`, a string comparison standing in for +/// a type check. This route is unsound; do not use it. +/// +/// @par The production-safe wiring: one `Dispatch`, one direct dispatch call +/// Build `Dispatch` directly over a dedicated `BridgeHandler` (or any other API that hands back a +/// `Completion` per call) and attach `.then()`/`.onError()` to *that +/// call's own* completion. Nothing can be cross-attributed, because the +/// completion belongs to this tick and nothing else; every failure path +/// reaches `onError`, including non-`std::exception` ones; and +/// `ClientTimeoutError` stays a real, catchable C++ type end to end, never +/// stringified. `examples/common/testkit/test_event_poller.cpp`'s +/// `makeDispatch()` is the reference implementation of exactly this shape — +/// read it before wiring a poller into a real GUI shell. +/// +/// (If a presenter-mediated route is ever genuinely wanted, the presenter +/// would first have to grow a *dedicated*, typed error signal for the polling +/// action alone — not the shared `failed(QString)` — carrying the +/// `std::exception_ptr` rather than a message. That is out of scope here and +/// no presenter offers it today; the direct-handler route above needs no such +/// change.) +/// +/// @par Bridge::setExecuteDeadline is bridge-wide, not per-handler +/// The constructor calls `bridge.setExecuteDeadline(executeDeadline)` itself +/// (the task brief's own instruction: a caller forgetting to configure this +/// is exactly the mistake this helper exists to make impossible — without +/// it, a rate-limited server silently dropping a poll frame hangs the +/// poller's in-flight call forever). This setting lives on the `Bridge` +/// object, not on any one handler, so constructing an `EventPoller` clobbers +/// whatever deadline (if any) was configured on that `Bridge` before, and a +/// second `EventPoller` — or any other code calling `setExecuteDeadline` — +/// against the same `Bridge` clobbers this one's in turn. Fine for this +/// ladder's actual shape (one `Bridge` per `AppContext`, at most one poller +/// per view), but worth knowing before sharing a `Bridge` across components +/// with differing deadline needs. +/// +/// That call is also the *only* reason a browser tab would ever need a +/// deadline mechanism at all, and until the final whole-branch review of +/// rung 3 it was a latent WASM abort: `Bridge::setExecuteDeadline` lazily +/// 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). +/// +/// @par Default poll interval and its trade-off +/// `kDefaultInterval` is 3 seconds. This is this class's answer to the +/// README's "Expected strain points" question ("Poll-interval latency: two +/// voters editing simultaneously see each other only on the next tick — +/// measure and document acceptable intervals"): shorter intervals lower that +/// latency but multiply server load and DB read pressure linearly with +/// concurrent viewers (N viewers on one poll = N `GetEventsSince` calls per +/// interval, forever, for as long as the poll stays open); longer intervals +/// do the reverse. 3 seconds sits in the middle of the brief's own suggested +/// 2-3s range: noticeable-but-tolerable staleness for a live vote/comment +/// feed, without turning an open poll page into a request storm. Not a +/// physical constant — override it per call site if a rung's own load +/// profile calls for something else. +/// +/// @par Default execute deadline +/// `kDefaultExecuteDeadline` is 5 seconds — generous enough to absorb a real, +/// loaded round trip (matching the order of magnitude `pumpUntil`'s own 5s +/// default budget uses elsewhere in this codebase) while still bounding how +/// long one silently-dropped frame can wedge a poll tick. It deliberately +/// exceeds `kDefaultInterval`: `EventPoller` never lets two dispatches race +/// (see `busy()`), so an in-flight call that outlives one interval simply +/// makes the next timer tick a no-op rather than piling up concurrent calls; +/// the deadline's only job is to guarantee that "no-op" state cannot last +/// forever. +/// +/// @note Unlike every other wait budget in the ladder testkit +/// (`examples/common/testkit/pump.hpp`'s `pumpUntil`/`awaitQt`, scaled by the +/// `MORPH_LADDER_DEADLINE_MS` env var via `deadlineScale()`), this constant +/// cannot be scaled the same way: `examples/common/gui/` is production code +/// shipped to real clients and must not depend on `examples/common/testkit/`. +/// A production adapter that constructs an `EventPoller` with no override +/// (e.g. `polls::gui::PollBridge::startPolling`) therefore always arms the +/// unscaled 5s value, even under a test run where `MORPH_LADDER_DEADLINE_MS` +/// has deliberately raised every *other* wait budget for a slow/loaded CI +/// runner or sanitizer build. On such a runner, a test that opens a real +/// adapter and dispatches further actions on the same `Bridge` races those +/// actions against this fixed deadline underneath a scaled test budget meant +/// to give them slack — a real, if currently unobserved, source of spurious +/// CI flakiness. Deliberately not "fixed" by adding a test-only override +/// parameter to `PollBridge`'s constructor: that adapter's own design +/// explicitly avoids exposing internals a test could drive around production +/// wiring (see its own class doc comment). If this ever causes a real, +/// reproduced flake, the right fix is likely a dedicated, clearly-named +/// test-only constructor overload on the adapter (not on this class, which +/// has no test-only knowledge to begin with), not a change here. +/// +/// @par Thread affinity +/// Like every other `examples/common/gui/` type, this class owns a `QTimer` +/// and must be constructed and used on the Qt event-loop thread. +namespace morph::ladder::gui { + +/// @brief Free functions the template below delegates to — pulled out of the +/// class body (and into `event_poller.cpp`, not header-inlined) for +/// the same reason `examples/common/testkit/pump.hpp`'s +/// `computeDeadlineScale` is factored out of `deadlineScale()`: pure +/// exception-classification logic that has nothing to do with +/// `EventT`/`EventIdT`, and is worth compiling once rather than once +/// per `EventPoller` instantiation. +namespace detail { + +/// @brief Whether @p err is a `morph::backend::ClientTimeoutError` — the one +/// error `EventPoller` treats as transient. +/// +/// Exactly one `rethrow_exception` and catch: a `ClientTimeoutError` nested +/// inside some other exception (`std::throw_with_nested`) is *not* detected +/// and is treated as fatal. Nothing on this class's paths produces one — +/// `Bridge`'s deadline machinery sets the timeout as the completion's +/// exception directly — so there is no nested walk here to go stale. +/// @param err The exception captured from a dispatch's `onError` callback; +/// `nullptr` is treated as "not a timeout". +/// @return `true` if rethrowing @p err lands in a `ClientTimeoutError` catch. +[[nodiscard]] bool isClientTimeout(const std::exception_ptr& err) noexcept; + +/// @brief Renders @p err as the message `onFatalError` receives. +/// @param err The exception captured from a dispatch's `onError` callback. +/// @return `std::exception::what()` if @p err rethrows into one, otherwise a +/// canned "non-std::exception" message; never empty. +[[nodiscard]] QString describeFailure(const std::exception_ptr& err); + +} // namespace detail + +/// @brief Periodic "GetEventsSince"-shaped poller — this rung's +/// framework-level deliverable. See this file's own top-of-file +/// comment for the full design rationale. +/// @tparam EventT One event as the caller's dispatch layer returns it +/// (e.g. `polls::PollEvent`). Never interpreted by this class — +/// only forwarded, one at a time and in order, to `onEvent`. +/// @tparam EventIdT The cursor type (e.g. `polls::PollEventId`). Copied, +/// never compared or arithmetic'd on — advancing it is entirely the +/// `Dispatch` closure's job (it reports back the new value). +template +class EventPoller { + public: + /// @brief Applies one event, in the order `Dispatch` returned it. + /// + /// @warning Must not destroy the `EventPoller` it belongs to. It is + /// called from inside `pollOnce()`'s success callback, underneath the + /// RAII `FlagGuard` that clears `_requestInFlight` when that frame + /// unwinds — destroying the poller from here leaves that guard writing + /// to freed storage. (`onFatalError` is the one callback for which + /// self-destruction *is* supported; see `handleError`.) A view that + /// wants to close itself in reaction to an event should schedule it — + /// `QTimer::singleShot(0, …)`, `deleteLater()` — not do it inline. + using ApplyEvent = std::function; + + /// @brief Reports the one fatal (non-timeout) failure this poller will + /// ever surface — see the class doc comment's retry-vs-fatal rule. + using OnFatalError = std::function; + + /// @brief One tick's success outcome: every event since the cursor this + /// tick dispatched with, oldest first, plus the cursor's new + /// value (ordinarily the last event's id; the `Dispatch` closure + /// decides, so a batch of zero events can still report the same + /// cursor back unchanged). + using OnSuccess = std::function events, EventIdT newLastEventId)>; + + /// @brief One tick's failure outcome. Whatever `Dispatch` observed — + /// typically whatever a `Completion<...>::onError` handed it, or + /// (see the class doc comment) whatever a presenter's own + /// string-only error signal was translated back into. + using OnError = std::function; + + /// @brief One tick's dispatch. Called with the current cursor; must call + /// exactly one of `onSuccess`/`onError`, synchronously or later, + /// exactly once. Never called again (`pollOnce()` is a no-op) + /// until the previous call's outcome has been reported. + using Dispatch = std::function; + + /// @brief See the class doc comment's "Default poll interval" section. + static constexpr std::chrono::milliseconds kDefaultInterval{3000}; + + /// @brief See the class doc comment's "Default execute deadline" section. + static constexpr std::chrono::milliseconds kDefaultExecuteDeadline{5000}; + + /// @param bridge The `Bridge` `dispatch` ultimately calls + /// through. Used here only to call `setExecuteDeadline` — see the + /// class doc comment's "Bridge::setExecuteDeadline is bridge-wide" + /// section for why that is the *only* thing this class does with + /// it, and why that alone is still worth a reference parameter. + /// @param startingCursor The cursor to dispatch the first tick with + /// (e.g. a freshly opened poll's own `GetPollStateResult`'s + /// `lastEventId`). + /// @param dispatch One tick's real work — see `Dispatch`'s own doc + /// comment. + /// @param onEvent Applies one event; called once per event + /// returned by a successful tick, in order. + /// @param onFatalError Called exactly once, the first time a + /// non-`ClientTimeoutError` failure stops this poller. + /// @param interval How often to tick. Defaults to + /// `kDefaultInterval`. + /// @param executeDeadline Forwarded to `bridge.setExecuteDeadline()` on + /// construction. Defaults to `kDefaultExecuteDeadline`. + EventPoller(::morph::bridge::Bridge& bridge, EventIdT startingCursor, Dispatch dispatch, ApplyEvent onEvent, + OnFatalError onFatalError, std::chrono::milliseconds interval = kDefaultInterval, + std::chrono::milliseconds executeDeadline = kDefaultExecuteDeadline) + : _lastEventId{std::move(startingCursor)}, + _dispatch{std::move(dispatch)}, + _onEvent{std::move(onEvent)}, + _onFatalError{std::move(onFatalError)} { + bridge.setExecuteDeadline(executeDeadline); + // `&_timer` as the connection's context object, not `this`: this + // class is not itself a `QObject` (see the class doc comment's + // "template, not a polls-specific class" note — a template cannot + // carry `Q_OBJECT`/moc output), so `_timer`, a member that is always + // destroyed before `this`'s storage is freed, stands in as the + // lifetime anchor Qt's auto-disconnect-on-destruction machinery + // needs. + // + // This covers the *periodic-timer signal* path only, and nothing + // else. It does not, and cannot, protect the *completion-callback* + // path: the lambdas `pollOnce()` hands to `_dispatch` are delivered + // by whatever executor the `Bridge` completes on — in practice + // `QtExecutor::post`, i.e. `QMetaObject::invokeMethod(..., + // Qt::QueuedConnection)`, which makes the pending callback an event + // owned by `QCoreApplication`, not a connection owned by `_timer`. + // Destroying `_timer` disconnects nothing of the sort. The + // `_liveness` token (last member; see its declaration) is what + // guards that path instead. + QObject::connect(&_timer, &QTimer::timeout, &_timer, [this] { pollOnce(); }); + _timer.start(interval); + } + + ~EventPoller() = default; + EventPoller(const EventPoller&) = delete; + EventPoller& operator=(const EventPoller&) = delete; + EventPoller(EventPoller&&) = delete; + EventPoller& operator=(EventPoller&&) = delete; + + /// @brief Runs one tick right now, synchronously dispatching (though the + /// outcome may resolve later, asynchronously). + /// + /// A no-op if a fatal error has already stopped this poller, or if a + /// previously dispatched tick has not yet reported its outcome — ticks + /// never overlap. This is what the owned `QTimer` calls on every + /// `interval`; it is public so a caller (or a test) can drive a tick + /// deterministically instead of waiting on the real timer — see + /// `examples/common/testkit/test_event_poller.cpp`'s own "drive the + /// timer manually" tests. + void pollOnce() { + if (_fatal || _requestInFlight) { + return; + } + _requestInFlight = true; + _dispatch( + _lastEventId, + [this, alive = std::weak_ptr{_liveness}](std::vector events, + EventIdT newLastEventId) { + // Liveness check first, before touching any member: this + // callback outlives `this` whenever the poller is destroyed + // with a tick in flight. See `_liveness`'s declaration. + if (alive.expired()) { + return; + } + // Cursor first, in-flight flag last. The window between them + // is exactly the window in which `_onEvent` runs, and + // `_onEvent` is caller code that may spin a nested Qt event + // loop (a modal dialog is ordinary GUI behaviour) and + // reenter `pollOnce()`. Advancing `_lastEventId` up front + // means such a reentrant tick asks for events *after* this + // batch rather than replaying it; keeping `_requestInFlight` + // set until this frame unwinds means it is refused outright, + // so this frame's later writes cannot rewind whatever a + // nested frame already advanced to. + _lastEventId = std::move(newLastEventId); + // RAII, not a plain assignment after the loop: a throwing + // `_onEvent` must still clear the flag, or `busy()` stays + // true forever and the poller never ticks again — the same + // hazard (and the same rule) as + // `examples/common/gui/presenter.hpp`'s `Presenter::track()`. + // A local guard struct, matching this codebase's existing + // idiom (`include/morph/net/socket_server.hpp`'s + // `ScopeGuard`); there is no shared scope-guard type here. + struct FlagGuard { + explicit FlagGuard(bool& target) : flag{target} {} + ~FlagGuard() { flag = false; } + FlagGuard(const FlagGuard&) = delete; + FlagGuard& operator=(const FlagGuard&) = delete; + FlagGuard(FlagGuard&&) = delete; + FlagGuard& operator=(FlagGuard&&) = delete; + bool& flag; + }; + const FlagGuard guard{_requestInFlight}; + for (const auto& event : events) { + _onEvent(event); + } + }, + [this, alive = std::weak_ptr{_liveness}](std::exception_ptr err) { + if (alive.expired()) { + return; + } + _requestInFlight = false; + handleError(err); + }); + } + + /// @brief (Re)arms the periodic timer at its configured interval. Already + /// running on construction; this is for a caller that previously + /// called `stop()` (e.g. a hidden poll view pausing its own + /// polling). A no-op once a fatal error has stopped this poller + /// for good. + void start() { + if (!_fatal) { + _timer.start(); + } + } + + /// @brief Disarms the periodic timer without treating this as a fatal + /// error — `onFatalError` is not called. Idempotent + /// (`QTimer::stop()` on a stopped timer is a no-op). + void stop() { _timer.stop(); } + + /// @brief Clears a fatal error, resets the cursor, and rearms the timer. + /// + /// The supported way back from `onFatalError`. A fatal error is normally + /// permanent: `start()` refuses to rearm and `_fatal` never clears, so + /// without this method a caller's only recovery would be destroying and + /// reconstructing the whole poller — which also re-runs the constructor's + /// `bridge.setExecuteDeadline()` call and so clobbers whatever deadline + /// anything else on that same `Bridge` had set since (see the class doc + /// comment's "bridge-wide, not per-handler" section). + /// + /// This exists because the fatal errors this class reports are exactly + /// the ones a GUI recovers from by *resyncing*: a stale cursor whose + /// events the server has already pruned fails the tick, the view falls + /// back to a full `GetPollState`, and that result carries a fresh + /// `lastEventId` to resume incremental polling from. Pass that value + /// here. + /// + /// Calling this on a poller that never went fatal is still meaningful — + /// it repoints the cursor and rearms — but note it does *not* cancel a + /// tick already in flight: if `busy()` is true, that tick's own success + /// callback will overwrite @p newCursor with whatever it reports. Resume + /// once the poller is idle. + /// + /// @param newCursor The cursor the next tick dispatches with, ordinarily + /// obtained from the full-state resync that followed the fatal + /// error. + void resume(EventIdT newCursor) { + _fatal = false; + _lastEventId = std::move(newCursor); + _timer.start(); + } + + /// @brief Whether the periodic timer is currently armed. + /// @return `true` if a tick will fire on the next `interval` elapsing. + [[nodiscard]] bool running() const noexcept { return _timer.isActive(); } + + /// @brief Whether a dispatched tick's outcome has not yet been reported. + /// @return `true` while `pollOnce()` would be a no-op because a previous + /// tick is still outstanding. + [[nodiscard]] bool busy() const noexcept { return _requestInFlight; } + + /// @brief Whether `onFatalError` has already fired. + /// @return `true` once a non-timeout dispatch failure has stopped this + /// poller for good. + [[nodiscard]] bool fatalErrorReported() const noexcept { return _fatal; } + + /// @brief The cursor the next tick will dispatch with. + /// @return The cursor the most recent successful tick reported, or the + /// constructor's `startingCursor` if no tick has yet succeeded. + /// Advanced *before* that tick's `onEvent` fan-out, not after it + /// (see `pollOnce()`'s "cursor first, in-flight flag last" + /// note), so a value read from inside `onEvent` already names the + /// batch being applied — and a throwing `onEvent` does not rewind + /// it. A failed tick leaves it untouched; `resume()` sets it + /// outright. + [[nodiscard]] const EventIdT& lastEventId() const noexcept { return _lastEventId; } + + private: + /// @brief Routes one tick's failure: retry (log, stay armed) for + /// `ClientTimeoutError`, stop-and-report-once for anything else. + /// @param err The exception a `Dispatch` call's `onError` reported. + void handleError(const std::exception_ptr& err) { + if (detail::isClientTimeout(err)) { + ::morph::log::logError( + "EventPoller: GetEventsSince timed out waiting for a reply (Bridge::setExecuteDeadline); " + "retrying on the next tick"); + return; + } + if (_fatal) { + // Load-bearing, not merely defensive. `pollOnce()` refuses to + // dispatch a *new* tick once `_fatal` is set, but nothing + // mechanically enforces `Dispatch`'s "call exactly one of + // onSuccess/onError, exactly once" contract — it is a + // caller-supplied `std::function`, and a closure that + // double-reports (e.g. one wired to a signal that fires twice, + // or one whose `.onError` is also reached by a second failure + // path) lands here with `_fatal` already set. This is the check + // that keeps `onFatalError`'s "exactly once" promise true + // regardless. + return; + } + _fatal = true; + _timer.stop(); + const QString message = detail::describeFailure(err); + ::morph::log::logError("EventPoller: dispatch failed non-recoverably, polling stopped: " + + message.toStdString()); + if (_onFatalError) { + // Deliberately the last statement of this function, and it must + // stay that way: `onFatalError` destroying the `EventPoller` is a + // natural GUI reaction ("the poll is gone, close this view"), and + // it is safe today only because (a) nothing here touches a member + // after this call returns, and (b) the callback that reached + // `handleError` is owned by the `Completion`'s own + // `CompletionState`, which is reference-counted independently of + // this object — so the lambda frame itself survives its own + // `this` being freed. Appending any member access after this + // line, or ever invoking `_onFatalError` from a lambda that the + // `EventPoller` itself owns, breaks that and reintroduces a + // use-after-free. + // + // One caveat the two conditions above do not cover: `_onFatalError` + // is itself a member, so this very call expression reads storage + // that the callback it invokes may free. A `std::function`'s + // invocation does not copy its target, and a callback that + // destroys the poller destroys the `std::function` frame it is + // running inside. It is safe today only because no callback wired + // anywhere in this repository does that — the one real callback, + // `PollBridge`'s (`examples/polls/gui_lib/poll_qml_bridges.cpp`), + // emits `pollingStopped`, which nothing in this rung's QML is + // even connected to, let alone tears the poll view down from. A + // future callback that really must destroy the poller should be + // given a local copy to invoke (`auto callback = _onFatalError; + // callback(message);`) rather than relying on this member + // surviving its own invocation. + _onFatalError(message); + } + } + + // Member declaration order below is load-bearing in two places; do not + // reorder without reading both. + // - `_timer` must remain a *member* (not, say, a `unique_ptr` released + // early or an object owned elsewhere), because it is the context + // object of the `timeout` connection the constructor makes: being a + // member is what guarantees it is destroyed — and so the connection + // auto-disconnected — before this object's storage goes away. That + // covers the timer signal path, and only that path. + // - `_liveness` must stay **last**. Members are destroyed in reverse + // declaration order, so the last-declared member is destroyed first: + // the token expires before anything a completion callback might touch + // (`_requestInFlight`, `_onEvent`, `_lastEventId`, `_dispatch`, …) has + // been torn down, which is precisely what makes the `alive.expired()` + // checks in `pollOnce()` correct rather than racy. Same reasoning, and + // the same placement, as `morph::bridge::Bridge::_liveness` + // (`include/morph/core/bridge.hpp`). + EventIdT _lastEventId; + Dispatch _dispatch; + ApplyEvent _onEvent; + OnFatalError _onFatalError; + QTimer _timer; + bool _requestInFlight = false; + bool _fatal = false; + /// @brief Weak-observable proof this object still exists. + /// + /// The callbacks `pollOnce()` hands to `_dispatch` capture a + /// `std::weak_ptr` to this and bail out if it has expired. They cannot + /// capture `this` alone: a completion callback is delivered through + /// `QtExecutor::post` → `QMetaObject::invokeMethod(..., + /// Qt::QueuedConnection)`, making it a queued event owned by + /// `QCoreApplication` — nothing about destroying an `EventPoller` (its + /// `_timer` included) cancels it. Destroying a poller while `busy()` is + /// true is the *ordinary* case (a user closes a poll view mid-tick), not + /// an edge case, and without this token that queued callback fires into + /// freed memory. Same pattern, for the same reason, as + /// `morph::bridge::Bridge::_liveness` and + /// `examples/common/testkit/backend_rig.hpp`'s + /// `QtDrivenMainThreadExecutor`. **Must remain the last declared member** + /// — see the note above. + std::shared_ptr _liveness{std::make_shared()}; +}; + +} // namespace morph::ladder::gui diff --git a/examples/common/gui/presenter.cpp b/examples/common/gui/presenter.cpp new file mode 100644 index 00000000..68cafb44 --- /dev/null +++ b/examples/common/gui/presenter.cpp @@ -0,0 +1,6 @@ +// SPDX-License-Identifier: Apache-2.0 +#include "gui/presenter.hpp" + +// Q_OBJECT (via the header) needs at least one non-header translation unit in +// its target for moc's generated file to link against; this file exists for +// that reason even though Presenter's own logic is fully inline above. diff --git a/examples/common/gui/presenter.hpp b/examples/common/gui/presenter.hpp new file mode 100644 index 00000000..0768bb86 --- /dev/null +++ b/examples/common/gui/presenter.hpp @@ -0,0 +1,158 @@ +// SPDX-License-Identifier: Apache-2.0 +#pragma once + +#include +#include + +#include + +#include +#include +#include + +/// @file +/// Shared presenter base (examples/TESTING.md, "Presenter architecture" rule +/// 3): "Observable quiescence." Every ladder presenter derives from this so +/// tests can wait for `busy() == false` instead of sleeping. + +namespace morph::ladder::gui { + +/// @brief Tracks in-flight completions so `busy()`/`idle()` reflect reality +/// without every presenter re-implementing a counter. +class Presenter : public QObject { + Q_OBJECT + + public: + explicit Presenter(QObject* parent = nullptr) : QObject{parent} {} + + /// @brief `true` while at least one `track()`ed completion has not yet + /// resolved or errored. + [[nodiscard]] bool busy() const { return _inFlight.load() != 0; } + + signals: + /// @brief Emitted the moment `busy()` transitions from `true` to `false`. + void idle(); + + /// @brief Emitted once, the first time this presenter's readiness gate + /// (whichever `BridgeHandler` a subclass names in `trackBound()`) + /// settles — i.e. once `Bridge::whenBound()`'s `Completion` + /// resolves, however it resolves. `Remote` mode's registration is + /// a round trip (docs/findings/017's neighbouring half): a handler + /// built the instant the socket connects still rejects every + /// dispatch with "handler not bound" until that round trip lands. + /// A subclass that calls + /// `trackBound()` in its constructor lets its view layer gate its + /// first dispatch on this signal instead of polling on a + /// `QTimer` — `Local` mode's handler is already bound by + /// construction, so `trackBound()` emits this synchronously + /// there. + void bound(); + + protected: + /// @brief Wires @p whenBoundCompletion (a `BridgeHandler::whenBound()` + /// call) to emit `bound()` exactly once, however it resolves. + /// + /// `Bridge::whenBound()`'s own contract (`morph/core/bridge.hpp`) + /// is "resolves with whatever `isBound()` would return once + /// settled" — this presenter does not care which way it settled, + /// only that the registration round trip (successful or not) is + /// no longer in flight, since either outcome means the next + /// dispatch attempt gets a real answer instead of a guaranteed + /// "handler not bound". + /// @param whenBoundCompletion The handler's own `whenBound()` result. + void trackBound(::morph::async::Completion whenBoundCompletion) { + // `QPointer`, not a bare `this` capture: `whenBound()`'s Completion + // resolves through the executor, asynchronously — even `Local` + // mode's immediate resolution is *posted*, not delivered inline + // (`morph::async::detail::CompletionState::attachThen`), so this + // presenter can already be destroyed by the time either handler + // below runs (e.g. a short-lived presenter torn down at the end of + // a test case). A `QPointer` reads back null instead of dereferencing + // freed memory, exactly like Qt's own auto-disconnect-on-destroy for + // signal/slot connections handles the same hazard. + QPointer self{this}; + std::move(whenBoundCompletion) + .then([self](bool) { + if (self) { + emit self->bound(); + } + }) + .onError([self](const std::exception_ptr&) { + if (self) { + emit self->bound(); + } + }); + } + + /// @brief Wraps @p completion's `.then`/`.onError` in begin/end counters, + /// forwarding a successful result to @p onOk and, on failure, the + /// `std::exception_ptr` to @p onErr (if supplied) before the busy + /// counter is decremented. + /// + /// @p onErr exists as a parameter rather than something a subclass + /// composes by calling `.onError(...)` on @p completion itself before + /// passing it here, for a documentation reason rather than a + /// correctness one now: `morph::async::detail::CompletionState:: + /// attachOnError` (`morph/core/completion.hpp`) fans out to every + /// attached handler in attachment order, so a subclass's own + /// pre-attached `.onError()` would in fact still fire today alongside + /// this method's own. Folding both into the one @p onErr parameter here + /// keeps every presenter's error-display-plus-busy-counter contract in + /// one visible place rather than split across two separate call sites. + /// + /// A presenter still "translates and routes, never decides" + /// (examples/IMPLEMENTATION.md rule 2): this base does not choose *how* + /// an error is displayed, only that @p onErr — the subclass's own + /// choice — is guaranteed to run before `finishOne()`. + /// @tparam T Type of @p completion's success value. + /// @param completion The in-flight completion to track. + /// @param onOk Success callback, invoked with the result value. + /// @param onErr Optional failure callback, invoked with the + /// `std::exception_ptr` before the busy counter decrements. + template + void track(::morph::async::Completion completion, std::function onOk, + std::function onErr = {}) { + _inFlight.fetch_add(1); + completion + .then([this, onOk = std::move(onOk)](T value) { + // finishOne() must run even if onOk throws. Otherwise the + // in-flight counter never decrements, `busy()` stays true + // forever, and every subsequent `settle()` burns its full + // deadline before failing — turning one presenter bug into a + // suite-wide timeout with no useful diagnostic. The exception + // is rethrown so it still reaches whatever the executor does + // with a throwing callback. + try { + onOk(std::move(value)); + } catch (...) { + finishOne(); + throw; + } + finishOne(); + }) + .onError([this, onErr = std::move(onErr)](const std::exception_ptr& err) { + // Same exception-safety contract as the onOk branch above: + // finishOne() must still run if onErr throws. + if (onErr) { + try { + onErr(err); + } catch (...) { + finishOne(); + throw; + } + } + finishOne(); + }); + } + + private: + void finishOne() { + if (_inFlight.fetch_sub(1) == 1) { + emit idle(); + } + } + + std::atomic _inFlight{0}; +}; + +} // namespace morph::ladder::gui diff --git a/examples/common/testkit/backend_rig.hpp b/examples/common/testkit/backend_rig.hpp new file mode 100644 index 00000000..b77ea16c --- /dev/null +++ b/examples/common/testkit/backend_rig.hpp @@ -0,0 +1,412 @@ +// SPDX-License-Identifier: Apache-2.0 +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +/// @file +/// The dual/triple-mode fixture (examples/TESTING.md, "The dual-mode +/// fixture"): one test body, parameterized by Catch2 GENERATE over Mode, runs +/// against every deployment shape the ladder ships. + +namespace morph::ladder::testkit { + +namespace detail { + +/// @brief Wraps a `MainThreadExecutor` so every `post()` also schedules a +/// same-loop-iteration `runFor()` via a zero-delay `QTimer`. +/// +/// `pump.hpp`'s `pumpUntil`/`awaitQt` only pump the Qt event loop +/// (`QCoreApplication::processEvents()`) — they never call +/// `MainThreadExecutor::runFor()`. A `BackendRig` in `Mode::LocalSingleThread` +/// posts model work onto a `MainThreadExecutor` (via `LocalBackend`'s strand); +/// without something draining that queue, a test doing +/// `awaitQt(handler.execute(...))` against that mode would hang forever, since +/// nothing ever runs the posted task. This adapter closes that gap: every +/// `post()` both enqueues the task on the wrapped `MainThreadExecutor` *and* +/// arranges for it (and anything it, in turn, posts — e.g. the `Completion` +/// callback delivered through this same executor) to drain the next time the +/// Qt event loop turns, which `pumpUntil`'s `processEvents()` loop already +/// does. This makes `LocalSingleThread` mode drain through the testkit's +/// existing pumping discipline instead of requiring a caller to manually call +/// `MainThreadExecutor::runFor()` the way bank's test harness does today +/// (`bank_test_support.hpp`'s `await()`/`waitUntil()`). It is also the closer +/// analogue to real WASM: under Emscripten the browser's own event loop drives +/// posted work, not a manually-polled loop. +class QtDrivenMainThreadExecutor : public ::morph::exec::IExecutor { +public: + /// @brief Enqueues @p task and schedules a drain on the Qt event loop. + /// + /// The drain lambda holds a `weak_ptr` to `_liveness` and touches nothing + /// else until it locks — never a bare `this`. A zero-delay + /// `QTimer::singleShot` is a *posted Qt event*, and nothing cancels it + /// when this executor dies: a `BackendRig` in `Mode::LocalSingleThread` + /// is routinely destroyed with one still in flight (the last completion + /// callback of a test case posts, the test body returns, the rig + /// unwinds), and the event then fires the next time *anything* spins the + /// Qt loop — the very next `BackendRig{Mode::Socket, ...}`'s + /// `waitForConnected()`, or `~QtWebSocketBackend`'s own + /// `processEvents()`, both of which happen inside a Catch2 `GENERATE` + /// matrix's following iteration. Without the guard, that stale event + /// reached `MainThreadExecutor::runFor()` on freed storage and threw + /// `std::system_error{"mutex lock failed: Invalid argument"}` out of a Qt + /// event handler, which Qt turns into an immediate `abort()` — surfacing + /// as an intermittent "Subprocess aborted" attributed to whichever test + /// case happened to be running, never to the one that left the event + /// behind. Observed in practice on rung 1's QML-adapter suite, reliably + /// under CPU load, roughly one run in fifty without it. + /// Same `_liveness`/`weak_ptr` shape `morph::bridge::Bridge` uses for the + /// identical hazard (`include/morph/core/bridge.hpp`). + /// @param task Callable to execute on the next event-loop turn. + void post(std::function task) override { + _inner.post(std::move(task)); + QTimer::singleShot(0, [this, weakLiveness = std::weak_ptr{_liveness}] { + if (weakLiveness.expired()) { + return; // This executor is gone; `this` is dangling. + } + _inner.runFor(kDrainBudget); + }); + } + +private: + // A strictly-zero budget cannot pop anything: MainThreadExecutor::runFor() + // computes `deadline = now() + timeout` once and loops `while (now() < + // deadline)`; with `timeout == 0` that comparison is already false by the + // time it is evaluated (two `steady_clock::now()` calls never return the + // same instant on real hardware), so the task just posted would never run + // and this adapter would hang exactly like the raw `MainThreadExecutor` it + // replaces. A small positive budget gives the loop at least one chance to + // observe the non-empty queue and drain it — and, transitively, anything a + // drained task posts back onto this same executor (e.g. a `Completion` + // resolving and posting its `.then()` callback), since that repost lands + // in the same queue this call is still draining. + static constexpr std::chrono::milliseconds kDrainBudget{5}; + + ::morph::exec::MainThreadExecutor _inner; + // Destroyed with this object; a still-pending drain lambda's weak_ptr + // then expires and the lambda returns without touching `_inner`. Declared + // last so it is destroyed *first* — before `_inner`, whose mutex is the + // storage the stale lambda used to reach. + std::shared_ptr _liveness{std::make_shared()}; +}; + +/// @brief Throws if `_wsServer->listen()` failed, otherwise a no-op. +/// +/// Factored out of `Socket` mode's constructor branch so the decision is +/// directly testable with a plain `bool` — forcing a *real* ephemeral-port +/// `listen()` failure deterministically (without flakiness, and without +/// adding a test-only seam to `QtWebSocketServer` itself) isn't practically +/// achievable, so the throw logic is what gets tested instead of the real +/// I/O call. Called with the true result at the real call site, which is now +/// a trivial, branch-free line. +/// @param listenSucceeded The real `listen()` call's result. +/// @throws std::runtime_error if @p listenSucceeded is `false`. +inline void throwIfListenFailed(bool listenSucceeded) { + if (!listenSucceeded) { + throw std::runtime_error("BackendRig: QtWebSocketServer failed to listen"); + } +} + +/// @brief Throws if a client's `waitForConnected()` failed, otherwise a no-op. +/// +/// Same rationale as `throwIfListenFailed` — see its doc comment. +/// @param connected The real `waitForConnected()` call's result. +/// @throws std::runtime_error if @p connected is `false`. +inline void throwIfConnectFailed(bool connected) { + if (!connected) { + throw std::runtime_error("BackendRig: client failed to connect"); + } +} + +} // namespace detail + +/// @brief Selects which of the three deployment shapes a `BackendRig` builds. +enum class Mode { + /// One `ThreadPoolExecutor{4}`, one `Bridge{LocalBackend}` shared by every + /// "client" — morph's in-process multi-handler semantics. + Local, + /// `LocalBackend` running models on the GUI executor itself: the WASM + /// constraint-parity mode (single-threaded, matches bank's + /// `__EMSCRIPTEN__` wiring). + LocalSingleThread, + /// `ThreadPoolExecutor{2-4}` -> `RemoteServer` -> `QtWebSocketServer` on + /// an ephemeral port; each client is its own `QtWebSocketBackend` + + /// `Bridge` over a real loopback socket. + Socket, +}; + +/// @brief Owns the executors/backend/server for one test's worth of clients. +/// +/// Teardown order: the test's own presenters/handlers go first (they are the +/// caller's locals, destroyed before this rig). Then `~BackendRig()` runs +/// `wsServer.closeGracefully(2s)` explicitly *before* any member is +/// destroyed, so the socket server stops accepting/serving while its clients +/// are still fully alive; member destruction then unwinds in reverse +/// declaration order (client bridges -> socket server -> `RemoteServer` -> +/// worker pool -> client executors). The executors going **last** is the +/// load-bearing part and the reason the members are not declared in reading +/// order: a pool thread resolves a caller's `Completion` by posting on the +/// client executor, so the pool — whose destructor joins its threads — has to +/// be gone before the executor it posts to is. See the member-declaration +/// comment below for the full rationale. +class BackendRig { +public: + /// @brief Builds the fixture for @p mode with @p nClients clients. + /// + /// @param mode Deployment shape to build. + /// @param nClients Number of clients `client()` will hand out. + /// `Local`/`LocalSingleThread` ignore this beyond + /// accepting it — every client shares the one `Bridge` + /// built here, so there is nothing to construct per + /// client. `Socket` builds exactly `nClients` + /// independent sockets/bridges. + /// @param authorizer Optional authorizer for `Mode::Socket`'s + /// `RemoteServer`; ignored by the other two modes. + /// @param serverConfig Per-connection resource limits for `Mode::Socket`'s + /// `QtWebSocketServer` (frame-size cap, connection cap, + /// rate limit, timeouts); ignored by the other two + /// modes, which run no server. Defaults to + /// `QtWebSocketServerConfig{}` — i.e. exactly the + /// unconfigured server this rig has always built. A + /// rung testing transport-enforced limits (pastebin's + /// size-limit UX case, which needs a small + /// `maxMessageBytes`) configures it here rather than + /// standing up its own server alongside the rig. + BackendRig(Mode mode, std::size_t nClients, std::shared_ptr<::morph::session::IAuthorizer> authorizer = nullptr, + ::morph::qt::QtWebSocketServerConfig serverConfig = ::morph::qt::QtWebSocketServerConfig{}) + : _mode{mode} { + switch (mode) { + case Mode::Local: { + _workerPool = std::make_unique<::morph::exec::ThreadPoolExecutor>(4); + // The pool backs the *models* (LocalBackend's strands run + // there); client-facing Completion callbacks must not. A + // ThreadPoolExecutor here would deliver .then/.onError on a + // pool thread, racing pump.hpp's pumpUntil/awaitQt (which + // read the resolved state from the Qt thread with no + // synchronization) and any Presenter built over this rig. + // QtExecutor puts every callback back on the one Qt thread — + // the same choice AppContext makes in both its modes, and + // what examples/TESTING.md's "all clients on the one Qt main + // thread" description of Local mode already claims. + _qtExecutor = std::make_unique<::morph::qt::QtExecutor>(); + _clientExecutor = _qtExecutor.get(); + auto backend = std::make_unique<::morph::backend::LocalBackend>(*_workerPool); + // All "clients" share one bridge in Local mode — there is + // deliberately no per-client isolation here (see + // examples/TESTING.md's convergence honesty note: Local mode + // has no staleness to converge from). No construction loop is + // needed: client(index) hands every index the same + // Bridge built here regardless of nClients' value. + _sharedLocalBridge = std::make_unique<::morph::bridge::Bridge>(std::move(backend)); + break; + } + case Mode::LocalSingleThread: { + _mainThreadExecutor = std::make_unique(); + _clientExecutor = _mainThreadExecutor.get(); + auto backend = std::make_unique<::morph::backend::LocalBackend>(*_mainThreadExecutor); + _sharedLocalBridge = std::make_unique<::morph::bridge::Bridge>(std::move(backend)); + break; + } + case Mode::Socket: { + _workerPool = std::make_unique<::morph::exec::ThreadPoolExecutor>(4); + if (authorizer) { + _server = std::make_shared<::morph::backend::RemoteServer>(*_workerPool, authorizer); + } else { + _server = std::make_shared<::morph::backend::RemoteServer>(*_workerPool); + } +#ifdef QT_NO_SSL + _wsServer = + std::make_unique<::morph::qt::QtWebSocketServer>(*_server, quint16{0}, std::move(serverConfig)); +#else + _wsServer = std::make_unique<::morph::qt::QtWebSocketServer>(*_server, quint16{0}, std::nullopt, + std::move(serverConfig)); +#endif + detail::throwIfListenFailed(_wsServer->listen()); + _qtExecutor = std::make_unique<::morph::qt::QtExecutor>(); + _clientExecutor = _qtExecutor.get(); + _url = QUrl{QString("ws://127.0.0.1:%1").arg(_wsServer->port())}; + for (std::size_t i = 0; i < nClients; ++i) { + auto backend = std::make_unique<::morph::qt::QtWebSocketBackend>(_url); + detail::throwIfConnectFailed(backend->waitForConnected()); + // The Bridge below takes ownership; this non-owning + // pointer is what `socketBackend()` hands back, so a test + // can reach transport-level operations that have no + // Bridge-level equivalent (`negotiateProtocolVersion()`). + _socketBackends.push_back(backend.get()); + _socketBridges.push_back(std::make_unique<::morph::bridge::Bridge>(std::move(backend))); + } + break; + } + default: + // Every Mode enumerator has its own case above, so this is + // unreachable in correct code — present only because + // -Wswitch-default (unlike Clang's -Wcovered-switch-default, + // suppressed project-wide for exactly this collision — see + // cmake/compiler_options.cmake's own note) still demands an + // explicit default even on a fully-covered switch. Throws + // rather than silently doing nothing, so a future Mode value + // reaching here from outside (a stray static_cast, memory + // corruption) fails loudly instead of constructing a + // half-initialized rig. + throw std::logic_error{"BackendRig: unknown Mode"}; + } + } + + BackendRig(const BackendRig&) = delete; + BackendRig& operator=(const BackendRig&) = delete; + BackendRig(BackendRig&&) = delete; + BackendRig& operator=(BackendRig&&) = delete; + + /// @brief Teardown order: gracefully close the socket server (if any) + /// before its bridges/pool are torn down by member destruction. + ~BackendRig() { + if (_wsServer) { + _wsServer->closeGracefully(std::chrono::milliseconds{2000}); + } + } + + [[nodiscard]] Mode mode() const { return _mode; } + + /// @brief Returns the @p index'th client's `BridgeHandler`. + /// + /// `Local`/`LocalSingleThread`: every index shares the one `Bridge` + /// (morph's in-process multi-handler semantics — the handler itself is + /// still per-call, constructed fresh here). `Socket`: each index owns its + /// own `Bridge` over its own socket. + /// @tparam Model Concrete model type to bind the handler to. + /// @param index Client index in `[0, nClients)`. + /// @return A fresh `BridgeHandler` bound to this client's bridge. + template + ::morph::bridge::BridgeHandler client(std::size_t index) { + if (_mode == Mode::Socket) { + if (index >= _socketBridges.size()) { + throw std::out_of_range("BackendRig::client: index beyond nClients"); + } + return ::morph::bridge::BridgeHandler{*_socketBridges[index], _clientExecutor}; + } + return ::morph::bridge::BridgeHandler{*_sharedLocalBridge, _clientExecutor}; + } + + /// @brief Returns the @p index'th client's `Bridge`. + /// + /// The composability half of `client()`: a `Presenter` subclass + /// takes `(Bridge&, IExecutor*)` and builds its own handlers, so a rung's + /// presenter tests need the raw bridge, not a pre-bound handler. Mode + /// dispatch mirrors `client()` exactly. + /// + /// @param index Client index in `[0, nClients)`; ignored in + /// `Local`/`LocalSingleThread`, where every client shares one + /// `Bridge`. + /// @return Reference to that client's bridge, owned by this rig. + /// @throws std::out_of_range in `Socket` mode if @p index >= nClients. + [[nodiscard]] ::morph::bridge::Bridge& bridge(std::size_t index) { + if (_mode == Mode::Socket) { + if (index >= _socketBridges.size()) { + throw std::out_of_range("BackendRig::bridge: index beyond nClients"); + } + return *_socketBridges[index]; + } + return *_sharedLocalBridge; + } + + /// @brief Returns the @p index'th client's raw `QtWebSocketBackend`. + /// + /// Deliberately narrow: `Bridge` is the ordinary seam, and every test that + /// only dispatches actions should use `client()`/`bridge()` + /// instead. A handful of transport-level operations have no Bridge-level + /// equivalent at all — `negotiateProtocolVersion()` (the `hello` + /// handshake, which pastebin's protocol-negotiation case exercises) is the + /// motivating one — and reaching them otherwise would mean a test + /// standing up a second socket alongside the rig's own, testing a + /// connection the rig never built. + /// + /// @param index Client index in `[0, nClients)`. + /// @return Reference to that client's backend, owned by the corresponding + /// `Bridge` (which is owned by this rig). + /// @throws std::logic_error in `Local`/`LocalSingleThread` — those modes + /// run no socket and have no such backend. + /// @throws std::out_of_range in `Socket` mode if @p index >= nClients. + [[nodiscard]] ::morph::qt::QtWebSocketBackend& socketBackend(std::size_t index) { + if (_mode != Mode::Socket) { + throw std::logic_error( + "BackendRig::socketBackend: only Mode::Socket runs over a socket; there is no backend in this mode"); + } + if (index >= _socketBackends.size()) { + throw std::out_of_range("BackendRig::socketBackend: index beyond nClients"); + } + return *_socketBackends[index]; + } + + /// @brief The executor every client's callbacks are delivered on. + /// + /// The second half of a presenter's `(Bridge&, IExecutor*)` pair. A + /// `QtExecutor` in `Local`/`Socket`, the Qt-driven `MainThreadExecutor` + /// adapter in `LocalSingleThread` — all three deliver on the Qt thread, + /// which is what makes `pump.hpp`'s wait primitives sound. + /// @return Non-owning pointer to the rig's client-facing executor. + [[nodiscard]] ::morph::exec::IExecutor* executor() const { return _clientExecutor; } + + /// @brief The loopback URL clients connect to, for building an extra + /// client (e.g. an `AppContext{Remote{rig.url()}}`) against this + /// rig's server. + /// @return `ws://127.0.0.1:`. + /// @throws std::logic_error in `Local`/`LocalSingleThread` — those modes + /// run no server and have no URL to hand out. + [[nodiscard]] QUrl url() const { + if (_mode != Mode::Socket) { + throw std::logic_error("BackendRig::url: only Mode::Socket runs a server; there is no URL in this mode"); + } + return _url; + } + +private: + Mode _mode; + ::morph::exec::IExecutor* _clientExecutor{nullptr}; + + // Declared in reverse teardown order, and the client-facing executors + // come first on purpose: members are destroyed in reverse, so they are + // the *last* things to go. + // + // In `Mode::Local` a model runs on `_workerPool`, and the pool thread + // that finishes it resolves the caller's `Completion` by calling `post()` + // on `_clientExecutor`. With the executor declared before the pool (its + // natural reading order), `~BackendRig` destroyed it while pool threads + // were still finishing dispatched work, and the next completion to + // resolve posted through a dangling `IExecutor*`. That crashes nowhere + // near the rig — the stale callback lands on the Qt event loop and + // detonates inside whatever later test happens to pump it, which is + // exactly how it presented (intermittent SIGSEGVs scattered across + // pastebin's socket cases). Destroying `_workerPool` — which joins its + // threads, so every in-flight completion has resolved — before the + // executors closes that window. `QtExecutor` is stateless and queues onto + // `QCoreApplication`, so callbacks it has already posted stay safe after + // the rig is gone. + std::unique_ptr<::morph::qt::QtExecutor> _qtExecutor; // Local / Socket + std::unique_ptr _mainThreadExecutor; // LocalSingleThread + std::unique_ptr<::morph::exec::ThreadPoolExecutor> _workerPool; // Local / Socket + std::shared_ptr<::morph::backend::RemoteServer> _server; // Socket + std::unique_ptr<::morph::qt::QtWebSocketServer> _wsServer; // Socket + std::unique_ptr<::morph::bridge::Bridge> _sharedLocalBridge; // Local / LocalSingleThread + std::vector> _socketBridges; // Socket + // Non-owning, parallel to _socketBridges: each entry is the backend the + // bridge at the same index owns. Declared *after* _socketBridges so it is + // destroyed first — it must never outlive the objects it points at. + std::vector<::morph::qt::QtWebSocketBackend*> _socketBackends; // Socket + QUrl _url; // Socket +}; + +} // namespace morph::ladder::testkit diff --git a/examples/common/testkit/db_busy_fixture.hpp b/examples/common/testkit/db_busy_fixture.hpp new file mode 100644 index 00000000..2b4b08ae --- /dev/null +++ b/examples/common/testkit/db_busy_fixture.hpp @@ -0,0 +1,123 @@ +// SPDX-License-Identifier: Apache-2.0 +#pragma once + +#include "db_fixture.hpp" + +#include + +#include +#include + +/// @file +/// The SQLITE_BUSY-provoking counterpart to `db_fault_fixture.hpp`'s +/// advisory-lock contention, which cannot fault an ordinary `DataMapper` +/// call (see `examples/TESTING.md`'s testkit section): holds a genuine, +/// uncommitted write transaction open on a second SqlConnection to the +/// shared test database, for the fixture's lifetime, so a concurrent write +/// from the code under test's own connection collides for real — no mock, +/// no simulated driver. See `DbBusyFixture`'s doc comment for the verified +/// locking recipe and test_db_busy_fixture.cpp for the observed exception +/// this produces and how the *other* connection (the one under test) must +/// shorten its own busy-timeout to fail fast. + +namespace morph::ladder::testkit { + +/// @brief Holds an open write transaction on @p tableName for its lifetime, +/// forcing a concurrent write from a different connection to that +/// same table to observe `SQLITE_BUSY`. +/// +/// Verified empirically against the real sqliteodbc driver this repo tests +/// against: +/// +/// - A plain `BEGIN` (or `Lightweight::SqlTransaction`, which only flips +/// `SQL_ATTR_AUTOCOMMIT` off via ODBC and issues no `BEGIN` of its own) +/// defers SQLite's actual lock acquisition to the connection's first +/// statement that touches data. `BEGIN IMMEDIATE`, sent as a raw +/// statement via `SqlStatement::ExecuteDirect` *before* any other +/// statement on this connection, is what forces SQLite's RESERVED write +/// lock to be taken immediately, so there is no race between this +/// constructor returning and a concurrent writer starting elsewhere. The +/// follow-up no-op `UPDATE ... SET id = id` isn't load-bearing for the +/// lock itself (`BEGIN IMMEDIATE` alone already reserves it) but exercises +/// the same code path a real write would, and gives a second, independent +/// confirmation the transaction is live. +/// - The destructor issues an explicit `ROLLBACK` rather than relying on +/// `_lockingConnection`'s own destructor to release the lock on +/// disconnect: ODBC disconnect-with-open-transaction behavior is +/// driver-defined, and an explicit release is unambiguous (the same +/// reasoning `DbFaultFixture`'s `SqlScopedLock`-based release already +/// follows). +/// +/// A gotcha this fixture's own consumer must handle, *not* something this +/// class can fix on the other connection's behalf: `Lightweight::SqlConnection +/// ::PostConnect()` unconditionally issues `PRAGMA busy_timeout = 60000` on +/// every new SQLite connection, regardless of the connection string's own +/// `Timeout=` parameter (which the ODBC driver would otherwise honor, but +/// Lightweight's PRAGMA runs after connect and wins). That means a +/// concurrent write against this fixture's lock does not fail fast by +/// default — it genuinely blocks for up to 60 real seconds before SQLite +/// gives up and returns `SQLITE_BUSY`. A caller that wants the fast, +/// deterministic failure a unit test needs must re-issue `PRAGMA +/// busy_timeout = N` (a small value) directly on *its own* connection before +/// attempting the racy write (see test_db_busy_fixture.cpp) — the +/// `ODBC_CONNECTION_STRING`/`Timeout=` override this file's task brief +/// originally proposed does not work, because the PRAGMA is not derived +/// from it. +/// +/// @par `SetPostConnectedHook` and `Lightweight::GlobalDataMapperPool()` +/// A model that acquires its connection via `GlobalDataMapperPool()` (rather +/// than opening one for its own exclusive, permanent use) is only +/// **guaranteed** to trigger `PostConnect()` — and so a caller's +/// `SetPostConnectedHook` override — when the pool actually creates a new +/// `SqlConnection`: an empty pool at `Acquire()` time (morph's configured +/// growth strategy, `BoundedOverflow`, never blocks and never fails to grow +/// on demand, so "empty" is the only condition that matters here). If the +/// pool already holds an idle, previously-connected mapper (from an earlier +/// acquisition elsewhere in the same test binary, including the pool's own +/// pre-warm at construction), `Acquire()` hands that one back without +/// reconnecting, and the override installed for *this* test never runs on +/// it. +/// +/// A test relying on "the code under test's connection opens fresh, under my +/// short-busy-timeout hook" must therefore force the pool's idle list empty +/// immediately before the acquisition it cares about, rather than assume it +/// already is — `db_pool_drain.hpp`'s `drainPoolIdleMappers()` does exactly +/// this (hold `Config.maxSize` acquisitions live across the racy call, since +/// `BoundedOverflow`'s `Return()` never idles more than `maxSize` at once, +/// so that count is always enough regardless of the pool's prior state). +/// Both `test_paste_model.cpp` (pastebin) and `test_bookmark_model.cpp` +/// (bookmarks) use it for exactly this SQLITE_BUSY-under-a-short-timeout +/// shape; reuse it for any future rung's equivalent test rather than relying +/// on test ordering or a freshly-started process. +class DbBusyFixture { + public: + /// @param tableName Table to lock — must already exist (construct this + /// fixture after a `DbFixture` has applied migrations) and must + /// have an `id` column (every ladder entity to date does). + explicit DbBusyFixture(std::string tableName): _tableName{ std::move(tableName) }, _lockingConnection{} + { + ::Lightweight::SqlStatement stmt{ _lockingConnection }; + (void) stmt.ExecuteDirect("BEGIN IMMEDIATE"); + (void) stmt.ExecuteDirect(std::format("UPDATE \"{}\" SET id = id", _tableName)); + } + + /// @brief Rolls back the held transaction explicitly — see the class + /// doc comment for why this doesn't rely on the connection's own + /// destructor instead. + ~DbBusyFixture() + { + ::Lightweight::SqlStatement stmt{ _lockingConnection }; + (void) stmt.ExecuteDirect("ROLLBACK"); + } + + DbBusyFixture(const DbBusyFixture&) = delete; + DbBusyFixture& operator=(const DbBusyFixture&) = delete; + DbBusyFixture(DbBusyFixture&&) = delete; + DbBusyFixture& operator=(DbBusyFixture&&) = delete; + + private: + std::string _tableName; + ::Lightweight::SqlConnection _lockingConnection; +}; + +} // namespace morph::ladder::testkit diff --git a/examples/common/testkit/db_fault_fixture.hpp b/examples/common/testkit/db_fault_fixture.hpp new file mode 100644 index 00000000..271bee4d --- /dev/null +++ b/examples/common/testkit/db_fault_fixture.hpp @@ -0,0 +1,56 @@ +// SPDX-License-Identifier: Apache-2.0 +#pragma once + +#include "testkit/db_fixture.hpp" + +#include +#include + +#include +#include +#include + +/// @file +/// Genuine cross-session lock contention for the ladder's store-error +/// coverage (examples/IMPLEMENTATION.md rule 5), built directly on +/// Lightweight's own shipped, already-tested `SqlScopedLock` — see this +/// file's class doc comment and the Task 4 design precedent note in the plan +/// this was built from for why that beats a hand-rolled mock or raw SQL. + +namespace morph::ladder::testkit { + +/// @brief Wraps a `DbFixture` and holds a real `SqlScopedLock` on a second, +/// independent `SqlConnection` to the same shared database, so any +/// code that takes the same-named lock on a *different* connection +/// (the fixture's own default-connection `SqlStatement`s, or a +/// model's `DataMapper`) observes a genuine contention failure. +class DbFaultFixture { + public: + /// @param lockName Advisory lock name to contend on — pick one that + /// matches what the code under test actually locks (e.g. a + /// model's own `SqlScopedLock` name), or a dedicated probe name + /// for testing the fixture itself. + explicit DbFaultFixture(std::string lockName = "morph_ladder_db_fault_fixture") + : _fixture{}, _lockingConnection{}, _lock{_lockingConnection, lockName, std::chrono::milliseconds{50}} {} + + DbFaultFixture(const DbFaultFixture&) = delete; + DbFaultFixture& operator=(const DbFaultFixture&) = delete; + DbFaultFixture(DbFaultFixture&&) = delete; + DbFaultFixture& operator=(DbFaultFixture&&) = delete; + ~DbFaultFixture() = default; + + /// @brief The lock name this fixture holds, so a test can attempt to + /// acquire the *same* name on its own connection and assert it + /// throws. `SqlScopedLock::Name()` itself returns a + /// `std::string_view` bound to the lock's own storage, so this + /// mirrors that return type rather than the brief's illustrative + /// `const std::string&` (which cannot bind to a `string_view`). + [[nodiscard]] std::string_view lockName() const noexcept { return _lock.Name(); } + + private: + DbFixture _fixture; + ::Lightweight::SqlConnection _lockingConnection; + ::Lightweight::SqlScopedLock _lock; +}; + +} // namespace morph::ladder::testkit diff --git a/examples/common/testkit/db_fixture.hpp b/examples/common/testkit/db_fixture.hpp new file mode 100644 index 00000000..db8662e2 --- /dev/null +++ b/examples/common/testkit/db_fixture.hpp @@ -0,0 +1,120 @@ +// SPDX-License-Identifier: Apache-2.0 +#pragma once + +#include +#include +#include +#include + +#include +#include + +/// @file +/// Real on-disk SQLite database, shared per test binary — mirrors +/// Lightweight's own `SqlTestFixture` (Lightweight/src/tests/Utils.hpp) and +/// examples/bank/tests/bank_test_support.hpp's `ensureDatabase()`, not a +/// per-fixture temp file. Every rung's LIGHTWEIGHT_SQL_MIGRATION-registered +/// schema (examples/IMPLEMENTATION.md rule 4) is picked up automatically: +/// MigrationManager is a process-wide singleton every linked-in schema.cpp +/// registers against at static-init time. + +namespace morph::ladder::testkit { + +/// @brief Drops every table in the shared on-disk test database and +/// re-applies pending migrations, for the lifetime of one fixture. +/// +/// Construct one per `TEST_CASE` (matching `TEST_CASE_METHOD(SqlTestFixture, +/// ...)`'s usage in Lightweight's own suite) so every test starts from a +/// clean, real schema on the same real connection. +class DbFixture { + public: + DbFixture() { + ensureConnectionConfigured(); + ::Lightweight::SqlStatement stmt; + dropAllTables(stmt); + ::Lightweight::SqlMigration::MigrationManager::GetInstance().ApplyPendingMigrations(); + } + + DbFixture(const DbFixture&) = delete; + DbFixture& operator=(const DbFixture&) = delete; + DbFixture(DbFixture&&) = delete; + DbFixture& operator=(DbFixture&&) = delete; + ~DbFixture() = default; + + public: + /// @brief Pure decision logic behind `ensureConnectionConfigured()`, + /// factored out so it is directly unit-testable: that function + /// applies its result behind a `static const` guard that runs + /// exactly once per *process* (parallel binaries — not parallel + /// test cases within one binary — are what that guard needs to + /// survive; Catch2 runs sections sequentially), so no test can + /// ever be first to observe a particular `ODBC_CONNECTION_STRING` + /// value once some earlier test (or the very first `DbFixture` in + /// the binary) has already forced the default-SQLite path. Taking + /// the raw env value as a parameter instead of reading it + /// internally sidesteps that: a test calls this with whatever + /// string it likes, no process boundary required. + /// @param envValue `ODBC_CONNECTION_STRING`'s raw value (as + /// `std::getenv` would return it), or `nullptr`/empty if unset. + /// @return @p envValue verbatim if non-empty (parity with Lightweight's + /// own override convention, so the same ladder suite can later + /// run a CI leg against Postgres/MSSQL the way + /// `examples/LADDER.md`'s security matrix expects other rungs to + /// gain non-SQLite legs); otherwise a real file named + /// `morph_ladder_test.db` in the current working directory. + [[nodiscard]] static std::string computeConnectionString(const char* envValue) { + if (envValue != nullptr && *envValue != '\0') { + return envValue; + } + return "DRIVER=SQLite3;Database=morph_ladder_test.db;Timeout=5000"; + } + + private: + /// @brief Points Lightweight's default connection at the connection + /// string `computeConnectionString` computes, exactly once per + /// process. All the interesting logic (env value set vs. not) + /// lives in that function above; this applies the result and has + /// no branch of its own left to miss. + static void ensureConnectionConfigured() { + static const bool once = [] { + ::Lightweight::SqlConnection::SetDefaultConnectionString( + ::Lightweight::SqlConnectionString{computeConnectionString(std::getenv("ODBC_CONNECTION_STRING"))}); + ::Lightweight::SqlMigration::MigrationManager::GetInstance().CreateMigrationHistory(); + return true; + }(); + (void)once; + } + + /// @brief `DROP TABLE IF EXISTS` every table currently in the database. + /// + /// Simplified relative to `SqlTestFixture::DropAllTablesInDatabase` + /// (Lightweight/src/tests/Utils.hpp): that version recursively orders + /// drops around foreign-key cycles (needed for Chinook-shaped schemas + /// with self- and cross-references). Rung 0 has no schema of its own and + /// no ladder rung has shipped a cyclic-FK schema yet, so this toggles + /// SQLite's `PRAGMA foreign_keys` off for the sweep instead — correct for + /// any acyclic schema, and simpler. If a future rung's schema is cyclic, + /// port `SqlTestFixture`'s recursive algorithm here rather than + /// reinventing one; note that as a one-line addition to this comment when + /// it happens, not a silent behavior change. + static void dropAllTables(::Lightweight::SqlStatement& stmt) { + const bool isSqlite = stmt.Connection().ServerType() == ::Lightweight::SqlServerType::SQLITE; + if (isSqlite) { + (void)stmt.ExecuteDirect("PRAGMA foreign_keys = OFF"); + } + // Lightweight's own SQLite table enumeration (SqlSchema.cpp's + // ReadAllTablesLegacy) already excludes sqlite_sequence — SQLite's + // autoincrement bookkeeping table — before it ever reaches an + // EventHandler, so it never appears in this list to begin with; no + // skip of our own is needed. + const auto tables = ::Lightweight::SqlSchema::ReadAllTables(stmt, stmt.Connection().DatabaseName()); + for (const auto& table : tables) { + (void)stmt.ExecuteDirect("DROP TABLE IF EXISTS \"" + table.name + "\""); + } + if (isSqlite) { + (void)stmt.ExecuteDirect("PRAGMA foreign_keys = ON"); + } + } +}; + +} // namespace morph::ladder::testkit diff --git a/examples/common/testkit/db_pool_drain.hpp b/examples/common/testkit/db_pool_drain.hpp new file mode 100644 index 00000000..62d86142 --- /dev/null +++ b/examples/common/testkit/db_pool_drain.hpp @@ -0,0 +1,57 @@ +// SPDX-License-Identifier: Apache-2.0 +#pragma once + +#include + +#include +#include + +/// @file +/// `drainPoolIdleMappers()` — forces the next +/// `Lightweight::GlobalDataMapperPool().Acquire()` anywhere in the process to +/// actually construct a fresh `DataMapper` (and so a fresh `SqlConnection`), +/// instead of possibly handing back an idle, already-connected one left over +/// from an earlier acquisition. See `db_busy_fixture.hpp`'s +/// "`SetPostConnectedHook` and `GlobalDataMapperPool()`" note for the +/// concrete problem this solves: a caller's `SetPostConnectedHook` override +/// only fires when the pool actually creates a new `SqlConnection`, and once +/// any rung's model acquires its persistence through the pool rather than +/// owning a connection for its own lifetime, a test relying on "the code +/// under test's connection opens fresh, under my hook" needs this to make +/// that a hard guarantee rather than an incidental one. + +namespace morph::ladder::testkit { + +/// @brief Forces the pool empty, so the very next `Acquire()` anywhere +/// constructs a genuinely new mapper. +/// +/// `Pool::Acquire()`'s non-blocking growth strategies (`BoundedOverflow`, +/// morph's own configured default, and `UnboundedGrow`) only ever construct +/// a fresh mapper when the pool's idle list is empty at the moment of the +/// call; otherwise they hand back whatever sits at the back of that list. +/// So: drain it. Acquiring and **holding** every currently-idle mapper +/// (never returning them while held) is the only way to empty that list +/// from outside the pool — there is no reset/clear method — after which the +/// very next `Acquire()` anywhere, while this batch is still held, is +/// guaranteed to construct new. +/// +/// `Config.maxSize` acquisitions are always enough regardless of the pool's +/// prior idle count, since `BoundedOverflow`'s own `Return()` never keeps +/// more than `maxSize` idle mappers at once. Releasing the returned batch +/// (by letting it go out of scope, or calling `.clear()` on it) is safe at +/// any point after the acquisition this call was meant to protect has +/// already happened — it does not undo that acquisition. +/// +/// @return The drained batch. Keep it alive (e.g. as a local `auto`) across +/// the acquisition that must be fresh; release it once that +/// acquisition has happened. +[[nodiscard]] inline std::vector<::Lightweight::DataMapperPool::PooledDataMapper> drainPoolIdleMappers() { + std::vector<::Lightweight::DataMapperPool::PooledDataMapper> held; + held.reserve(::Lightweight::DefaultPoolConfig.maxSize); + for (std::size_t i = 0; i < ::Lightweight::DefaultPoolConfig.maxSize; ++i) { + held.push_back(::Lightweight::GlobalDataMapperPool().Acquire()); + } + return held; +} + +} // namespace morph::ladder::testkit diff --git a/examples/common/testkit/fault_proxy.cpp b/examples/common/testkit/fault_proxy.cpp new file mode 100644 index 00000000..80bbba3b --- /dev/null +++ b/examples/common/testkit/fault_proxy.cpp @@ -0,0 +1,162 @@ +// SPDX-License-Identifier: Apache-2.0 +#include "testkit/fault_proxy.hpp" + +#include +#include + +#include + +namespace morph::ladder::testkit { + +FaultProxy::FaultProxy(QUrl upstreamUrl, QObject* parent) : QObject{parent}, _upstreamUrl{std::move(upstreamUrl)} {} + +FaultProxy::~FaultProxy() { + if (_listener) { + _listener->close(); + } + if (_clientSocket != nullptr) { + _clientSocket->disconnect(); + _clientSocket->abort(); + } + if (_upstreamSocket != nullptr) { + _upstreamSocket->disconnect(); + _upstreamSocket->abort(); + } +} + +QUrl FaultProxy::start() { + _listener = std::make_unique(QStringLiteral("morph-ladder-fault-proxy"), + QWebSocketServer::NonSecureMode); + connect(_listener.get(), &QWebSocketServer::newConnection, this, &FaultProxy::onClientConnection); + detail::throwIfListenFailed(_listener->listen(QHostAddress::LocalHost, 0)); + _url = QUrl{QString("ws://127.0.0.1:%1").arg(_listener->serverPort())}; + return _url; +} + +void FaultProxy::dropReply(std::uint64_t callId) { + std::lock_guard lock{_rulesMtx}; + _rules[callId].drop = true; +} + +void FaultProxy::delayReply(std::uint64_t callId, std::chrono::milliseconds delay) { + std::lock_guard lock{_rulesMtx}; + _rules[callId].delay = delay; +} + +void FaultProxy::duplicateReply(std::uint64_t callId) { + std::lock_guard lock{_rulesMtx}; + _rules[callId].duplicate = true; +} + +void FaultProxy::killAfter(std::uint64_t callId) { + std::lock_guard lock{_rulesMtx}; + _rules[callId].kill = true; +} + +void FaultProxy::setRequestObserver(std::function observer) { + _requestObserver = std::move(observer); +} + +FaultProxy::Rule FaultProxy::ruleFor(std::uint64_t callId) { + std::lock_guard lock{_rulesMtx}; + auto iter = _rules.find(callId); + return iter == _rules.end() ? Rule{} : iter->second; +} + +void FaultProxy::onClientConnection() { + auto* incoming = _listener->nextPendingConnection(); + if (!detail::isValidIncomingConnection(incoming)) { + return; + } + // One client leg at a time (see the class doc comment). A reconnect after + // killAfter arrives here as a fresh connection replacing the aborted one. + if (_clientSocket != nullptr) { + _clientSocket->disconnect(); + _clientSocket->abort(); + _clientSocket->deleteLater(); + } + _clientSocket = incoming; + connect(_clientSocket, &QWebSocket::textMessageReceived, this, &FaultProxy::onClientTextMessage); + connect(_clientSocket, &QWebSocket::disconnected, this, [this] { _clientSocket = nullptr; }); + + if (_upstreamSocket == nullptr) { + _upstreamSocket = new QWebSocket{QString{}, QWebSocketProtocol::VersionLatest, this}; + connect(_upstreamSocket, &QWebSocket::connected, this, &FaultProxy::onUpstreamConnected); + connect(_upstreamSocket, &QWebSocket::textMessageReceived, this, &FaultProxy::onUpstreamTextMessage); + _upstreamSocket->open(_upstreamUrl); + } +} + +void FaultProxy::onClientTextMessage(const QString& message) { + // Report the request before forwarding it. This runs while the frame is + // still in this proxy, so a rule armed from the observer is installed + // strictly before the upstream server can produce a reply for it — the + // race-free way to name "call k" from outside the wire layer (see + // setRequestObserver). + if (_requestObserver) { + const std::uint64_t callId = detail::decodeCallIdOrZero(message); + if (callId != 0) { + _requestObserver(callId, *this); + } + } + + // Client -> server direction is forwarded verbatim; every rule this proxy + // supports targets the reply (server -> client) leg, matching + // TESTING.md's "drop exactly the reply frame of call k". + if (_upstreamSocket != nullptr && _upstreamConnected) { + _upstreamSocket->sendTextMessage(message); + } else { + // The upstream handshake is still in flight; a write now would be + // dropped on the floor. Buffer instead — the very first client frame + // (a synchronous `register`) reliably lands in this window. + _upstreamBacklog.push_back(message); + } +} + +void FaultProxy::onUpstreamConnected() { + _upstreamConnected = true; + auto backlog = std::move(_upstreamBacklog); + _upstreamBacklog.clear(); + for (const auto& message : backlog) { + _upstreamSocket->sendTextMessage(message); + } +} + +void FaultProxy::sendToClient(const QString& message) { + if (_clientSocket != nullptr) { + _clientSocket->sendTextMessage(message); + ++_repliesForwarded; + } +} + +void FaultProxy::onUpstreamTextMessage(const QString& message) { + const std::uint64_t callId = detail::decodeCallIdOrZero(message); + const Rule rule = ruleFor(callId); + + if (rule.drop) { + return; + } + if (rule.kill) { + if (_clientSocket != nullptr) { + // Detach the dying socket's signals before aborting: a queued + // `disconnected` from it, delivered after the client's automatic + // reconnect has already installed a fresh leg, would otherwise + // null out that new leg. + _clientSocket->disconnect(); + _clientSocket->abort(); + _clientSocket = nullptr; + } + return; + } + + const int copies = rule.duplicate ? 2 : 1; + for (int i = 0; i < copies; ++i) { + if (rule.delay) { + QTimer::singleShot(*rule.delay, this, [this, message] { sendToClient(message); }); + } else { + sendToClient(message); + } + } +} + +} // namespace morph::ladder::testkit diff --git a/examples/common/testkit/fault_proxy.hpp b/examples/common/testkit/fault_proxy.hpp new file mode 100644 index 00000000..f222de01 --- /dev/null +++ b/examples/common/testkit/fault_proxy.hpp @@ -0,0 +1,246 @@ +// SPDX-License-Identifier: Apache-2.0 +#pragma once + +#include + +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +/// @file +/// The single highest-yield harness the ladder needs and the repo lacked +/// (examples/TESTING.md, "The fault-injection wire proxy"): an in-process +/// WebSocket relay between `QtWebSocketBackend` and `QtWebSocketServer` with +/// scriptable per-call rules — drop exactly the reply frame of call k, delay +/// it, duplicate it, or kill the connection mid-reply. Closes the fault-proxy +/// half of finding 004. + +namespace morph::ladder::testkit { + +namespace detail { + +/// @brief Throws if `_listener->listen()` failed, otherwise a no-op. +/// +/// Factored out of `start()` so the decision is directly unit-testable with +/// a plain `bool` — forcing a real ephemeral-port `listen()` failure +/// deterministically isn't practically achievable without flakiness or a +/// test-only seam on `QWebSocketServer` itself, so the throw logic is what +/// gets tested instead of the real I/O call (mirrors +/// `backend_rig.hpp`'s `throwIfListenFailed`, same rationale, different +/// error message). +/// @param listenSucceeded The real `listen()` call's result. +/// @throws std::runtime_error if @p listenSucceeded is `false`. +inline void throwIfListenFailed(bool listenSucceeded) { + if (!listenSucceeded) { + throw std::runtime_error("FaultProxy::start: failed to listen on an ephemeral loopback port"); + } +} + +/// @brief Whether `nextPendingConnection()`'s result is real and should be +/// adopted as this proxy's client leg. +/// +/// Factored out of `onClientConnection()` so the decision is directly +/// unit-testable by passing `nullptr` or a real pointer, without needing to +/// race a `QWebSocketServer` into returning a spent connection. +/// @param incoming The result of `_listener->nextPendingConnection()`. +/// @return `true` if @p incoming is non-null. +[[nodiscard]] inline bool isValidIncomingConnection(QWebSocket* incoming) noexcept { + return incoming != nullptr; +} + +/// @brief Decodes a wire frame's `callId`, or `0` if it doesn't decode. +/// +/// Shared by `onClientTextMessage()` (request leg) and +/// `onUpstreamTextMessage()` (reply leg) — both need "the callId, or 0 for +/// an undecodable frame" and neither treats a decode failure as fatal (an +/// undecodable frame is forwarded unreported/unmatched rather than dropped). +/// Factoring the try/catch out here collapses both call sites down to a +/// single branch-free assignment, so this is what's unit-tested directly: a +/// real trusted server never emits an undecodable reply, so the +/// reply-side catch block is otherwise unreachable from an integration test. +/// @param message The raw text frame, as received from either socket. +/// @return The decoded `callId`, or `0` if @p message doesn't decode. +[[nodiscard]] inline std::uint64_t decodeCallIdOrZero(const QString& message) noexcept { + try { + return ::morph::wire::decode(message.toStdString()).callId; + } catch (const std::exception&) { + return 0; + } +} + +} // namespace detail + +/// @brief One client<->server relay leg with scriptable server->client reply +/// interception, keyed on the wire envelope's `callId`. +/// +/// @par Wiring +/// Construct with the real `QtWebSocketServer`'s URL, call `start()`, and hand +/// the returned URL to a `QtWebSocketBackend` in place of the server's. Every +/// frame is forwarded verbatim in both directions except where a rule +/// registered for a reply's `callId` says otherwise. +/// +/// @par Connection model +/// Exactly one client leg at a time (the testkit's clients are one socket per +/// `Bridge`; a rig needing N faulted clients builds N proxies). A second +/// incoming connection replaces the first, which matches what +/// `QtWebSocketBackend`'s automatic reconnect does after a `killAfter`. The +/// proxy opens its own upstream socket lazily, on the first client connection, +/// and buffers client frames until that upstream handshake completes — without +/// that buffer the very first frame a client sends (a synchronous `register`, +/// emitted the moment `waitForConnected()` returns) would be written to a +/// still-opening socket and silently lost. +/// +/// @par Threading +/// A `QObject` living on the Qt event loop thread: every slot below runs +/// there, and so does `setRequestObserver`'s callback. The rule table is +/// nevertheless mutex-guarded so a rule may be armed from any thread. +class FaultProxy : public QObject { + Q_OBJECT + + public: + /// @brief Constructs a proxy that will relay to @p upstreamUrl. + /// @param upstreamUrl The real `QtWebSocketServer`'s URL (e.g. + /// `ws://127.0.0.1:`). + /// @param parent Optional `QObject` parent. + explicit FaultProxy(QUrl upstreamUrl, QObject* parent = nullptr); + + /// @brief Stops listening and tears both legs down. + ~FaultProxy() override; + + FaultProxy(const FaultProxy&) = delete; + FaultProxy& operator=(const FaultProxy&) = delete; + FaultProxy(FaultProxy&&) = delete; + FaultProxy& operator=(FaultProxy&&) = delete; + + /// @brief Starts listening on an ephemeral loopback port. + /// @return This proxy's own URL, to hand to a `QtWebSocketBackend` in place + /// of the real server's. + /// @throws std::runtime_error if the listening socket cannot be bound. + [[nodiscard]] QUrl start(); + + /// @brief This proxy's own URL. + /// @return The URL `start()` returned, or an empty `QUrl` before `start()`. + [[nodiscard]] QUrl url() const { return _url; } + + /// @brief How many server->client frames this proxy has written to the + /// client leg so far. + /// + /// Counts frames on the wire, not calls: a `duplicateReply`'d call + /// contributes two, a `dropReply`'d or `killAfter`'d one contributes none. + /// This is what lets a test tell "the client's `Completion` ignored the + /// second copy" apart from "no second copy was ever sent" — the difference + /// between a real idempotency guarantee and a vacuous assertion. + /// + /// @return The running count. Read it from the Qt event loop thread. + [[nodiscard]] std::uint64_t repliesForwarded() const { return _repliesForwarded; } + + /// @brief The reply whose envelope has this `callId` is silently dropped + /// (never forwarded to the client) — simulates a lost reply frame + /// after the server already committed the effect. + /// @param callId Wire `callId` of the reply to drop. + void dropReply(std::uint64_t callId); + + /// @brief The reply for @p callId is held for @p delay before forwarding. + /// @param callId Wire `callId` of the reply to hold. + /// @param delay How long to hold it. + void delayReply(std::uint64_t callId, std::chrono::milliseconds delay); + + /// @brief The reply for @p callId is forwarded twice (simulates a + /// duplicate delivery, the inverse fault to `dropReply`). + /// @param callId Wire `callId` of the reply to duplicate. + void duplicateReply(std::uint64_t callId); + + /// @brief The client<->proxy connection is aborted the instant the + /// reply for @p callId would otherwise be forwarded (simulates a + /// crash/kill mid-reply, before the client observes it). + /// @param callId Wire `callId` of the reply to die on. + void killAfter(std::uint64_t callId); + + /// @brief Registers a callback invoked synchronously from the + /// client->server forwarding path, after decoding a request's + /// `callId` but before that request is forwarded upstream. + /// + /// This is how a test arms a rule for a *specific upcoming* call + /// race-free. `BridgeHandler::execute()` returns a bare `Completion` and + /// never exposes the `callId` the backend assigned it, so a test cannot + /// name call k from the outside. The observer supplies it at the only + /// moment where naming it is still safe: the request is sitting in this + /// proxy, not yet forwarded, so a rule registered from inside the callback + /// is guaranteed installed before the request — and therefore before any + /// possible reply to it — ever reaches the upstream server. + /// + /// Only requests carrying a non-zero `callId` are reported: `callId == 0` + /// is the wire's marker for a synchronous control call + /// (`register`/`deregister`/`hello`), which has no asynchronous reply to + /// fault. A request this proxy cannot decode is forwarded unreported. + /// + /// @param observer Callback receiving the forwarded request's `callId` and + /// this proxy (so it can call `dropReply`/`delayReply`/ + /// `duplicateReply`/`killAfter` on it directly). Pass `nullptr` to + /// clear. + void setRequestObserver(std::function observer); + + private slots: + /// @brief Accepts the pending client connection and opens the upstream leg. + void onClientConnection(); + + /// @brief Forwards one client->server frame, reporting it to the observer first. + /// @param message The raw frame text. + void onClientTextMessage(const QString& message); + + /// @brief Flushes frames buffered while the upstream handshake was in flight. + void onUpstreamConnected(); + + /// @brief Applies this reply's rule (if any) and forwards it to the client. + /// @param message The raw frame text. + void onUpstreamTextMessage(const QString& message); + + private: + /// @brief The scripted faults armed for one `callId`. + struct Rule { + /// @brief Never forward the reply. + bool drop = false; + /// @brief Forward the reply twice. + bool duplicate = false; + /// @brief Abort the client leg instead of forwarding. + bool kill = false; + /// @brief Hold the reply this long before forwarding. + std::optional delay; + }; + + /// @brief Looks up the rule armed for @p callId. + /// @param callId Wire `callId` to look up. + /// @return The armed rule, or a default (fault-free) one. + [[nodiscard]] Rule ruleFor(std::uint64_t callId); + + /// @brief Sends @p message to the client leg if one is connected. + /// @param message The raw frame text. + void sendToClient(const QString& message); + + QUrl _upstreamUrl; + QUrl _url; + std::unique_ptr _listener; + QWebSocket* _clientSocket{nullptr}; // the test's QtWebSocketBackend connects here + QWebSocket* _upstreamSocket{nullptr}; // the proxy's own connection to the real server + bool _upstreamConnected{false}; + std::uint64_t _repliesForwarded{0}; + std::vector _upstreamBacklog; // client frames awaiting the upstream handshake + + std::mutex _rulesMtx; + std::unordered_map _rules; + std::function _requestObserver; +}; + +} // namespace morph::ladder::testkit diff --git a/examples/common/testkit/pump.hpp b/examples/common/testkit/pump.hpp new file mode 100644 index 00000000..fb2f43aa --- /dev/null +++ b/examples/common/testkit/pump.hpp @@ -0,0 +1,136 @@ +// SPDX-License-Identifier: Apache-2.0 +#pragma once + +#include +#include + +#include + +#include +#include +#include +#include +#include +#include +#include + +/// @file +/// The ladder testkit's only sanctioned wait surface (examples/TESTING.md, +/// "Pumping discipline"). A `sleep_for` anywhere else in ladder test code is a +/// review-rejectable defect. + +namespace morph::ladder::testkit { + +namespace detail { + +/// @brief Pure decision logic behind `deadlineScale()`, factored out so it is +/// directly unit-testable: `deadlineScale()` itself reads +/// `MORPH_LADDER_DEADLINE_MS` behind a `static const` guard that runs +/// exactly once per *process*, so no test in the shared +/// `ladder_common_tests` binary can ever be first to observe a +/// particular env value — some earlier test (or `testkit_main.cpp`'s +/// own Qt setup) has always already forced the "unset" path before any +/// test gets to run. Taking the raw env value as a parameter instead +/// of reading it internally sidesteps that entirely: a test calls this +/// with whatever string it likes, no process boundary required. +/// @param envValue `MORPH_LADDER_DEADLINE_MS`'s raw value (as `std::getenv` +/// would return it), or `nullptr` if unset. +/// @return The scale factor, interpreting @p envValue as "use this many ms as +/// the new 5000ms baseline"; `1.0` if unset or unparseable. +[[nodiscard]] inline double computeDeadlineScale(const char* envValue) noexcept { + if (envValue == nullptr) { + return 1.0; + } + try { + return std::stod(envValue) / 5000.0; + } catch (const std::exception&) { + return 1.0; + } +} + +/// @brief `MORPH_LADDER_DEADLINE_MS`, read once per process — scales every +/// `pumpUntil` default deadline uniformly (slow CI runners, sanitizer +/// builds) without touching call sites. All the interesting logic +/// (unset vs. set, parseable vs. not) lives in `computeDeadlineScale` +/// above; this is a one-line, branch-free delegation. +inline double deadlineScale() { + static const double scale = computeDeadlineScale(std::getenv("MORPH_LADDER_DEADLINE_MS")); + return scale; +} + +} // namespace detail + +/// @brief Bounded `processEvents` slices until @p pred is true or @p deadline elapses. +/// +/// @param pred Polled after every slice. +/// @param deadline Wall-clock budget, scaled by `MORPH_LADDER_DEADLINE_MS`. +/// @return `true` if @p pred became true before the deadline, `false` on timeout. +template Pred> +[[nodiscard]] bool pumpUntil(Pred pred, std::chrono::milliseconds deadline = std::chrono::milliseconds{5000}) { + const auto scaledDeadline = + std::chrono::milliseconds{static_cast(static_cast(deadline.count()) * detail::deadlineScale())}; + const auto start = std::chrono::steady_clock::now(); + while (!pred()) { + if (std::chrono::steady_clock::now() - start >= scaledDeadline) { + return false; + } + QCoreApplication::processEvents(QEventLoop::AllEvents, 10); + } + return true; +} + +/// @brief Resolves one `Completion` by pumping the Qt loop; rethrows errors. +/// +/// @tparam T Result type of @p completion. +/// @param completion The completion to await. +/// @param deadline Wall-clock budget passed through to `pumpUntil`. +/// @return The resolved value. +/// @throws std::runtime_error if the deadline elapses before resolution. +template +T awaitQt(::morph::async::Completion completion, std::chrono::milliseconds deadline = std::chrono::milliseconds{5000}) { + // `value`/`error` live in a heap-allocated block kept alive by `shared_ptr`s + // captured (by value) in the `then`/`onError` handlers below. Those handlers + // are held by the completion's backing state, which can outlive this stack + // frame: if `pumpUntil` times out, `awaitQt` throws and unwinds while the + // underlying async operation is still pending. Were `value`/`error` plain + // locals captured by reference, a callback firing after that unwind would + // write through a dangling reference into destroyed stack memory. Routing + // them through `state` means a late callback instead writes into orphaned + // (but valid) heap memory — harmless, since nothing reads it anymore. + struct State { + std::optional value; + std::exception_ptr error; + }; + auto state = std::make_shared(); + + completion + .then([state](T resolved) { state->value = std::move(resolved); }) + .onError([state](const std::exception_ptr& err) { state->error = err; }); + + const bool settled = pumpUntil([state] { return state->value.has_value() || state->error != nullptr; }, deadline); + if (!settled) { + throw std::runtime_error("awaitQt: deadline elapsed before the completion resolved"); + } + if (state->error) { + std::rethrow_exception(state->error); + } + return std::move(*state->value); +} + +/// @brief `pumpUntil(!presenter.busy())` — waits for a presenter's tracked +/// completions to drain. See `examples/common/gui/presenter.hpp` +/// (Task 6) for `busy()`'s contract; this template has no header +/// dependency on that type, so Task 6 requires no change here. +/// @tparam PresenterLike Anything exposing `bool busy() const`. +/// @param presenter Presenter whose in-flight completions to drain. +/// @param deadline Wall-clock budget passed through to `pumpUntil`. +/// @return `true` if the presenter went idle before the deadline, `false` on +/// timeout — `[[nodiscard]]` because a silently ignored timeout turns +/// "the action never completed" into "the assertion below reads stale +/// state", which is exactly the flake this primitive exists to avoid. +template +[[nodiscard]] bool settle(const PresenterLike& presenter, std::chrono::milliseconds deadline = std::chrono::milliseconds{5000}) { + return pumpUntil([&] { return !presenter.busy(); }, deadline); +} + +} // namespace morph::ladder::testkit diff --git a/examples/common/testkit/strand_interleaver.hpp b/examples/common/testkit/strand_interleaver.hpp new file mode 100644 index 00000000..5502ad74 --- /dev/null +++ b/examples/common/testkit/strand_interleaver.hpp @@ -0,0 +1,107 @@ +// SPDX-License-Identifier: Apache-2.0 +#pragma once + +#include + +#include +#include +#include +#include +#include +#include + +/// @file +/// 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` +/// 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 +/// `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` +/// (issue #55's 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 — see the historical discussion in +/// https://github.com/LASTRADA-Software/morph/issues/55. + +namespace morph::ladder::testkit { + +/// @brief An `IExecutor` that queues every posted task and runs them only +/// when explicitly stepped — never on its own thread. +/// +/// 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 +/// `step()`/`runSchedule()`). +/// +/// Unlike `ThreadPoolExecutor`/`StrandExecutor`, 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 +/// rather than have silently swallowed. +class DeterministicExecutor : public ::morph::exec::IExecutor { + public: + void post(std::function task) override { + std::lock_guard lock{_mtx}; + _queue.push_back(std::move(task)); + } + + /// @return The number of tasks currently queued and not yet run. + [[nodiscard]] std::size_t pending() const { + std::lock_guard lock{_mtx}; + return _queue.size(); + } + + /// @brief Runs the oldest-queued task. Throws if the queue is empty. + void step() { + std::function task; + { + std::lock_guard lock{_mtx}; + if (_queue.empty()) { + throw std::runtime_error("DeterministicExecutor::step: queue is empty"); + } + task = std::move(_queue.front()); + _queue.pop_front(); + } + task(); + } + + /// @brief Runs tasks in the exact order given, by *current* queue + /// position at the moment each entry is consumed — so a task that + /// posts new work mid-schedule is reflected in later indices. + /// `order` must name every index that will exist by the time it's + /// reached; the simplest correct schedule is just `{0, 1, ..., n-1}` + /// run one at a time via repeated `step()` calls when a test only + /// wants strict FIFO — `runSchedule` exists for tests that + /// deliberately want a *non*-FIFO interleaving across two strands' + /// queues merged into one DeterministicExecutor. + /// @param order The queue indices to run, in caller-chosen order, each + /// read against the queue's *current* contents at the + /// moment it is consumed (see above). + void runSchedule(const std::vector& order) { + for (auto index : order) { + std::function task; + { + std::lock_guard lock{_mtx}; + if (index >= _queue.size()) { + throw std::runtime_error("DeterministicExecutor::runSchedule: index beyond current queue size"); + } + task = std::move(_queue[index]); + _queue.erase(_queue.begin() + static_cast(index)); + } + task(); + } + } + + private: + mutable std::mutex _mtx; + std::deque> _queue; +}; + +} // namespace morph::ladder::testkit diff --git a/examples/common/testkit/test_backend_rig.cpp b/examples/common/testkit/test_backend_rig.cpp new file mode 100644 index 00000000..19d69080 --- /dev/null +++ b/examples/common/testkit/test_backend_rig.cpp @@ -0,0 +1,280 @@ +// SPDX-License-Identifier: Apache-2.0 +#include +#include +#include + +#include "testkit/backend_rig.hpp" +#include "testkit/pump.hpp" + +#include +#include +#include +#include +#include + +#include +#include +#include +#include + +namespace { + +/// @brief Denies every registration — proves BackendRig{Mode::Socket, N, +/// authorizer} genuinely threads the authorizer through to the +/// RemoteServer it builds, rather than silently ignoring it. +class DenyAllAuthorizer : public morph::session::IAuthorizer { + public: + // authorize() is IAuthorizer's one pure-virtual hook (dispatch-time + // gating); this test only exercises the registration-time hook below, so + // this stays permissive, matching AllowAllAuthorizer's own default. + [[nodiscard]] bool authorize(const morph::session::Context&, std::string_view, std::string_view) const override { + return true; + } + + [[nodiscard]] bool authorizeRegister(const morph::session::Context&, std::string_view) const override { + return false; + } +}; + +} // namespace + +// Deliberately at namespace scope, not inside an anonymous namespace: glz's +// reflection (which BRIDGE_REGISTER_MODEL/BRIDGE_REGISTER_ACTION rely on to +// serialize these types across the wire, exercised by Mode::Socket) needs +// external linkage on the type — see glaze/reflection/get_name.hpp's +// `extern const T external` — so an anonymous-namespace type fails to link. +struct RigProbeAction { + int value = 0; +}; +struct RigProbeModel { + int execute(RigProbeAction action) { return action.value * 2; } +}; + +BRIDGE_REGISTER_MODEL(RigProbeModel, "RigProbeModel") +BRIDGE_REGISTER_ACTION(RigProbeModel, RigProbeAction, "RigProbeAction") + +// Stateful accumulator, mirroring tests/qt/test_qt_websocket.cpp's +// WsCounterModel/WsAddAction. RigProbeModel above is a pure function of its +// action (execute() reads no member state), so a test built on it cannot tell +// genuine per-client instance isolation apart from every client accidentally +// sharing one instance — the two are indistinguishable when nothing +// accumulates. This model's running total only comes out right, per client, +// if each client truly owns its own instance. +struct RigAddAction { + int by = 0; +}; +struct RigCounterModel { + int value = 0; + int execute(RigAddAction action) { + value += action.by; + return value; + } +}; + +BRIDGE_REGISTER_MODEL(RigCounterModel, "RigCounterModel") +BRIDGE_REGISTER_ACTION(RigCounterModel, RigAddAction, "RigAddAction") + +// Carries an arbitrarily large payload, so a test can push one action frame +// past a configured QtWebSocketServerConfig::maxMessageBytes. RigProbeAction's +// lone int cannot: no value of it produces a frame big enough to trip any +// cap a server would plausibly be configured with. +struct RigBlobAction { + std::string blob; +}; +struct RigBlobModel { + std::size_t execute(RigBlobAction action) { return action.blob.size(); } +}; + +BRIDGE_REGISTER_MODEL(RigBlobModel, "RigBlobModel") +BRIDGE_REGISTER_ACTION(RigBlobModel, RigBlobAction, "RigBlobAction") + +TEST_CASE("BackendRig: one action round-trips in every mode", "[ladder][testkit][rig]") { + auto mode = GENERATE(morph::ladder::testkit::Mode::Local, morph::ladder::testkit::Mode::LocalSingleThread, + morph::ladder::testkit::Mode::Socket); + + morph::ladder::testkit::BackendRig rig{mode, /*nClients=*/1}; + auto handler = rig.client(0); + + auto result = morph::ladder::testkit::awaitQt(handler.execute(RigProbeAction{21})); + REQUIRE(result == 42); +} + +TEST_CASE("BackendRig exposes bridge/executor/url so presenters compose over it", "[ladder][testkit][rig]") { + auto mode = GENERATE(morph::ladder::testkit::Mode::Local, morph::ladder::testkit::Mode::LocalSingleThread, + morph::ladder::testkit::Mode::Socket); + + morph::ladder::testkit::BackendRig rig{mode, /*nClients=*/1}; + + // The pair a Presenter subclass is constructed from — client() + // hands out a pre-bound handler, which a presenter that builds its own + // handlers cannot use. + morph::bridge::BridgeHandler handler{rig.bridge(0), rig.executor()}; + REQUIRE(morph::ladder::testkit::awaitQt(handler.execute(RigProbeAction{21})) == 42); + + if (mode == morph::ladder::testkit::Mode::Socket) { + REQUIRE(rig.url().scheme() == "ws"); + REQUIRE(rig.url().port() > 0); + } else { + // No server, so no URL to hand out — a caller asking for one has a + // mode confusion, not a missing value. + REQUIRE_THROWS_AS(rig.url(), std::logic_error); + } +} + +TEST_CASE("BackendRig::Socket: N clients each get an isolated model instance", "[ladder][testkit][rig][socket-only]") { + morph::ladder::testkit::BackendRig rig{morph::ladder::testkit::Mode::Socket, /*nClients=*/3}; + + // One handler per client, held for the whole test: each call to + // rig.client(index) registers a fresh model instance, so getting + // a handler once per client and driving several actions through it (as + // opposed to re-fetching the handler for every action) is what actually + // exercises one running total per client rather than one per call. + auto handler0 = rig.client(0); + auto handler1 = rig.client(1); + auto handler2 = rig.client(2); + + // Client 0 increments by 10 three times -> running total 10, 20, 30. + int last0 = 0; + for (int i = 0; i < 3; ++i) { + last0 = morph::ladder::testkit::awaitQt(handler0.execute(RigAddAction{10})); + } + // Client 1 increments by 1 twice -> running total 1, 2. + int last1 = 0; + for (int i = 0; i < 2; ++i) { + last1 = morph::ladder::testkit::awaitQt(handler1.execute(RigAddAction{1})); + } + // Client 2 increments by 5 four times -> running total 5, 10, 15, 20. + int last2 = 0; + for (int i = 0; i < 4; ++i) { + last2 = morph::ladder::testkit::awaitQt(handler2.execute(RigAddAction{5})); + } + + // Only genuine per-client isolation produces exactly these three totals: + // if clients accidentally shared one server-side instance, each client's + // total would be contaminated by the others' increments (e.g. client 1's + // final value would include client 0's +10s), and these REQUIREs would + // fail. + REQUIRE(last0 == 30); + REQUIRE(last1 == 2); + REQUIRE(last2 == 20); +} + +TEST_CASE("BackendRig::mode() reports the mode it was constructed with", "[ladder][testkit][rig]") { + auto mode = GENERATE(morph::ladder::testkit::Mode::Local, morph::ladder::testkit::Mode::LocalSingleThread, + morph::ladder::testkit::Mode::Socket); + morph::ladder::testkit::BackendRig rig{mode, /*nClients=*/1}; + REQUIRE(rig.mode() == mode); +} + +TEST_CASE("BackendRig::Socket threads a custom authorizer through to the RemoteServer it builds", + "[ladder][testkit][rig][socket-only]") { + auto authorizer = std::make_shared(); + morph::ladder::testkit::BackendRig rig{morph::ladder::testkit::Mode::Socket, /*nClients=*/1, authorizer}; + + // Registration itself is denied and throws synchronously from + // BridgeHandler's constructor — if the authorizer were silently ignored + // (the pre-fix default-allow behavior), this would construct cleanly + // instead. + REQUIRE_THROWS_WITH(rig.client(0), Catch::Matchers::ContainsSubstring("unauthorized")); +} + +TEST_CASE("BackendRig::Socket threads a custom QtWebSocketServerConfig through to the server it builds", + "[ladder][testkit][rig][socket-only]") { + morph::qt::QtWebSocketServerConfig cfg; + cfg.maxMessageBytes = 1024; // far below the 8 MiB wire cap the default carries + morph::ladder::testkit::BackendRig rig{morph::ladder::testkit::Mode::Socket, /*nClients=*/1, + /*authorizer=*/nullptr, cfg}; + + // Registration frames stay well under the cap, so the handler itself + // constructs normally — only the oversized action frame below is refused, + // by the transport, before it ever reaches the model. + auto handler = rig.client(0); + + REQUIRE(morph::ladder::testkit::awaitQt(handler.execute(RigBlobAction{std::string(16, 'x')})) == 16); + + // If the config were silently dropped (the pre-extension behavior), this + // 64 KiB frame would sail through the default 8 MiB cap and resolve with + // its own size instead of rejecting. + REQUIRE_THROWS_WITH( + morph::ladder::testkit::awaitQt(handler.execute(RigBlobAction{std::string(64 * 1024, 'x')})), + Catch::Matchers::ContainsSubstring("maxMessageBytes")); +} + +TEST_CASE("BackendRig::socketBackend() hands out the live backend, usable for hello negotiation", + "[ladder][testkit][rig][socket-only]") { + morph::ladder::testkit::BackendRig rig{morph::ladder::testkit::Mode::Socket, /*nClients=*/2}; + + // negotiateProtocolVersion() is transport-level and has no Bridge-level + // equivalent — reaching it at all is the reason this accessor exists. + REQUIRE(rig.socketBackend(0).negotiateProtocolVersion() == morph::wire::ProtocolNegotiationResult::Negotiated); + REQUIRE(rig.socketBackend(1).negotiateProtocolVersion() == morph::wire::ProtocolNegotiationResult::Negotiated); + + // Still a working backend afterwards: negotiation is not a one-way door. + auto handler = rig.client(0); + REQUIRE(morph::ladder::testkit::awaitQt(handler.execute(RigProbeAction{21})) == 42); +} + +TEST_CASE("BackendRig::socketBackend() throws out_of_range past nClients, and logic_error off Socket mode", + "[ladder][testkit][rig]") { + { + morph::ladder::testkit::BackendRig rig{morph::ladder::testkit::Mode::Socket, /*nClients=*/1}; + REQUIRE_THROWS_AS(rig.socketBackend(1), std::out_of_range); + } + auto localMode = + GENERATE(morph::ladder::testkit::Mode::Local, morph::ladder::testkit::Mode::LocalSingleThread); + morph::ladder::testkit::BackendRig localRig{localMode, /*nClients=*/1}; + REQUIRE_THROWS_AS(localRig.socketBackend(0), std::logic_error); +} + +TEST_CASE("BackendRig::client() throws out_of_range past nClients in Socket mode", + "[ladder][testkit][rig][socket-only]") { + morph::ladder::testkit::BackendRig rig{morph::ladder::testkit::Mode::Socket, /*nClients=*/1}; + REQUIRE_THROWS_AS(rig.client(1), std::out_of_range); +} + +TEST_CASE("BackendRig::bridge() throws out_of_range past nClients in Socket mode", + "[ladder][testkit][rig][socket-only]") { + morph::ladder::testkit::BackendRig rig{morph::ladder::testkit::Mode::Socket, /*nClients=*/1}; + REQUIRE_THROWS_AS(rig.bridge(1), std::out_of_range); +} + +// Forcing a real listen()/waitForConnected() failure deterministically isn't +// practically achievable without flakiness or a test-only seam on +// QtWebSocketServer/QtWebSocketBackend themselves — the throw logic that +// would run on failure is factored into these two plain-bool functions +// instead, so it's what gets tested. See their doc comments in +// backend_rig.hpp for the full rationale. +TEST_CASE("throwIfListenFailed throws exactly when its argument is false", "[ladder][testkit][rig]") { + REQUIRE_THROWS_AS(morph::ladder::testkit::detail::throwIfListenFailed(false), std::runtime_error); + REQUIRE_NOTHROW(morph::ladder::testkit::detail::throwIfListenFailed(true)); +} + +TEST_CASE("throwIfConnectFailed throws exactly when its argument is false", "[ladder][testkit][rig]") { + REQUIRE_THROWS_AS(morph::ladder::testkit::detail::throwIfConnectFailed(false), std::runtime_error); + REQUIRE_NOTHROW(morph::ladder::testkit::detail::throwIfConnectFailed(true)); +} + +// A `QtDrivenMainThreadExecutor` destroyed with its zero-delay drain timer +// still pending must not touch its own storage when that timer fires. This is +// the exact shape that aborted the process before `_liveness` was added: a +// `Mode::LocalSingleThread` rig is routinely destroyed one event-loop turn +// after its last `post()`, and the *next* thing to spin the Qt loop — +// `BackendRig{Mode::Socket, ...}`'s `waitForConnected()`, or +// `~QtWebSocketBackend`'s own `processEvents()` — delivered the stale event +// into freed memory, threw `std::system_error{"mutex lock failed"}` out of a +// Qt event handler, and Qt turned that into `abort()`. Reverting the guard in +// `post()` makes this case abort rather than fail. +TEST_CASE("QtDrivenMainThreadExecutor's pending drain is inert after the executor is destroyed", + "[ladder][testkit][rig]") { + bool taskRan = false; + { + morph::ladder::testkit::detail::QtDrivenMainThreadExecutor executor; + executor.post([&taskRan] { taskRan = true; }); + // Deliberately no pump here: the drain timer is left in flight, which + // is precisely the state the crash needed. + } + // Spinning the loop now delivers the orphaned timer event. It must be a + // no-op, not a use-after-free. + REQUIRE_FALSE(morph::ladder::testkit::pumpUntil([] { return false; }, std::chrono::milliseconds{50})); + CHECK_FALSE(taskRan); +} diff --git a/examples/common/testkit/test_clock.cpp b/examples/common/testkit/test_clock.cpp new file mode 100644 index 00000000..8f92dfa0 --- /dev/null +++ b/examples/common/testkit/test_clock.cpp @@ -0,0 +1,55 @@ +// SPDX-License-Identifier: Apache-2.0 +#include + +#include "clock.hpp" + +using namespace std::chrono_literals; + +TEST_CASE("morph::ladder::now() reads the real wall clock with no override installed", + "[ladder][testkit][clock]") { + const auto before = ::morph::time::DateTime::now(); + const auto observed = morph::ladder::now(); + const auto after = ::morph::time::DateTime::now(); + REQUIRE(observed.hasValue()); + REQUIRE(*observed >= before); + REQUIRE(*observed <= after); +} + +TEST_CASE("ScopedClockOverride freezes now() at the given instant", "[ladder][testkit][clock]") { + const ::morph::time::DateTime frozen{std::chrono::year{2030}, std::chrono::month{1}, std::chrono::day{1}, + std::chrono::hours{0}, std::chrono::minutes{0}, std::chrono::seconds{0}}; + { + morph::ladder::ScopedClockOverride guard{frozen}; + REQUIRE(*morph::ladder::now() == frozen); + REQUIRE(*morph::ladder::now() == frozen); // stable across repeated reads, not a one-shot + } + REQUIRE(*morph::ladder::now() != frozen); // restored to the real clock after the guard's scope +} + +TEST_CASE("ScopedClockOverride freezes now() at a pre-1970 instant", "[ladder][testkit][clock]") { + // A pre-epoch instant's epoch-ms is negative. The disabled sentinel used + // to be -1, so any negative override (including this one) fell through + // to the real wall clock instead of the frozen instant, silently. The + // sentinel is now INT64_MIN, which no real DateTime a test constructs can + // ever equal. + const ::morph::time::DateTime frozen{std::chrono::year{1965}, std::chrono::month{3}, std::chrono::day{12}, + std::chrono::hours{0}, std::chrono::minutes{0}, std::chrono::seconds{0}}; + REQUIRE(frozen.value.time_since_epoch().count() < 0); + morph::ladder::ScopedClockOverride guard{frozen}; + REQUIRE(*morph::ladder::now() == frozen); +} + +TEST_CASE("ScopedClockOverride nests: the inner guard wins, the outer resumes on inner's destruction", + "[ladder][testkit][clock]") { + const ::morph::time::DateTime outer{std::chrono::year{2030}, std::chrono::month{1}, std::chrono::day{1}, + std::chrono::hours{0}, std::chrono::minutes{0}, std::chrono::seconds{0}}; + const ::morph::time::DateTime inner{std::chrono::year{2031}, std::chrono::month{6}, std::chrono::day{15}, + std::chrono::hours{12}, std::chrono::minutes{0}, std::chrono::seconds{0}}; + morph::ladder::ScopedClockOverride outerGuard{outer}; + REQUIRE(*morph::ladder::now() == outer); + { + morph::ladder::ScopedClockOverride innerGuard{inner}; + REQUIRE(*morph::ladder::now() == inner); + } + REQUIRE(*morph::ladder::now() == outer); +} diff --git a/examples/common/testkit/test_db_busy_fixture.cpp b/examples/common/testkit/test_db_busy_fixture.cpp new file mode 100644 index 00000000..ecd1607f --- /dev/null +++ b/examples/common/testkit/test_db_busy_fixture.cpp @@ -0,0 +1,114 @@ +// SPDX-License-Identifier: Apache-2.0 +#include +#include + +#include "testkit/db_busy_fixture.hpp" +#include "testkit/db_fixture.hpp" + +#include +#include + +#include +#include +#include + +// Not an anonymous namespace: reflection-cpp's `DataMapper` reflects on this +// struct via `Reflection::detail::External`, which requires `T` to have +// external linkage — see test_db_fixture.cpp's identical comment on +// `LadderTestkitProbe` for the full explanation. +namespace ladder_testkit_busy_probe { + +struct BusyProbe { + static constexpr std::string_view TableName = "busy_fixture_probe"; + + Lightweight::Field id; + Lightweight::Field label; +}; + +} // namespace ladder_testkit_busy_probe + +using ladder_testkit_busy_probe::BusyProbe; + +LIGHTWEIGHT_SQL_MIGRATION(2, "busy_fixture_probe: create probe table") +{ + plan.CreateTable("busy_fixture_probe") + .PrimaryKeyWithAutoIncrement("id") + .Column("label", Lightweight::SqlColumnTypeDefinitions::Varchar{ 64 }); +} + +namespace { + +/// @brief Same database `DbFixture` just migrated, but with a short +/// `Timeout=` — see db_busy_fixture.hpp's doc comment for why this +/// has to be set *at connect time*, in the connection string itself, +/// rather than via a later `PRAGMA busy_timeout` (which only shortens +/// SQLite's own per-attempt busy handler, not the sqliteodbc +/// driver's own outer retry ceiling — captured once at connect and +/// never re-read from the live connection afterward). +/// +/// Derived from the process's actual active connection string (rather than +/// a hard-coded literal) so this stays correct if `ODBC_CONNECTION_STRING` +/// ever points somewhere other than `DbFixture`'s own SQLite-file default. +[[nodiscard]] std::string shortTimeoutConnectionString() +{ + std::string connStr = + morph::ladder::testkit::DbFixture::computeConnectionString(std::getenv("ODBC_CONNECTION_STRING")); + static constexpr std::string_view key = "Timeout="; + if (auto const pos = connStr.find(key); pos != std::string::npos) { + auto const valueStart = pos + key.size(); + auto valueEnd = connStr.find(';', valueStart); + if (valueEnd == std::string::npos) { + valueEnd = connStr.size(); + } + connStr.replace(valueStart, valueEnd - valueStart, "200"); + } else { + connStr += ";Timeout=200"; + } + return connStr; +} + +} // namespace + +TEST_CASE("DbBusyFixture forces a genuine SQLITE_BUSY on a concurrent write to the same table", + "[ladder][testkit][db][busy]") +{ + morph::ladder::testkit::DbFixture fixture; + { + Lightweight::DataMapper mapper; + BusyProbe row; + row.label = "seed"; + mapper.Create(row); + } + + morph::ladder::testkit::DbBusyFixture busy{ "busy_fixture_probe" }; + + Lightweight::DataMapper mapper{ Lightweight::SqlConnectionString{ shortTimeoutConnectionString() } }; + // Lightweight::SqlConnection::PostConnect() unconditionally issues + // `PRAGMA busy_timeout = 60000` for every SQLite connection right after + // connect, which *does* win over whatever the connection string's + // `Timeout=` set moments earlier for SQLite's own internal busy handler + // (confirmed empirically: last PRAGMA busy_timeout call wins). Re-issue + // it here, short, so the handler governing each individual retry attempt + // is short too -- both this AND shortTimeoutConnectionString()'s short + // `Timeout=` are required together (confirmed empirically): the + // connection string alone shortens only the driver's outer retry + // ceiling, which a single 60s-bounded inner attempt already blows past + // before that ceiling is ever checked; the PRAGMA alone shortens only + // the inner attempts, leaving the outer ceiling (5000ms by + // DbFixture::computeConnectionString's own default) as the effective + // total bound. Together, both bounds are short, and the racy write below + // fails within a few hundred milliseconds. + (void) Lightweight::SqlStatement{ mapper.Connection() }.ExecuteDirect("PRAGMA busy_timeout = 200"); + + BusyProbe row; + row.label = "should collide"; + auto const start = std::chrono::steady_clock::now(); + REQUIRE_THROWS_WITH(mapper.Create(row), Catch::Matchers::ContainsSubstring("database is locked")); + auto const elapsed = std::chrono::steady_clock::now() - start; + // Must fail fast, not after minutes -- otherwise this "test" would just + // be a very slow way to prove the same thing (observed without the + // combined override above: tens of seconds, occasionally exceeding even + // the ladder_common_tests suite's 120s ctest TIMEOUT budget for a single + // test case). + REQUIRE(elapsed < std::chrono::seconds{ 5 }); +} diff --git a/examples/common/testkit/test_db_fault_fixture.cpp b/examples/common/testkit/test_db_fault_fixture.cpp new file mode 100644 index 00000000..a6a812e4 --- /dev/null +++ b/examples/common/testkit/test_db_fault_fixture.cpp @@ -0,0 +1,66 @@ +// SPDX-License-Identifier: Apache-2.0 +#include + +#include "testkit/db_fault_fixture.hpp" +#include "testkit/db_fixture.hpp" + +#include +#include + +#include +#include + +// Mirrors Lightweight's own MigrationLockTests.cpp: two distinct +// `SqlConnection` instances are required to prove genuine cross-session +// contention. SQL Server's `sp_getapplock` (with `@LockOwner=Session`) and +// PostgreSQL's `pg_advisory_lock` are both reentrant on the same connection, +// so acquiring twice through one session would succeed — cross-session +// contention is the path that throws on every backend (including SQLite, +// whose lock table just rejects the duplicate). `DbFaultFixture`'s own +// `_lockingConnection` and each test's `secondConn`/`thirdConn` below are +// always separate `SqlConnection` instances for exactly this reason. + +TEST_CASE("DbFaultFixture: a second session contending on the same lock name throws", + "[ladder][testkit][db][fault]") { + morph::ladder::testkit::DbFaultFixture fault{"probe_lock"}; + + Lightweight::SqlConnection secondConn; + REQUIRE_THROWS_AS( + (Lightweight::SqlScopedLock{secondConn, "probe_lock", std::chrono::milliseconds{50}}), + std::runtime_error); +} + +TEST_CASE("DbFaultFixture::lockName() reports the name it was constructed with", "[ladder][testkit][db][fault]") { + morph::ladder::testkit::DbFaultFixture fault{"probe_lock_named"}; + REQUIRE(fault.lockName() == "probe_lock_named"); + + // A test can use lockName() to name the exact lock it holds when + // contending against it, instead of hard-coding the string twice. + Lightweight::SqlConnection secondConn; + REQUIRE_THROWS_AS( + (Lightweight::SqlScopedLock{secondConn, fault.lockName(), std::chrono::milliseconds{50}}), + std::runtime_error); +} + +TEST_CASE("DbFaultFixture: a different lock name is unaffected", "[ladder][testkit][db][fault]") { + morph::ladder::testkit::DbFaultFixture fault{"probe_lock_a"}; + + Lightweight::SqlConnection secondConn; + Lightweight::SqlScopedLock other{secondConn, "probe_lock_b", std::chrono::milliseconds{50}}; + REQUIRE(other.IsLocked()); +} + +TEST_CASE("DbFaultFixture: releasing the fixture (going out of scope) lets a later acquisition succeed", + "[ladder][testkit][db][fault]") { + { + morph::ladder::testkit::DbFaultFixture fault{"probe_lock_scoped"}; + Lightweight::SqlConnection secondConn; + REQUIRE_THROWS_AS( + (Lightweight::SqlScopedLock{secondConn, "probe_lock_scoped", std::chrono::milliseconds{50}}), + std::runtime_error); + } + // fault is destroyed here — its SqlScopedLock releases. + Lightweight::SqlConnection thirdConn; + Lightweight::SqlScopedLock reacquire{thirdConn, "probe_lock_scoped", std::chrono::milliseconds{50}}; + REQUIRE(reacquire.IsLocked()); +} diff --git a/examples/common/testkit/test_db_fixture.cpp b/examples/common/testkit/test_db_fixture.cpp new file mode 100644 index 00000000..a101c98b --- /dev/null +++ b/examples/common/testkit/test_db_fixture.cpp @@ -0,0 +1,110 @@ +// SPDX-License-Identifier: Apache-2.0 +#include + +#include "testkit/db_fixture.hpp" + +#include +#include + +// Not an anonymous namespace: reflection-cpp's `DataMapper` reflects on this +// struct via `Reflection::detail::External`, which requires `T` to have +// external linkage — a type declared inside an unnamed namespace has internal +// linkage and fails to compile (`used but not defined in this translation +// unit, and cannot be defined in any other translation unit because its type +// does not have linkage`). Lightweight's own reflection-backed test fixtures +// hit the same constraint and use a named namespace instead (see +// `Lightweight/src/tests/MigrationReflectionTests.cpp`'s `ReflectionTests`); +// this mirrors that, scoped to this test file only by the uncommon name. +namespace ladder_testkit_probe { + +struct LadderTestkitProbe { + // Reflection's default table name is the (unqualified) struct name, i.e. + // "LadderTestkitProbe" — explicit here so DataMapper targets the same + // "ladder_testkit_probe" table the migration below creates. + static constexpr std::string_view TableName = "ladder_testkit_probe"; + + Lightweight::Field id; + Lightweight::Field label; +}; + +} // namespace ladder_testkit_probe + +using ladder_testkit_probe::LadderTestkitProbe; + +LIGHTWEIGHT_SQL_MIGRATION(1, "ladder_testkit_probe: create probe table") { + plan.CreateTable("ladder_testkit_probe") + .PrimaryKeyWithAutoIncrement("id") + .Column("label", Lightweight::SqlColumnTypeDefinitions::Varchar{64}); +} + +TEST_CASE("DbFixture resets the shared database: a row from a prior fixture is gone", "[ladder][testkit][db]") { + { + morph::ladder::testkit::DbFixture fixture; + Lightweight::DataMapper mapper; + LadderTestkitProbe row; + row.label = "left-over-from-first-fixture"; + mapper.Create(row); + } + // A fresh fixture drops+recreates the table — the row above must not survive. + morph::ladder::testkit::DbFixture fixture; + Lightweight::DataMapper mapper; + auto rows = mapper.Query().All(); + REQUIRE(rows.empty()); +} + +TEST_CASE("DbFixture applies pending migrations so a registered table exists and is writable", "[ladder][testkit][db]") { + morph::ladder::testkit::DbFixture fixture; + Lightweight::DataMapper mapper; + LadderTestkitProbe row; + row.label = "probe"; + mapper.Create(row); + auto rows = mapper.Query().All(); + REQUIRE(rows.size() == 1); + REQUIRE(rows.front().label.Value() == "probe"); +} + +// ensureConnectionConfigured() applies its result behind a `static const` +// guard that runs exactly once per *process*, so no test can ever be first +// to observe a particular ODBC_CONNECTION_STRING value once some earlier +// test has already forced the default-SQLite path. computeConnectionString +// takes the raw env value as a parameter instead, so it's directly testable +// without a process boundary — see db_fixture.hpp's comment on it. +TEST_CASE("DbFixture::computeConnectionString falls back to the default SQLite file when unset", + "[ladder][testkit][db]") { + REQUIRE(morph::ladder::testkit::DbFixture::computeConnectionString(nullptr) == + "DRIVER=SQLite3;Database=morph_ladder_test.db;Timeout=5000"); + REQUIRE(morph::ladder::testkit::DbFixture::computeConnectionString("") == + "DRIVER=SQLite3;Database=morph_ladder_test.db;Timeout=5000"); +} + +TEST_CASE("DbFixture::computeConnectionString uses ODBC_CONNECTION_STRING verbatim when set", + "[ladder][testkit][db]") { + REQUIRE(morph::ladder::testkit::DbFixture::computeConnectionString("DRIVER=PostgreSQL;Database=whatever") == + "DRIVER=PostgreSQL;Database=whatever"); +} + +TEST_CASE("DbFixture's table-drop sweep is unaffected by SQLite's own sqlite_sequence bookkeeping table", + "[ladder][testkit][db]") { + { + morph::ladder::testkit::DbFixture fixture; + // Lightweight's PrimaryKeyWithAutoIncrement() emits a plain SQLite + // rowid-alias `INTEGER PRIMARY KEY` (no sqlite_sequence involved) — + // the probe table above never triggers this. The literal + // `AUTOINCREMENT` keyword is what makes SQLite create and maintain + // its own `sqlite_sequence` bookkeeping table, so force that here. + Lightweight::SqlStatement stmt; + (void)stmt.ExecuteDirect("CREATE TABLE ladder_autoincrement_probe (id INTEGER PRIMARY KEY AUTOINCREMENT)"); + (void)stmt.ExecuteDirect("INSERT INTO ladder_autoincrement_probe DEFAULT VALUES"); + } + // A fresh fixture's drop sweep runs with sqlite_sequence now present in + // the database (created as a side effect above) — this must not throw + // (Lightweight's own ReadAllTables never surfaces sqlite_sequence as a + // table to drop in the first place — see db_fixture.hpp's comment on + // dropAllTables), and the migrated probe table must still come back + // clean. + REQUIRE_NOTHROW([] { morph::ladder::testkit::DbFixture fixture; }()); + + morph::ladder::testkit::DbFixture fixture; + Lightweight::DataMapper mapper; + REQUIRE(mapper.Query().All().empty()); +} diff --git a/examples/common/testkit/test_db_pool_drain.cpp b/examples/common/testkit/test_db_pool_drain.cpp new file mode 100644 index 00000000..7b481aca --- /dev/null +++ b/examples/common/testkit/test_db_pool_drain.cpp @@ -0,0 +1,54 @@ +// SPDX-License-Identifier: Apache-2.0 +#include + +#include "testkit/db_fixture.hpp" +#include "testkit/db_pool_drain.hpp" + +#include +#include + +#include + +// drainPoolIdleMappers()'s whole point is making the *next* Acquire() +// trigger Lightweight::SqlConnection::PostConnect() -- there is no direct +// "was this connection fresh" observable exposed to a morph consumer (see +// db_busy_fixture.hpp's own note: Pool::IdleCount()/WaiterCount() exist only +// under Lightweight's internal BUILD_TESTS macro, never defined for code +// linking against Lightweight as a library), so PostConnect firing (or not) +// via SetPostConnectedHook is the only observable morph itself has, and is +// exactly the mechanism the busy-timeout tests this helper protects rely on. + +TEST_CASE("drainPoolIdleMappers makes the next Acquire() trigger PostConnect", + "[ladder][testkit][db][pool]") { + morph::ladder::testkit::DbFixture fixture; + + // Warm the pool with at least one real, connected mapper first -- an + // ordinary Acquire()+destroy, so a later Acquire() has something idle to + // (wrongly) hand back if the drain below did not actually work. + (void) ::Lightweight::GlobalDataMapperPool().Acquire(); + + // The drain itself acquires Config.maxSize mappers, and however many of + // those the pool did not already have idle each connect for real (firing + // PostConnect of their own) -- that is drainPoolIdleMappers() doing + // exactly its job, not noise to suppress, but it means the hook must be + // installed *after* the drain to isolate the one acquisition this test + // actually cares about. + auto drained = morph::ladder::testkit::drainPoolIdleMappers(); + + std::atomic postConnectCount{0}; + ::Lightweight::SqlConnection::SetPostConnectedHook( + [&postConnectCount](::Lightweight::SqlConnection&) { postConnectCount.fetch_add(1); }); + + { + // Still holding every idle mapper drainPoolIdleMappers() acquired -- + // the pool's idle list is empty right now, so this Acquire() must + // construct a fresh DataMapper, which must connect, which must fire + // PostConnect() exactly once. + auto fresh = ::Lightweight::GlobalDataMapperPool().Acquire(); + CHECK(postConnectCount.load() == 1); + // fresh still in scope here -- released below, after the assertion + // above already observed the fresh acquisition. + } + + ::Lightweight::SqlConnection::ResetPostConnectedHook(); +} diff --git a/examples/common/testkit/test_event_poller.cpp b/examples/common/testkit/test_event_poller.cpp new file mode 100644 index 00000000..543d1032 --- /dev/null +++ b/examples/common/testkit/test_event_poller.cpp @@ -0,0 +1,495 @@ +// SPDX-License-Identifier: Apache-2.0 +// +// Task 15: this rung's framework-level deliverable. Lives alongside +// test_presenter.cpp (which tests gui/presenter.hpp from testkit/, the +// established precedent for where examples/common/gui/'s own tests live) -- +// not a fresh examples/common/tests/ directory. See +// examples/common/CMakeLists.txt's ladder_common_tests target. +// +// EventPoller is generic (see event_poller.hpp's own doc +// comment for why), so these tests exercise it against a small fake feed +// model of this file's own -- FeedModel/GetFeedSince/GetFeedSinceResult -- +// rather than polls::PollModel/GetEventsSince, mirroring how +// test_backend_rig.cpp and test_presenter.cpp each build their own throwaway +// probe model instead of depending on a real rung's. +// +// The one piece of real Bridge machinery these tests deliberately exercise +// for real, not through a fake: Bridge::setExecuteDeadline. EventPoller's +// constructor calls it, and the "survives a ClientTimeoutError" test below +// drives a genuine BridgeHandler::execute() call that never +// replies, letting the real Bridge::TimeoutScheduler resolve it with a real +// morph::backend::ClientTimeoutError -- the same mechanism (and the same +// class doc comment already pointed here) as +// examples/polls/tests/test_shared_instance_lifecycle.cpp's own +// "Bridge::setExecuteDeadline recovers a call the real rate limiter silently +// drops" test, just without standing up a rate-limited WebSocket server: a +// condition-variable-gated model call is enough to force the deadline to +// fire, deterministically and without any sleep_for (examples/TESTING.md, +// "Pumping discipline -- no sleeps"). + +#include + +#include "gui/app_context.hpp" +#include "gui/event_poller.hpp" +#include "testkit/pump.hpp" + +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +// Deliberately at file scope, not inside an anonymous namespace: glz's +// reflection (which BRIDGE_REGISTER_MODEL/BRIDGE_REGISTER_ACTION rely on to +// serialize these types) needs external linkage on the type -- see +// testkit/test_backend_rig.cpp's RigProbeModel for the identical precedent +// and rationale. +struct FeedEvent { + int id = 0; + std::string summary; +}; + +struct GetFeedSince { + int lastEventId = 0; +}; + +struct GetFeedSinceResult { + std::vector events; +}; + +/// @brief `FeedModel`'s process-wide control block -- a free-standing type +/// (not nested inside `FeedModel` itself) because a static data +/// member's in-class initializer cannot reference a nested class's +/// own in-class default member initializers before that nested +/// class's definition is complete (a real compiler restriction, not +/// a style choice -- nesting this and writing +/// `static inline Control control{};` fails to compile under clang +/// with "default member initializer ... needed within definition of +/// enclosing class"). +struct FeedControl { + std::vector events; + std::atomic callCount{0}; + std::atomic blockFirstCall{false}; + std::atomic throwNotFound{false}; + std::mutex releaseMutex; + std::condition_variable releaseCv; + bool released = false; +}; + +/// @brief Backing model for these tests. `control` is static (process-wide) +/// rather than an instance field because this test relies on the +/// plain `BRIDGE_REGISTER_MODEL` default-construction path — the same +/// choice `morph::ladder::now()`'s `ScopedClockOverride` process-global +/// slot makes (`examples/common/clock.hpp`) — rather than adopting +/// `ModelRegistryFactory`'s per-instance construction-hook seam +/// (`include/morph/core/registry.hpp`) that would let a fresh +/// `FeedModel` instance receive its own fixture data directly. Reset +/// with `resetFeedControl()` at the top of every TEST_CASE that +/// touches it. +struct FeedModel { + static inline FeedControl control{}; + + GetFeedSinceResult execute(GetFeedSince action) { + const int thisCall = control.callCount.fetch_add(1) + 1; + if (control.throwNotFound.load()) { + throw std::runtime_error{"NotFound: feed does not exist"}; + } + if (control.blockFirstCall.load() && thisCall == 1) { + // Blocks this worker-pool thread until the test releases it -- + // simulating a frame a rate limiter silently drops, without any + // sleep_for. Bridge's own TimeoutScheduler (armed by + // EventPoller's constructor via setExecuteDeadline) races this + // independently and resolves the caller's Completion with + // ClientTimeoutError long before this wait ever returns; the + // test observes that via pumpUntil, then releases this wait + // itself so ~ThreadPoolExecutor's join at teardown does not + // hang on a permanently blocked worker. + std::unique_lock lock{control.releaseMutex}; + control.releaseCv.wait(lock, [] { return control.released; }); + } + GetFeedSinceResult result; + for (const auto& event : control.events) { + if (event.id > action.lastEventId) { + result.events.push_back(event); + } + } + return result; + } +}; + +BRIDGE_REGISTER_MODEL(FeedModel, "EventPollerTestFeedModel") +BRIDGE_REGISTER_ACTION(FeedModel, GetFeedSince, "EventPollerTestGetFeedSince") + +namespace { + +void resetFeedControl() { + auto& control = FeedModel::control; + control.events.clear(); + control.callCount.store(0); + control.blockFirstCall.store(false); + control.throwNotFound.store(false); + // Under releaseMutex, matching releaseBlockedCall()'s own write: a worker + // thread left blocked in FeedModel::execute() by a *previous* test case + // can still be reading this flag under the same mutex, so an unguarded + // write here is a data race (and a ThreadSanitizer report waiting to + // happen -- this suite is expected to run under /sanitize eventually). + { + const std::lock_guard lock{control.releaseMutex}; + control.released = false; + } +} + +void releaseBlockedCall() { + { + const std::lock_guard lock{FeedModel::control.releaseMutex}; + FeedModel::control.released = true; + } + FeedModel::control.releaseCv.notify_all(); +} + +using Poller = morph::ladder::gui::EventPoller; + +/// @brief The production wiring's stand-in for these tests: a `Dispatch` +/// closure driving a real `BridgeHandler` directly, rather +/// than a presenter's own signal-based API -- see event_poller.hpp's +/// "Why dispatch is a caller-supplied closure" doc comment for why +/// that keeps `ClientTimeoutError` a real, catchable exception type +/// here instead of a string comparison. +Poller::Dispatch makeDispatch(std::shared_ptr> handler) { + return [handler](int lastEventId, Poller::OnSuccess onSuccess, Poller::OnError onError) { + handler->execute(GetFeedSince{.lastEventId = lastEventId}) + .then([handler, lastEventId, onSuccess](GetFeedSinceResult result) { + const int newLastEventId = result.events.empty() ? lastEventId : result.events.back().id; + onSuccess(std::move(result.events), newLastEventId); + }) + .onError([handler, onError](std::exception_ptr err) { onError(std::move(err)); }); + }; +} + +} // namespace + +TEST_CASE("EventPoller applies every event returned since the last tick and advances its cursor", + "[gui][event-poller]") { + resetFeedControl(); + FeedModel::control.events = {{.id = 1, .summary = "a"}, {.id = 2, .summary = "b"}, {.id = 3, .summary = "c"}}; + + morph::ladder::gui::AppContext ctx{morph::ladder::gui::Local{}}; + auto handler = std::make_shared>(ctx.bridge(), ctx.executor()); + + std::vector appliedIds; + bool fatal = false; + // A one-hour interval never fires on its own for the duration of this + // test -- pollOnce() below drives every tick manually and + // deterministically (examples/TESTING.md's "Pumping discipline"; the + // task brief's own "drive the timer manually rather than sleeping"). + Poller poller{ + ctx.bridge(), /*startingCursor=*/0, makeDispatch(handler), + [&](const FeedEvent& event) { appliedIds.push_back(event.id); }, [&](const QString&) { fatal = true; }, + std::chrono::hours{1}}; + + REQUIRE_FALSE(poller.busy()); + poller.pollOnce(); + REQUIRE(poller.busy()); + REQUIRE(morph::ladder::testkit::pumpUntil([&] { return !poller.busy(); })); + CHECK(appliedIds == std::vector{1, 2, 3}); + CHECK(poller.lastEventId() == 3); + CHECK_FALSE(fatal); + CHECK(poller.running()); + + // A second tick with nothing new applies nothing and leaves the cursor + // exactly where it was. + poller.pollOnce(); + REQUIRE(morph::ladder::testkit::pumpUntil([&] { return !poller.busy(); })); + CHECK(appliedIds == std::vector{1, 2, 3}); + CHECK(poller.lastEventId() == 3); +} + +TEST_CASE("EventPoller survives a ClientTimeoutError -- retries on the next tick, does not stop", + "[gui][event-poller]") { + resetFeedControl(); + FeedModel::control.events = {{.id = 1, .summary = "a"}}; + FeedModel::control.blockFirstCall = true; + + morph::ladder::gui::AppContext ctx{morph::ladder::gui::Local{}}; + auto handler = std::make_shared>(ctx.bridge(), ctx.executor()); + + std::vector appliedIds; + bool fatal = false; + // A short executeDeadline keeps this test fast; the interval stays an + // hour so only pollOnce() drives ticks. + Poller poller{ + ctx.bridge(), /*startingCursor=*/0, + makeDispatch(handler), [&](const FeedEvent& event) { appliedIds.push_back(event.id); }, + [&](const QString&) { fatal = true; }, std::chrono::hours{1}, std::chrono::milliseconds{100}}; + + poller.pollOnce(); + REQUIRE(poller.busy()); + // The dispatched call is blocked inside FeedModel::execute() on a + // worker thread; Bridge's own TimeoutScheduler (armed by EventPoller's + // constructor) resolves the Completion with ClientTimeoutError on its + // own, independent of that block, once executeDeadline elapses. + REQUIRE(morph::ladder::testkit::pumpUntil([&] { return !poller.busy(); })); + CHECK_FALSE(fatal); + CHECK(poller.running()); // a timeout is not fatal -- still armed + CHECK(appliedIds.empty()); + CHECK(poller.lastEventId() == 0); // cursor did not advance + + // Unblock the first call's worker thread now, before this test ends -- + // otherwise ~ThreadPoolExecutor (via ~AppContext) would join a thread + // that never returns. + releaseBlockedCall(); + + // The next tick genuinely retries and this time succeeds. + poller.pollOnce(); + REQUIRE(morph::ladder::testkit::pumpUntil([&] { return !poller.busy(); })); + CHECK(appliedIds == std::vector{1}); + CHECK(poller.lastEventId() == 1); + CHECK_FALSE(fatal); +} + +TEST_CASE("EventPoller stops and reports onFatalError exactly once on a non-timeout failure (e.g. NotFound)", + "[gui][event-poller]") { + resetFeedControl(); + FeedModel::control.throwNotFound = true; + + morph::ladder::gui::AppContext ctx{morph::ladder::gui::Local{}}; + auto handler = std::make_shared>(ctx.bridge(), ctx.executor()); + + int fatalCount = 0; + QString lastMessage; + Poller poller{ + ctx.bridge(), /*startingCursor=*/0, makeDispatch(handler), + [](const FeedEvent&) { FAIL("onEvent must not run when the dispatch itself failed"); }, + [&](const QString& message) { + ++fatalCount; + lastMessage = message; + }, + std::chrono::hours{1}}; + + poller.pollOnce(); + REQUIRE(morph::ladder::testkit::pumpUntil([&] { return !poller.busy(); })); + CHECK(fatalCount == 1); + CHECK(poller.fatalErrorReported()); + CHECK_FALSE(poller.running()); + CHECK(lastMessage.toStdString().find("NotFound") != std::string::npos); + + // A further tick -- manual here, but equally a real timer tick, if the + // timer were still armed -- must not dispatch again and must not report + // onFatalError a second time: pollOnce() itself refuses once _fatal is + // set, and the timer is already stopped. + poller.pollOnce(); + CHECK_FALSE(poller.busy()); + CHECK(fatalCount == 1); +} + +TEST_CASE("EventPoller destroyed with a tick in flight suppresses the orphaned completion callback", + "[gui][event-poller]") { + // Regression test for the use-after-free EventPoller::_liveness fixes. + // + // The callbacks pollOnce() hands to Dispatch are delivered through + // QtExecutor::post -> QMetaObject::invokeMethod(..., Qt::QueuedConnection), + // so a pending one is an event owned by QCoreApplication -- NOT a + // connection owned by the poller's own _timer. Destroying the poller + // (the ordinary case of a user closing a poll view mid-tick) cancels + // nothing, and without the _liveness weak_ptr guard that queued callback + // fires into freed memory. Verified to catch the regression: with the + // two `alive.expired()` checks removed this test reports the applied + // event (and, under ASan, a heap-use-after-free). + resetFeedControl(); + FeedModel::control.events = {{.id = 1, .summary = "a"}}; + FeedModel::control.blockFirstCall = true; + + morph::ladder::gui::AppContext ctx{morph::ladder::gui::Local{}}; + auto handler = std::make_shared>(ctx.bridge(), ctx.executor()); + + // Both deliberately outlive the poller. `applied` is what a surviving + // (i.e. unsuppressed) callback would set; `completionDelivered` proves + // the completion genuinely did resolve after the poller died -- without + // it this test could "pass" by simply never delivering anything at all. + auto applied = std::make_shared>(false); + auto completionDelivered = std::make_shared>(false); + + Poller::Dispatch dispatch = [handler, completionDelivered](int lastEventId, Poller::OnSuccess onSuccess, + Poller::OnError onError) { + handler->execute(GetFeedSince{.lastEventId = lastEventId}) + .then([handler, lastEventId, onSuccess, completionDelivered](GetFeedSinceResult result) { + completionDelivered->store(true); + const int newLastEventId = result.events.empty() ? lastEventId : result.events.back().id; + onSuccess(std::move(result.events), newLastEventId); + }) + .onError([handler, onError, completionDelivered](std::exception_ptr err) { + completionDelivered->store(true); + onError(std::move(err)); + }); + }; + + // An hour-long executeDeadline as well as an hour-long interval: neither + // the timer nor Bridge's TimeoutScheduler may resolve this tick on its + // own -- the test controls exactly when the dispatch completes. + auto poller = std::make_unique( + ctx.bridge(), /*startingCursor=*/0, dispatch, [applied](const FeedEvent&) { applied->store(true); }, + [](const QString&) { FAIL("onFatalError must not run after the poller is destroyed"); }, + std::chrono::hours{1}, std::chrono::hours{1}); + + poller->pollOnce(); + REQUIRE(poller->busy()); + + // Destroy while the dispatch is genuinely outstanding: the worker thread + // is still parked inside FeedModel::execute(). + poller.reset(); + + // Now let the model call return. The Completion resolves and posts the + // now-orphaned success callback as a queued Qt event. + releaseBlockedCall(); + REQUIRE(morph::ladder::testkit::pumpUntil([&] { return completionDelivered->load(); })); + // Keep pumping a while longer so any straggler queued event definitely + // gets its turn (never-true predicate == "pump for this long"). + static_cast(morph::ladder::testkit::pumpUntil([] { return false; }, std::chrono::milliseconds{50})); + + CHECK_FALSE(applied->load()); +} + +TEST_CASE("EventPoller advances its cursor before applying events and stays busy across the batch", + "[gui][event-poller]") { + // Regression test for the success-callback ordering fix. Previously + // _requestInFlight was cleared *before* the onEvent fan-out and + // _lastEventId advanced *after* it, so for the whole duration of the + // caller's callbacks busy() already read false (a reentrant pollOnce() + // -- e.g. from a modal dialog spinning a nested Qt event loop -- was not + // blocked) while the cursor still held its pre-batch value (so that + // reentrant tick refetched and reapplied the same events). + resetFeedControl(); + FeedModel::control.events = {{.id = 1, .summary = "a"}, {.id = 2, .summary = "b"}}; + + morph::ladder::gui::AppContext ctx{morph::ladder::gui::Local{}}; + auto handler = std::make_shared>(ctx.bridge(), ctx.executor()); + + Poller* pollerPtr = nullptr; + std::vector cursorInsideOnEvent; + std::vector busyInsideOnEvent; + std::vector appliedIds; + + Poller poller{ctx.bridge(), /*startingCursor=*/0, makeDispatch(handler), + [&](const FeedEvent& event) { + appliedIds.push_back(event.id); + cursorInsideOnEvent.push_back(pollerPtr->lastEventId()); + busyInsideOnEvent.push_back(pollerPtr->busy()); + // Simulated reentrancy: a nested event loop ticking the + // poller again from inside an event handler. Must be + // refused outright (see callCount below). + pollerPtr->pollOnce(); + }, + [](const QString&) { FAIL("no fatal error expected"); }, std::chrono::hours{1}}; + pollerPtr = &poller; + + poller.pollOnce(); + REQUIRE(morph::ladder::testkit::pumpUntil([&] { return !poller.busy(); })); + + CHECK(appliedIds == std::vector{1, 2}); + // The cursor is already at its post-batch value for *every* onEvent call, + // including the first -- not still 0. + CHECK(cursorInsideOnEvent == std::vector{2, 2}); + // And the poller still reports itself busy throughout, so the reentrant + // pollOnce() calls above were no-ops... + CHECK(busyInsideOnEvent == std::vector{true, true}); + // ...which the model's own call counter confirms: exactly one dispatch + // reached the backend, not three. + CHECK(FeedModel::control.callCount.load() == 1); + CHECK(poller.lastEventId() == 2); + CHECK_FALSE(poller.busy()); +} + +TEST_CASE("EventPoller clears its in-flight flag even when onEvent throws", "[gui][event-poller]") { + // The RAII half of the ordering fix: _requestInFlight is released by a + // scope guard, not a plain assignment, so a throwing onEvent cannot wedge + // busy() at true forever (the same hazard gui/presenter.hpp's + // Presenter::track() guards against). + resetFeedControl(); + FeedModel::control.events = {{.id = 1, .summary = "a"}}; + + morph::ladder::gui::AppContext ctx{morph::ladder::gui::Local{}}; + auto handler = std::make_shared>(ctx.bridge(), ctx.executor()); + + bool onEventThrew = false; + // A Dispatch that contains the throw rather than letting it escape into + // the executor (where QtExecutor would let it reach the Qt event loop and + // std::terminate) -- the point under test is EventPoller's own state + // after the throw, not the executor's throwing-callback policy. + Poller::Dispatch dispatch = [handler, &onEventThrew](int lastEventId, Poller::OnSuccess onSuccess, + Poller::OnError onError) { + handler->execute(GetFeedSince{.lastEventId = lastEventId}) + .then([handler, lastEventId, onSuccess, &onEventThrew](GetFeedSinceResult result) { + const int newLastEventId = result.events.empty() ? lastEventId : result.events.back().id; + try { + onSuccess(std::move(result.events), newLastEventId); + } catch (const std::runtime_error&) { + onEventThrew = true; + } + }) + .onError([handler, onError](std::exception_ptr err) { onError(std::move(err)); }); + }; + + Poller poller{ctx.bridge(), /*startingCursor=*/0, dispatch, + [](const FeedEvent&) -> void { throw std::runtime_error{"onEvent blew up"}; }, + [](const QString&) { FAIL("no fatal error expected"); }, std::chrono::hours{1}}; + + poller.pollOnce(); + REQUIRE(morph::ladder::testkit::pumpUntil([&] { return onEventThrew; })); + CHECK_FALSE(poller.busy()); + // The cursor still advanced -- it is written before the fan-out, so a + // throwing onEvent does not condemn the poller to redelivering the same + // batch on every subsequent tick. + CHECK(poller.lastEventId() == 1); + // ...and the poller genuinely accepts another tick. + poller.pollOnce(); + CHECK(poller.busy()); + REQUIRE(morph::ladder::testkit::pumpUntil([&] { return !poller.busy(); })); +} + +TEST_CASE("EventPoller::resume clears a fatal error and polls again from a new cursor", "[gui][event-poller]") { + resetFeedControl(); + FeedModel::control.throwNotFound = true; + + morph::ladder::gui::AppContext ctx{morph::ladder::gui::Local{}}; + auto handler = std::make_shared>(ctx.bridge(), ctx.executor()); + + int fatalCount = 0; + std::vector appliedIds; + Poller poller{ctx.bridge(), /*startingCursor=*/0, makeDispatch(handler), + [&](const FeedEvent& event) { appliedIds.push_back(event.id); }, + [&](const QString&) { ++fatalCount; }, std::chrono::hours{1}}; + + poller.pollOnce(); + REQUIRE(morph::ladder::testkit::pumpUntil([&] { return !poller.busy(); })); + REQUIRE(fatalCount == 1); + REQUIRE(poller.fatalErrorReported()); + REQUIRE_FALSE(poller.running()); + // Without resume(), this is terminal: pollOnce() refuses forever. + poller.pollOnce(); + REQUIRE_FALSE(poller.busy()); + + // The GUI's recovery: a full GetPollState-shaped resync hands back a + // fresh cursor, and polling continues incrementally from there. + FeedModel::control.throwNotFound = false; + FeedModel::control.events = {{.id = 1, .summary = "a"}, {.id = 2, .summary = "b"}, {.id = 3, .summary = "c"}}; + poller.resume(2); + + CHECK_FALSE(poller.fatalErrorReported()); + CHECK(poller.lastEventId() == 2); + CHECK(poller.running()); + + // And a tick genuinely dispatches again rather than silently refusing. + poller.pollOnce(); + REQUIRE(poller.busy()); + REQUIRE(morph::ladder::testkit::pumpUntil([&] { return !poller.busy(); })); + CHECK(appliedIds == std::vector{3}); // only what is after the new cursor + CHECK(poller.lastEventId() == 3); + CHECK(fatalCount == 1); +} diff --git a/examples/common/testkit/test_fault_proxy.cpp b/examples/common/testkit/test_fault_proxy.cpp new file mode 100644 index 00000000..79195394 --- /dev/null +++ b/examples/common/testkit/test_fault_proxy.cpp @@ -0,0 +1,390 @@ +// SPDX-License-Identifier: Apache-2.0 +#include + +#include "testkit/fault_proxy.hpp" +#include "testkit/pump.hpp" + +#include +#include +#include +#include +#include +#include +#include + +#include +#include + +#include +#include +#include +#include +#include +#include + +// Deliberately at namespace scope, not inside an anonymous namespace: glz's +// reflection (which BRIDGE_REGISTER_MODEL/BRIDGE_REGISTER_ACTION rely on to +// serialize these types across the wire) needs external linkage on the type — +// see glaze/reflection/get_name.hpp's `extern const T external`. +struct FaultProbeAdd { + int by = 0; +}; + +// A running total, not a pure function of the action: only an accumulator can +// distinguish "the reply was dropped on the way back" from "the request never +// reached the server at all" — a later call's total still carries the effect +// of the call whose reply went missing. +struct FaultProbeCounter { + int value = 0; + int execute(FaultProbeAdd action) { + value += action.by; + return value; + } +}; + +BRIDGE_REGISTER_MODEL(FaultProbeCounter, "FaultProbeCounter") +BRIDGE_REGISTER_ACTION(FaultProbeCounter, FaultProbeAdd, "FaultProbeAdd") + +namespace { + +using namespace std::chrono_literals; + +/// @brief `RemoteServer` -> `QtWebSocketServer` -> `FaultProxy` -> +/// `QtWebSocketBackend` -> `Bridge`, wired in that order and torn down +/// in reverse. +/// +/// Reconnect is disabled on the client: it isolates every assertion below from +/// an automatic re-dial racing them (the `killAfter` case especially, which +/// asserts on the disconnect the client observes). +struct ProxyRig { + ::morph::exec::ThreadPoolExecutor serverPool{2}; + std::shared_ptr<::morph::backend::RemoteServer> server; + std::unique_ptr<::morph::qt::QtWebSocketServer> wsServer; + std::unique_ptr<::morph::ladder::testkit::FaultProxy> proxy; + ::morph::qt::QtExecutor qtExec; + ::morph::qt::QtWebSocketBackend* backend{nullptr}; + std::unique_ptr<::morph::bridge::Bridge> bridge; + + ProxyRig() { + server = std::make_shared<::morph::backend::RemoteServer>(serverPool); + wsServer = std::make_unique<::morph::qt::QtWebSocketServer>(*server, quint16{0}); + if (!wsServer->listen()) { + throw std::runtime_error("ProxyRig: QtWebSocketServer failed to listen"); + } + + proxy = std::make_unique<::morph::ladder::testkit::FaultProxy>( + QUrl{QString("ws://127.0.0.1:%1").arg(wsServer->port())}); + const QUrl proxyUrl = proxy->start(); + + auto backendPtr = std::make_unique<::morph::qt::QtWebSocketBackend>( + proxyUrl, std::nullopt, ::morph::qt::QtWebSocketBackend::Config{.reconnectEnabled = false}); + backend = backendPtr.get(); + if (!backendPtr->waitForConnected()) { + throw std::runtime_error("ProxyRig: client failed to connect through the proxy"); + } + bridge = std::make_unique<::morph::bridge::Bridge>(std::move(backendPtr)); + } + + ProxyRig(const ProxyRig&) = delete; + ProxyRig& operator=(const ProxyRig&) = delete; + ProxyRig(ProxyRig&&) = delete; + ProxyRig& operator=(ProxyRig&&) = delete; + + ~ProxyRig() { + bridge.reset(); + backend = nullptr; + proxy.reset(); + if (wsServer) { + wsServer->closeGracefully(2000ms); + } + } +}; + +} // namespace + +TEST_CASE("FaultProxy relays an unfaulted call unchanged", "[ladder][testkit][fault-proxy]") { + ProxyRig rig; + ::morph::bridge::BridgeHandler handler{*rig.bridge, &rig.qtExec}; + + CHECK(::morph::ladder::testkit::awaitQt(handler.execute(FaultProbeAdd{2})) == 2); + CHECK(::morph::ladder::testkit::awaitQt(handler.execute(FaultProbeAdd{3})) == 5); +} + +TEST_CASE("FaultProxy::dropReply loses exactly the reply frame of the targeted call", + "[ladder][testkit][fault-proxy]") { + // Declared above the rig deliberately, and it matters here more than + // anywhere else in this file: this test leaves call 2's `Completion` + // unsettled at scope exit *on purpose*. `~ProxyRig` then tears the backend + // down, which calls `cancelPending(DisconnectedError)`; that posts the + // `.onError` below through `QtExecutor`, and `~QtWebSocketBackend`'s own + // `processEvents()` dispatches it a few lines later. Locals declared after + // the rig are destroyed *before* it (reverse declaration order), so the + // callback would write into dead stack slots. Anything a lambda outliving + // the rig captures by reference therefore lives up here — the request + // observer's counters included, since the proxy owns that lambda until + // `~ProxyRig` destroys it. + int requestsSeen = 0; + std::uint64_t targetedCallId = 0; + bool secondResolved = false; + bool secondFailed = false; + + ProxyRig rig; + ::morph::bridge::BridgeHandler handler{*rig.bridge, &rig.qtExec}; + + // The callId of an upcoming execute() is not knowable from here — + // BridgeHandler::execute() hands back a bare Completion and never names the + // id the backend assigned it. setRequestObserver supplies it at the one + // moment where arming a rule for it is still race-free: the request is + // sitting in the proxy, not yet forwarded upstream. + rig.proxy->setRequestObserver([&](std::uint64_t callId, ::morph::ladder::testkit::FaultProxy& self) { + if (++requestsSeen == 2) { + targetedCallId = callId; + self.dropReply(callId); // exactly call k = 2, nothing else + } + }); + + // Call 1 — unfaulted, must resolve. + CHECK(::morph::ladder::testkit::awaitQt(handler.execute(FaultProbeAdd{1})) == 1); + + // Call 2 — its reply is the one dropped. + const std::uint64_t forwardedBefore = rig.proxy->repliesForwarded(); + handler.execute(FaultProbeAdd{10}) + .then([&](int) { secondResolved = true; }) + .onError([&](const std::exception_ptr&) { secondFailed = true; }); + + // Call 3 — unfaulted, must resolve. Its running total is the load-bearing + // assertion: 1 + 10 + 100 only comes out if call 2 genuinely reached the + // server and committed its effect there, so this distinguishes "the reply + // was dropped" from "the request was never sent". It equally rules out a + // proxy that drops everything — a blanket drop would hang this await. + CHECK(::morph::ladder::testkit::awaitQt(handler.execute(FaultProbeAdd{100})) == 111); + + CHECK(requestsSeen == 3); + CHECK(targetedCallId != 0); + // Exactly one reply frame crossed to the client over those two calls — + // call 3's. Call 2's was swallowed, and nothing else was. + CHECK(rig.proxy->repliesForwarded() - forwardedBefore == 1); + + // And call 2 stays unsettled: neither resolved nor failed. (Pumping here + // has already happened for call 3's round trip, so this is a second, + // explicit budget on top of that.) + CHECK_FALSE(::morph::ladder::testkit::pumpUntil([&] { return secondResolved || secondFailed; }, 500ms)); + CHECK_FALSE(secondResolved); + CHECK_FALSE(secondFailed); +} + +TEST_CASE("FaultProxy::delayReply holds exactly the targeted call's reply, which still arrives", + "[ladder][testkit][fault-proxy]") { + // Above the rig, for the reason spelled out in the dropReply case: every + // one of these is captured by reference into a lambda the rig outlives. + // Both completions do settle before this test returns — but only if its + // REQUIREs hold, and a failing REQUIRE unwinds the scope with a completion + // still pending, which is exactly the case that must not become UB. + constexpr auto kDelay = 600ms; + int requestsSeen = 0; + bool delayedResolved = false; + bool promptResolved = false; + + ProxyRig rig; + ::morph::bridge::BridgeHandler handler{*rig.bridge, &rig.qtExec}; + + rig.proxy->setRequestObserver([&](std::uint64_t callId, ::morph::ladder::testkit::FaultProxy& self) { + if (++requestsSeen == 1) { + self.delayReply(callId, kDelay); + } + }); + + const auto issuedAt = std::chrono::steady_clock::now(); + handler.execute(FaultProbeAdd{1}).then([&](int) { delayedResolved = true; }); + handler.execute(FaultProbeAdd{2}).then([&](int) { promptResolved = true; }); + + // The *second* call is untouched and comes back on its own schedule, while + // the first is still parked in the proxy — that ordering is what makes this + // "exactly call k is delayed" rather than "the link is slow". + REQUIRE(::morph::ladder::testkit::pumpUntil([&] { return promptResolved; })); + CHECK_FALSE(delayedResolved); + + // The held reply is delayed, not lost: it does arrive, and only after the + // scripted delay has elapsed. + REQUIRE(::morph::ladder::testkit::pumpUntil([&] { return delayedResolved; })); + const auto elapsed = std::chrono::steady_clock::now() - issuedAt; + CHECK(elapsed >= kDelay - 50ms); + + // Both calls' effects are on the server exactly once. + CHECK(::morph::ladder::testkit::awaitQt(handler.execute(FaultProbeAdd{0})) == 3); +} + +TEST_CASE("FaultProxy::duplicateReply delivers the reply twice on the wire but resolves the Completion once", + "[ladder][testkit][fault-proxy]") { + // Above the rig — see the dropReply case. + int requestsSeen = 0; + int thenCount = 0; + int observedValue = 0; + + ProxyRig rig; + ::morph::bridge::BridgeHandler handler{*rig.bridge, &rig.qtExec}; + + rig.proxy->setRequestObserver([&](std::uint64_t callId, ::morph::ladder::testkit::FaultProxy& self) { + if (++requestsSeen == 1) { + self.duplicateReply(callId); + } + }); + + const std::uint64_t forwardedBefore = rig.proxy->repliesForwarded(); + handler.execute(FaultProbeAdd{5}).then([&](int value) { + ++thenCount; + observedValue = value; + }); + + REQUIRE(::morph::ladder::testkit::pumpUntil([&] { return thenCount >= 1; })); + CHECK(observedValue == 5); + + // The duplicate really did go out on the wire — without this the + // single-invocation assertion below would pass just as happily against a + // proxy that quietly forwarded one copy. + REQUIRE(::morph::ladder::testkit::pumpUntil( + [&] { return rig.proxy->repliesForwarded() - forwardedBefore >= 2; })); + CHECK(rig.proxy->repliesForwarded() - forwardedBefore == 2); + + // The second copy of the reply must not re-fire the callback: + // QtWebSocketBackend erases the pending entry when the first copy lands, so + // the duplicate finds no match and is dropped. A `thenCount` of 2 here + // would be a framework finding, not a test bug. + CHECK_FALSE(::morph::ladder::testkit::pumpUntil([&] { return thenCount >= 2; }, 400ms)); + CHECK(thenCount == 1); + + // A duplicated *reply* is not a duplicated *execution*: the server ran the + // action once, so the running total is still 5. + CHECK(::morph::ladder::testkit::awaitQt(handler.execute(FaultProbeAdd{0})) == 5); +} + +TEST_CASE("FaultProxy: a second client connection replaces the first, still working end-to-end", + "[ladder][testkit][fault-proxy]") { + ProxyRig rig; + ::morph::bridge::BridgeHandler handler{*rig.bridge, &rig.qtExec}; + CHECK(::morph::ladder::testkit::awaitQt(handler.execute(FaultProbeAdd{1})) == 1); + + // A second backend connects to the same proxy URL while the first client + // socket is still live from the proxy's perspective — onClientConnection() + // must tear down the old leg and adopt the new one instead of crashing or + // silently keeping both. This is the shape a real reconnect after + // killAfter takes (a fresh connection replacing an aborted one); this test + // doesn't need killAfter to reach it, just two connections in sequence. + auto secondBackend = std::make_unique<::morph::qt::QtWebSocketBackend>( + rig.proxy->url(), std::nullopt, ::morph::qt::QtWebSocketBackend::Config{.reconnectEnabled = false}); + REQUIRE(secondBackend->waitForConnected()); + ::morph::bridge::Bridge secondBridge{std::move(secondBackend)}; + ::morph::bridge::BridgeHandler secondHandler{secondBridge, &rig.qtExec}; + + // The replacement leg genuinely relays end-to-end through the proxy. A + // fresh connection registers its own model instance server-side (models + // here are per-registration, not shared across connections unless + // registered that way), so this is 1 (0+1 on the new instance), not 2 — + // the point of this assertion is that the call resolves through the + // *new* leg at all, not that state carried over from the old one. + CHECK(::morph::ladder::testkit::awaitQt(secondHandler.execute(FaultProbeAdd{1})) == 1); +} + +TEST_CASE("FaultProxy: an undecodable client frame is forwarded unreported, not dropped or crashed on", + "[ladder][testkit][fault-proxy]") { + ProxyRig rig; + + bool observerCalled = false; + rig.proxy->setRequestObserver([&](std::uint64_t, ::morph::ladder::testkit::FaultProxy&) { observerCalled = true; }); + + // A raw socket, not a QtWebSocketBackend: the backend only ever emits + // well-formed wire::Envelopes, so reaching onClientTextMessage's + // undecodable-frame branch needs a client that can send genuine garbage. + QWebSocket raw; + QString reply; + bool gotReply = false; + QObject::connect(&raw, &QWebSocket::textMessageReceived, [&](const QString& msg) { + reply = msg; + gotReply = true; + }); + raw.open(rig.proxy->url()); + REQUIRE(::morph::ladder::testkit::pumpUntil([&] { return raw.state() == QAbstractSocket::ConnectedState; })); + + raw.sendTextMessage(QStringLiteral("not-json-and-not-a-wire-envelope")); + + // The garbage frame is still forwarded upstream (onClientTextMessage's + // undecodable branch only skips reporting it to the observer, per its own + // comment) — the real server replies with its own protocol-level error, + // proving the frame reached it rather than being silently swallowed here. + REQUIRE(::morph::ladder::testkit::pumpUntil([&] { return gotReply; })); + CHECK_FALSE(observerCalled); + + raw.close(); + REQUIRE(::morph::ladder::testkit::pumpUntil([&] { return raw.state() == QAbstractSocket::UnconnectedState; })); +} + +TEST_CASE("FaultProxy::killAfter drops the connection instead of the targeted reply, and the client sees it", + "[ladder][testkit][fault-proxy]") { + // Above the rig — see the dropReply case. `disconnected` especially: the + // backend owns the handler that writes it, and the backend is destroyed + // inside `~ProxyRig`. + std::atomic disconnected{false}; + int requestsSeen = 0; + bool resolved = false; + bool failed = false; + + ProxyRig rig; + ::morph::bridge::BridgeHandler handler{*rig.bridge, &rig.qtExec}; + + // Observed on the *client*, through QtWebSocketBackend's own disconnect + // notification (the [issue29] pattern in tests/qt/test_qt_websocket.cpp) — + // not by inspecting the proxy's or the server's side of the socket. + rig.backend->setDisconnectHandler([&] { disconnected.store(true); }); + + rig.proxy->setRequestObserver([&](std::uint64_t callId, ::morph::ladder::testkit::FaultProxy& self) { + if (++requestsSeen == 1) { + self.killAfter(callId); + } + }); + + const std::uint64_t forwardedBefore = rig.proxy->repliesForwarded(); + handler.execute(FaultProbeAdd{1}) + .then([&](int) { resolved = true; }) + .onError([&](const std::exception_ptr&) { failed = true; }); + + REQUIRE(::morph::ladder::testkit::pumpUntil([&] { return disconnected.load(); })); + CHECK(requestsSeen == 1); + // The connection died *instead of* the reply being forwarded. + CHECK(rig.proxy->repliesForwarded() == forwardedBefore); + + // The reply died with the connection: the call fails rather than resolving. + REQUIRE(::morph::ladder::testkit::pumpUntil([&] { return failed; })); + CHECK_FALSE(resolved); +} + +// Forcing a real listen() failure or a genuinely-null nextPendingConnection() +// deterministically isn't practically achievable without flakiness or a +// test-only seam on Qt's own socket classes — the decision logic that would +// run in either case is factored into these two plain functions instead, so +// it's what gets tested. See their doc comments in fault_proxy.hpp. +TEST_CASE("FaultProxy's throwIfListenFailed throws exactly when its argument is false", + "[ladder][testkit][fault-proxy]") { + REQUIRE_THROWS_AS(::morph::ladder::testkit::detail::throwIfListenFailed(false), std::runtime_error); + REQUIRE_NOTHROW(::morph::ladder::testkit::detail::throwIfListenFailed(true)); +} + +TEST_CASE("isValidIncomingConnection rejects null, accepts non-null", "[ladder][testkit][fault-proxy]") { + REQUIRE_FALSE(::morph::ladder::testkit::detail::isValidIncomingConnection(nullptr)); + + QWebSocket socket; + REQUIRE(::morph::ladder::testkit::detail::isValidIncomingConnection(&socket)); +} + +// A real trusted upstream server never emits an undecodable reply, so +// onUpstreamTextMessage's catch branch is otherwise unreachable from an +// integration test — decodeCallIdOrZero is what's tested directly instead. +// See its doc comment in fault_proxy.hpp. +TEST_CASE("decodeCallIdOrZero round-trips a valid envelope's callId, and is 0 for garbage", + "[ladder][testkit][fault-proxy]") { + const QString validReply = + QString::fromStdString(::morph::wire::encode(::morph::wire::makeOk(/*callId=*/7))); + CHECK(::morph::ladder::testkit::detail::decodeCallIdOrZero(validReply) == 7); + + CHECK(::morph::ladder::testkit::detail::decodeCallIdOrZero( + QStringLiteral("not-json-and-not-a-wire-envelope")) == 0); +} diff --git a/examples/common/testkit/test_presenter.cpp b/examples/common/testkit/test_presenter.cpp new file mode 100644 index 00000000..523bd44c --- /dev/null +++ b/examples/common/testkit/test_presenter.cpp @@ -0,0 +1,305 @@ +// SPDX-License-Identifier: Apache-2.0 +#include + +#include "gui/app_context.hpp" +#include "gui/presenter.hpp" +#include "testkit/backend_rig.hpp" +#include "testkit/pump.hpp" + +#include + +#include + +// Deliberately at namespace scope, not inside an anonymous namespace: glz's +// reflection (which BRIDGE_REGISTER_MODEL/BRIDGE_REGISTER_ACTION rely on to +// serialize these types across the wire) needs external linkage on the type — +// see testkit/test_backend_rig.cpp's RigProbeModel for the same pattern. +// The registration macros must also appear before ProbePresenter below: its +// inline bump() calls BridgeHandler::execute< +// PresenterProbeAction>(), which needs morph::model::ActionTraits< +// PresenterProbeAction> already specialised at that point (an ordinary +// member function's body is compiled in place, not deferred to end of TU). +struct PresenterProbeAction { + int value = 0; +}; +struct PresenterProbeModel { + int execute(PresenterProbeAction action) { return action.value + 1; } +}; + +// A second action whose model deliberately throws, so a test can drive +// track()'s .onError path (finishOne() called from the error branch, never +// exercised by the plain success-path test above). +struct PresenterProbeFailAction {}; +struct PresenterProbeFailModel { + int execute(PresenterProbeFailAction) { throw std::runtime_error{"presenter probe: deliberate failure"}; } +}; + +BRIDGE_REGISTER_MODEL(PresenterProbeModel, "PresenterProbeModel") +BRIDGE_REGISTER_ACTION(PresenterProbeModel, PresenterProbeAction, "PresenterProbeAction") +BRIDGE_REGISTER_MODEL(PresenterProbeFailModel, "PresenterProbeFailModel") +BRIDGE_REGISTER_ACTION(PresenterProbeFailModel, PresenterProbeFailAction, "PresenterProbeFailAction") + +namespace { + +class ProbePresenter : public morph::ladder::gui::Presenter { + public: + ProbePresenter(morph::bridge::Bridge& bridge, morph::exec::IExecutor* exec) + : _handler{bridge, exec}, _failHandler{bridge, exec} {} + + /// @brief Calls trackBound() on the base class -- the doc-commented + /// usage pattern (presenter.hpp) that no other test in this file + /// exercises: every other ProbePresenter test constructs the + /// presenter and never touches bound()/trackBound() at all. + void hookBound() { trackBound(_handler.whenBound()); } + + void bump(int value) { + track(_handler.execute(PresenterProbeAction{value}), [this](int result) { lastResult = result; }); + } + + /// @brief Drives the model that always throws, so track()'s .onError + /// branch (and therefore finishOne() called from there) actually + /// runs — the plain success path above never reaches it. + void bumpAndFail() { + track(_failHandler.execute(PresenterProbeFailAction{}), [](int) { + FAIL("onOk must not run for a failed action"); + }); + } + + /// @brief Drives the (successful) probe action, but with an onOk callback + /// that itself throws — track()'s catch-block must still call + /// finishOne() before rethrowing (presenter.hpp's documented + /// exception-safety contract), or busy() would stay true forever. + void bumpAndThrowFromOnOk() { + track(_handler.execute(PresenterProbeAction{0}), + [](int) -> void { throw std::runtime_error{"presenter probe: onOk threw"}; }); + } + + /// @brief Drives the model that always throws, using the three-argument + /// track(onOk, onErr) overload so a test can assert the onErr + /// callback itself actually fires. Regression coverage for + /// docs/findings/023: bumpAndFail() above only exercises the + /// two-argument form, which busy()/idle() alone cannot + /// distinguish from the pre-fix bug (the surviving handler in + /// both cases is track()'s own, so the counter always cleared + /// correctly — the bug was invisible to that assertion). This + /// method exercises the new third parameter directly, which is + /// what the fix in presenter.hpp actually added. + void bumpAndFailWithHandler() { + track( + _failHandler.execute(PresenterProbeFailAction{}), + [](int) { FAIL("onOk must not run for a failed action"); }, + [this](const std::exception_ptr&) { errorHandlerFired = true; }); + } + + /// @brief Drives the model that always throws, with an `onErr` callback + /// that itself throws — the mirror of `bumpAndThrowFromOnOk()` for + /// `track()`'s *error* branch. That branch has its own + /// `catch (...) { finishOne(); throw; }`, and it is the one a real + /// presenter is most likely to trip: `onErr` is where a subclass + /// renders the failure, and rendering is exactly the kind of code + /// that throws. + void bumpAndThrowFromOnErr() { + track( + _failHandler.execute(PresenterProbeFailAction{}), + [](int) { FAIL("onOk must not run for a failed action"); }, + [](const std::exception_ptr&) -> void { throw std::runtime_error{"presenter probe: onErr threw"}; }); + } + + int lastResult = -1; + bool errorHandlerFired = false; + + private: + morph::bridge::BridgeHandler _handler; + morph::bridge::BridgeHandler _failHandler; +}; + +} // namespace + +TEST_CASE("Presenter::busy() is true while an action is in flight and false once it settles", + "[ladder][testkit][gui][presenter]") { + morph::ladder::gui::AppContext ctx{morph::ladder::gui::Local{}}; + ProbePresenter presenter{ctx.bridge(), ctx.executor()}; + + REQUIRE_FALSE(presenter.busy()); + presenter.bump(41); + REQUIRE(morph::ladder::testkit::settle(presenter)); + REQUIRE_FALSE(presenter.busy()); + REQUIRE(presenter.lastResult == 42); +} + +TEST_CASE("Presenter::track() calls finishOne() on the error path, not just success", + "[ladder][testkit][gui][presenter]") { + morph::ladder::gui::AppContext ctx{morph::ladder::gui::Local{}}; + ProbePresenter presenter{ctx.bridge(), ctx.executor()}; + + REQUIRE_FALSE(presenter.busy()); + presenter.bumpAndFail(); + REQUIRE(presenter.busy()); + REQUIRE(morph::ladder::testkit::settle(presenter)); + REQUIRE_FALSE(presenter.busy()); // .onError's finishOne() ran — the counter didn't leak +} + +TEST_CASE("Presenter::track() calls finishOne() even when onOk itself throws", + "[ladder][testkit][gui][presenter]") { + morph::ladder::gui::AppContext ctx{morph::ladder::gui::Local{}}; + ProbePresenter presenter{ctx.bridge(), ctx.executor()}; + + REQUIRE_FALSE(presenter.busy()); + presenter.bumpAndThrowFromOnOk(); + // track()'s .then() rethrows after finishOne() (presenter.hpp's own + // catch-block), but Completion's executor composes every attached + // .then() handler and itself catches (and logs) a throwing one rather + // than letting it escape to pumpUntil's caller (docs/spec/core/completion.md, + // "Handler fan-out") — so this only observes the counter, not the throw + // itself. + REQUIRE(morph::ladder::testkit::pumpUntil([&] { return !presenter.busy(); })); + // finishOne() ran before the exception was swallowed: busy() is false, + // not leaked. + REQUIRE_FALSE(presenter.busy()); +} + +TEST_CASE("Presenter::track()'s three-argument overload invokes onErr on the error path", + "[ladder][testkit][gui][presenter]") { + // Regression test for docs/findings/023 (Completion::onError() is + // single-slot: a second .onError() attach silently discards the first). + // The test case above ("...calls finishOne() on the error path...") only + // asserts busy()/idle() — that assertion passed even with the pre-fix + // bug present, since the surviving .onError() handler was always + // track()'s own. This test instead asserts the onErr callback supplied + // as track()'s third argument actually runs — the thing the bug would + // have silently discarded. + morph::ladder::gui::AppContext ctx{morph::ladder::gui::Local{}}; + ProbePresenter presenter{ctx.bridge(), ctx.executor()}; + + REQUIRE_FALSE(presenter.errorHandlerFired); + presenter.bumpAndFailWithHandler(); + REQUIRE(morph::ladder::testkit::settle(presenter)); + REQUIRE(presenter.errorHandlerFired); + REQUIRE_FALSE(presenter.busy()); // both onErr and finishOne() ran +} + +TEST_CASE("Presenter::track() calls finishOne() even when onErr itself throws", + "[ladder][testkit][gui][presenter]") { + // The `.onError` branch's half of the exception-safety contract the + // "...even when onOk itself throws" case above pins for `.then`. Same + // mechanism (finishOne() runs from the catch-block before the rethrow), + // but Completion's executor composes every attached .onError() handler + // and itself catches (and logs) a throwing one rather than letting it + // escape to pumpUntil's caller (docs/spec/core/completion.md, "Handler + // fan-out") — the same reason the ".then" mirror test above no longer + // expects a throw either. What is still at stake: if `finishOne()` did + // not run before the rethrow, `_inFlight` would never return to zero, + // `busy()` would stay true forever, and every later `settle()` in the + // process would burn its full deadline before failing with no useful + // diagnostic. + morph::ladder::gui::AppContext ctx{morph::ladder::gui::Local{}}; + ProbePresenter presenter{ctx.bridge(), ctx.executor()}; + + REQUIRE_FALSE(presenter.busy()); + presenter.bumpAndThrowFromOnErr(); + REQUIRE(morph::ladder::testkit::pumpUntil([&] { return !presenter.busy(); })); + REQUIRE_FALSE(presenter.busy()); +} + +TEST_CASE("AppContext{Local} is ready on construction and runs onReady inline", + "[ladder][testkit][gui][app-context]") { + morph::ladder::gui::AppContext ctx{morph::ladder::gui::Local{}}; + + // No transport to wait for, so no deferral: a Local context is usable the + // line after its constructor returns, as every existing caller assumes. + REQUIRE(ctx.ready()); + + bool fired = false; + ctx.onReady([&] { fired = true; }); + REQUIRE(fired); // synchronous — nothing pumped the event loop in between +} + +TEST_CASE("AppContext::onReady(nullptr) is a no-op, not a crash", "[ladder][testkit][gui][app-context]") { + morph::ladder::gui::AppContext ctx{morph::ladder::gui::Local{}}; + ctx.onReady(nullptr); // must simply do nothing — no callback to run or queue + SUCCEED("onReady(nullptr) returned without invoking or storing anything"); +} + +TEST_CASE("AppContext::login() sets the bridge's default session principal", "[ladder][testkit][gui][app-context]") { + morph::ladder::gui::AppContext ctx{morph::ladder::gui::Local{}}; + ctx.login("alice"); + // login() forwards to Bridge::setDefaultSession — observable indirectly + // via the same bridge a handler built against this context would use; + // the model itself doesn't read the principal here, so this asserts the + // call completes without throwing rather than a specific session::current() + // read, which needs a live dispatch to observe. + ProbePresenter presenter{ctx.bridge(), ctx.executor()}; + presenter.bump(1); + REQUIRE(morph::ladder::testkit::settle(presenter)); + REQUIRE(presenter.lastResult == 2); +} + +TEST_CASE("AppContext{Remote} defers readiness to the first connect", + "[ladder][testkit][gui][app-context][socket-only]") { + // A server with no clients of its own — the AppContext below is the client. + morph::ladder::testkit::BackendRig rig{morph::ladder::testkit::Mode::Socket, /*nClients=*/0}; + morph::ladder::gui::AppContext ctx{morph::ladder::gui::Remote{rig.url()}}; + + // Not ready the line after construction: QWebSocket::open() is + // asynchronous and no event-loop turn has run yet. A BridgeHandler + // constructed here would queue its registration and retry once the + // socket connects (registerModelAsync's queueing, docs/spec/core/ + // backend.md), rather than failing -- but ctx.ready() still reflects + // socket-connect timing, not registration settlement, so it is false + // regardless. + REQUIRE_FALSE(ctx.ready()); + + int fired = 0; + ctx.onReady([&] { ++fired; }); + REQUIRE(fired == 0); // queued, not run + + REQUIRE(morph::ladder::testkit::pumpUntil([&] { return ctx.ready(); })); + REQUIRE(fired == 1); + + // Registered after readiness: runs inline, exactly like Local mode. + bool late = false; + ctx.onReady([&] { late = true; }); + REQUIRE(late); + REQUIRE(fired == 1); // the first callback is not re-run +} + +TEST_CASE("Presenter::trackBound() emits bound() exactly once, synchronously in Local mode", + "[ladder][testkit][gui][presenter]") { + // Local mode's handler is already bound by construction (presenter.hpp's + // own doc comment on bound()), so whenBound()'s Completion settles + // on the very first event-loop turn -- pumpUntil, not a bare assertion, + // since "posted, not delivered inline" (trackBound()'s own comment on why + // it uses QPointer) still applies even when the outcome is a foregone + // conclusion. + morph::ladder::gui::AppContext ctx{morph::ladder::gui::Local{}}; + ProbePresenter presenter{ctx.bridge(), ctx.executor()}; + + int boundCount = 0; + QObject::connect(&presenter, &morph::ladder::gui::Presenter::bound, [&] { ++boundCount; }); + + presenter.hookBound(); + REQUIRE(morph::ladder::testkit::pumpUntil([&] { return boundCount == 1; })); + REQUIRE(boundCount == 1); +} + +TEST_CASE("Presenter::trackBound() still emits bound() when the presenter is destroyed first", + "[ladder][testkit][gui][presenter]") { + // trackBound()'s QPointer guard exists for exactly this case: + // whenBound()'s Completion resolves through the executor (posted, not + // inline), so a short-lived presenter destroyed before that post runs + // must not have its .then()/.onError() handlers dereference freed + // memory. This constructs the presenter inside a nested scope, destroys + // it immediately, then pumps -- if the QPointer guard were missing or + // wrong, this would be a use-after-free (caught by ASan/UBSan CI legs, + // not just a logic assertion here). + morph::ladder::gui::AppContext ctx{morph::ladder::gui::Local{}}; + { + ProbePresenter presenter{ctx.bridge(), ctx.executor()}; + presenter.hookBound(); + } // presenter destroyed here, whenBound()'s completion still pending + + // Nothing to assert beyond "this doesn't crash" -- pump a couple of turns + // so the posted completion actually runs while the presenter is gone. + REQUIRE_FALSE(morph::ladder::testkit::pumpUntil([] { return false; }, std::chrono::milliseconds{50})); + SUCCEED("posted whenBound() completion resolved after destruction without crashing"); +} diff --git a/examples/common/testkit/test_pump.cpp b/examples/common/testkit/test_pump.cpp new file mode 100644 index 00000000..73de1279 --- /dev/null +++ b/examples/common/testkit/test_pump.cpp @@ -0,0 +1,114 @@ +// SPDX-License-Identifier: Apache-2.0 +#include + +#include "testkit/pump.hpp" + +#include + +#include +#include + +#include + +TEST_CASE("pumpUntil returns true once the predicate flips", "[ladder][testkit][pump]") { + REQUIRE(QCoreApplication::instance() != nullptr); + bool flag = false; + QTimer::singleShot(20, [&] { flag = true; }); + REQUIRE(morph::ladder::testkit::pumpUntil([&] { return flag; }, std::chrono::milliseconds{500})); +} + +TEST_CASE("pumpUntil returns false on timeout without hanging", "[ladder][testkit][pump]") { + REQUIRE_FALSE(morph::ladder::testkit::pumpUntil([] { return false; }, std::chrono::milliseconds{50})); +} + +// deadlineScale() itself reads MORPH_LADDER_DEADLINE_MS behind a `static +// const` guard that runs exactly once per *process* — no test in this shared +// binary can ever be first to observe a particular env value, since some +// earlier test has always already forced the "unset" path. computeDeadlineScale +// takes the raw env value as a parameter instead, so it's directly testable +// without a process boundary — see pump.hpp's comment on it for the full +// rationale. +TEST_CASE("computeDeadlineScale is 1.0 when MORPH_LADDER_DEADLINE_MS is unset", "[ladder][testkit][pump]") { + REQUIRE(morph::ladder::testkit::detail::computeDeadlineScale(nullptr) == 1.0); +} + +TEST_CASE("computeDeadlineScale interprets its argument as a new 5000ms baseline", "[ladder][testkit][pump]") { + REQUIRE(morph::ladder::testkit::detail::computeDeadlineScale("2500") == 0.5); + REQUIRE(morph::ladder::testkit::detail::computeDeadlineScale("5000") == 1.0); + REQUIRE(morph::ladder::testkit::detail::computeDeadlineScale("10000") == 2.0); +} + +TEST_CASE("computeDeadlineScale is 1.0 for an unparseable value, not a crash", "[ladder][testkit][pump]") { + REQUIRE(morph::ladder::testkit::detail::computeDeadlineScale("not-a-number") == 1.0); + REQUIRE(morph::ladder::testkit::detail::computeDeadlineScale("") == 1.0); +} + +// morph::async::Completion is consumer-facing only (then()/onError()); it has +// no resolve()/fail() of its own. The producer side is Completion:: +// makeSettleable(execPtr) (issue #55's public "settleable promise" seam, +// docs/spec/core/completion.md), which returns a {Completion, Promise} +// pair sharing one state -- the Promise exposes resolve()/reject() without +// ever naming morph::async::detail::CompletionState. Here we use +// morph::qt::QtExecutor (already linked in via morph::qt) as the executor, +// since it delivers callbacks through the Qt event loop exactly as +// pumpUntil expects to pump them. + +TEST_CASE("awaitQt resolves a Completion and returns its value", "[ladder][testkit][pump]") { + morph::qt::QtExecutor executor; + auto [completion, promise] = morph::async::Completion::makeSettleable(&executor); + auto sharedPromise = std::make_shared::Promise>(std::move(promise)); + QTimer::singleShot(10, [sharedPromise] { sharedPromise->resolve(42); }); + REQUIRE(morph::ladder::testkit::awaitQt(std::move(completion)) == 42); +} + +TEST_CASE("awaitQt rethrows the completion's error", "[ladder][testkit][pump]") { + morph::qt::QtExecutor executor; + auto [completion, promise] = morph::async::Completion::makeSettleable(&executor); + auto sharedPromise = std::make_shared::Promise>(std::move(promise)); + QTimer::singleShot(10, [sharedPromise] { + try { + throw std::runtime_error("boom"); + } catch (...) { + sharedPromise->reject(std::current_exception()); + } + }); + REQUIRE_THROWS_AS(morph::ladder::testkit::awaitQt(std::move(completion)), std::runtime_error); +} + +// Regression test for a stack-use-after-scope bug: awaitQt's original +// implementation captured its `value`/`error` locals *by reference* in the +// then()/onError() handlers. Those handlers are stored on the completion's +// backing CompletionState, which can outlive awaitQt's stack frame — e.g. +// when awaitQt times out and throws while the underlying operation is still +// pending. Here `state` (the CompletionState) is kept alive by this test +// past the awaitQt call, exactly as an unrelated pending-call map elsewhere +// would keep it alive in production. Resolving it *after* awaitQt has +// already thrown and unwound exercises the late-callback path: with the old +// by-reference capture this write lands on destroyed stack memory (a +// stack-use-after-scope, reliably flagged by ASan even when it doesn't +// crash outright in a plain build); with the fix (heap state behind a +// shared_ptr captured by value) it lands on harmless, still-valid, orphaned +// heap memory. This test cannot assert on the corrupted value directly — +// its value is proving the process doesn't crash/corrupt under a sanitizer. +TEST_CASE("awaitQt timeout does not leave dangling references for a late-firing callback", "[ladder][testkit][pump]") { + morph::qt::QtExecutor executor; + auto [completion, promise] = morph::async::Completion::makeSettleable(&executor); + auto sharedPromise = std::make_shared::Promise>(std::move(promise)); + + // Nothing ever resolves this completion before the deadline, so awaitQt + // times out and throws while its then()/onError() handlers are still + // attached to the shared state behind `sharedPromise`. + REQUIRE_THROWS_AS(morph::ladder::testkit::awaitQt(std::move(completion), std::chrono::milliseconds{50}), + std::runtime_error); + + // awaitQt's frame is gone, but `sharedPromise` (held here, as a backend's + // pending-call map would hold the underlying state) is still alive and + // its paired Completion still holds the handlers awaitQt installed. + // Resolve it now and pump so the posted callback actually runs. + sharedPromise->resolve(42); + // Deliberately discarded: the predicate is `false` by construction, so + // this is "pump for 50ms", not a wait — the timeout *is* the point. + (void)morph::ladder::testkit::pumpUntil([] { return false; }, std::chrono::milliseconds{50}); + + SUCCEED("late resolution after awaitQt's timeout did not crash or corrupt memory"); +} diff --git a/examples/common/testkit/test_strand_interleaver.cpp b/examples/common/testkit/test_strand_interleaver.cpp new file mode 100644 index 00000000..8cc92785 --- /dev/null +++ b/examples/common/testkit/test_strand_interleaver.cpp @@ -0,0 +1,124 @@ +// SPDX-License-Identifier: Apache-2.0 +#include + +#include "testkit/strand_interleaver.hpp" + +#include + +#include +#include +#include + +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}; + + std::vector order; + morph::exec::detail::ModelId key{1}; + morph::exec::detail::ModelId otherKey{2}; + + strand.post(key, [&] { order.push_back(1); }); + strand.post(otherKey, [&] { order.push_back(100); }); + strand.post(key, [&] { order.push_back(2); }); + + REQUIRE(det.pending() >= 1); + + // Deliberately run the *other* key's task before the same-key pair's + // second entry, proving the interleaving is under this test's control + // rather than the underlying pool's scheduling. + while (det.pending() > 0) { + det.step(); + } + + // key's two tasks must have run in post order relative to each other + // (StrandExecutor'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. + auto posOf = [&](int value) { + return static_cast(std::find(order.begin(), order.end(), value) - order.begin()); + }; + REQUIRE(posOf(1) < posOf(2)); +} + +TEST_CASE("DeterministicExecutor::runSchedule executes queued tasks in the caller's chosen order", + "[ladder][testkit][strand-interleaver]") { + morph::ladder::testkit::DeterministicExecutor det; + std::vector order; + det.post([&] { order.push_back(1); }); + det.post([&] { order.push_back(2); }); + det.post([&] { order.push_back(3); }); + + // Indices are re-read after each erase, not fixed against the original + // queue: to run "3" (index 2) first, then "1" (index 0), then "2", the + // third index is 0 — not 1 — because once "3" and "1" are gone, "2" is + // the only element left and sits at index 0. + det.runSchedule({ 2, 0, 0 }); // run "3" first, then "1", then "2" + REQUIRE(order == std::vector{ 3, 1, 2 }); +} + +TEST_CASE("DeterministicExecutor::runSchedule forces a non-default interleaving across two StrandExecutor 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 + // that queue FIFO therefore already interleaves otherKey's task between + // key's two tasks, without any deliberate scripting. + // + // This test proves runSchedule can force a *different* order than that + // default: both of key's tasks back-to-back, with otherKey's task + // pushed out to run last — an order plain FIFO draining would never + // produce, and one that only works because runSchedule re-reads the + // 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). + morph::ladder::testkit::DeterministicExecutor det; + morph::exec::detail::StrandExecutor strand{det}; + + std::vector order; + morph::exec::detail::ModelId key{1}; + morph::exec::detail::ModelId otherKey{2}; + + strand.post(key, [&] { order.push_back(1); }); + 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. + 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 2: run index 1 — *not* index 0 — to run key's second-task + // dispatch (the one 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. + det.runSchedule({ 0, 1, 0 }); + + REQUIRE(order == std::vector{ 1, 2, 100 }); +} + +TEST_CASE("DeterministicExecutor::step throws when the queue is empty", "[ladder][testkit][strand-interleaver]") { + morph::ladder::testkit::DeterministicExecutor det; + REQUIRE(det.pending() == 0); + REQUIRE_THROWS_AS(det.step(), std::runtime_error); +} + +TEST_CASE("DeterministicExecutor::runSchedule throws on an out-of-range index", + "[ladder][testkit][strand-interleaver]") { + morph::ladder::testkit::DeterministicExecutor det; + det.post([] {}); + REQUIRE_THROWS_AS(det.runSchedule({ 1 }), std::runtime_error); +} diff --git a/examples/common/testkit/test_wasm_registration_path_native.cpp b/examples/common/testkit/test_wasm_registration_path_native.cpp new file mode 100644 index 00000000..8c1cda49 --- /dev/null +++ b/examples/common/testkit/test_wasm_registration_path_native.cpp @@ -0,0 +1,110 @@ +// SPDX-License-Identifier: Apache-2.0 +#include + +#include "testkit/pump.hpp" + +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include + +// Deliberately at namespace scope, not inside an anonymous namespace: glz's +// reflection (which BRIDGE_REGISTER_MODEL/BRIDGE_REGISTER_ACTION rely on to +// serialize these types across the wire) needs external linkage on the +// type — see glaze/reflection/get_name.hpp's `extern const T external`, and +// test_fault_proxy.cpp's/test_backend_rig.cpp's identical note. Distinctly +// named from wasm_spike/spike_model.hpp's SpikeEchoModel/SpikeEchoAction: +// this test target and main_wasm.cpp's registration would violate ODR if +// ever linked into the same process (wasm_spike/spike_model.hpp's own +// comment), so this test uses its own model instead of reusing that one. +struct WasmSpikeProbeAction { + int value = 0; +}; +struct WasmSpikeProbeModel { + int execute(WasmSpikeProbeAction action) { return action.value; } +}; + +BRIDGE_REGISTER_MODEL(WasmSpikeProbeModel, "WasmSpikeProbeModel") +BRIDGE_REGISTER_ACTION(WasmSpikeProbeModel, WasmSpikeProbeAction, "WasmSpikeProbeAction") + +// The brief's original draft for this test (and main_wasm.cpp's first draft) +// constructed a `BridgeHandler` unconditionally, immediately after +// constructing the Bridge -- before any Qt event-loop turn had a chance to +// run, so the QWebSocket was guaranteed to still be unconnected at that +// point. `QtWebSocketBackend::registerModelAsync()` now queues a +// pre-connect registration and retries it once the socket connects (see +// tests/qt/test_qt_websocket.cpp's "registerModelAsync called before the +// socket connects queues and retries once connected fires", +// docs/spec/core/backend.md's "Asynchronous registration") -- so this call +// sequence now resolves natively, with no need for the deferred-construction +// workaround the test below demonstrates (which remains a valid, +// simpler-still sequence, just no longer the only correct one). +// `BridgeHandler::whenBound()`/`isBound()` observe the same settlement +// `binding->currentId` used to be polled for directly, without this test +// ever naming +// `morph::bridge::detail::HandlerBinding`. +TEST_CASE("registerHandler() called immediately after Bridge construction, before any event-loop turn, resolves " + "once the socket connects -- see finding 017", + "[ladder][testkit][wasm-spike]") { + morph::exec::ThreadPoolExecutor serverPool{2}; + auto server = std::make_shared(serverPool); + morph::qt::QtWebSocketServer wsServer{*server, 0}; + REQUIRE(wsServer.listen()); + + QUrl url{QString("ws://127.0.0.1:%1").arg(wsServer.port())}; + auto backendPtr = std::make_unique( + url, std::nullopt, morph::qt::QtWebSocketBackend::Config{.asyncRegistrationEnabled = true}); + + morph::qt::QtExecutor qtExec; + morph::bridge::Bridge bridge{std::move(backendPtr)}; + + // Constructing the handler registers immediately -- before the socket is + // connected -- see finding 017. + morph::bridge::BridgeHandler handler{bridge, &qtExec}; + + REQUIRE(morph::ladder::testkit::pumpUntil([&] { return handler.isBound(); })); +} + +// The corrected, still fully WASM-safe sequence: defer constructing the +// `BridgeHandler` (whose constructor registers) until `setConnectHandler`'s +// callback has actually fired at least once -- no `waitForConnected()` +// (which would nest an event loop and abort a WASM page), just ordering the +// same non-blocking calls correctly. main_wasm.cpp uses this exact corrected +// sequence (see its file comment for the same explanation), including the +// `std::optional>` deferred-construction idiom, since a +// handler cannot be built before there is somewhere to register it into yet +// must still exist afterward to `execute()` against. +TEST_CASE("The WASM spike's registration call sequence resolves natively when registerHandler() is deferred to " + "setConnectHandler's callback (asyncRegistrationEnabled + setConnectHandler)", + "[ladder][testkit][wasm-spike]") { + morph::exec::ThreadPoolExecutor serverPool{2}; + auto server = std::make_shared(serverPool); + morph::qt::QtWebSocketServer wsServer{*server, 0}; + REQUIRE(wsServer.listen()); + + QUrl url{QString("ws://127.0.0.1:%1").arg(wsServer.port())}; + auto backendPtr = std::make_unique( + url, std::nullopt, morph::qt::QtWebSocketBackend::Config{.asyncRegistrationEnabled = true}); + auto* rawBackend = backendPtr.get(); // stays valid: bridge below co-owns the same object + + morph::qt::QtExecutor qtExec; + morph::bridge::Bridge bridge{std::move(backendPtr)}; + + std::optional> handler; + // Installed after Bridge takes ownership (via the raw pointer captured + // above) but before any event-loop turn runs, so it cannot miss the + // connect signal -- identical pattern to main_wasm.cpp. + rawBackend->setConnectHandler([&bridge, &qtExec, &handler] { handler.emplace(bridge, &qtExec); }); + + REQUIRE(morph::ladder::testkit::pumpUntil([&] { return handler.has_value() && handler->isBound(); })); + + auto result = morph::ladder::testkit::awaitQt(handler->execute(WasmSpikeProbeAction{99})); + REQUIRE(result == 99); +} diff --git a/examples/common/testkit/testkit_main.cpp b/examples/common/testkit/testkit_main.cpp new file mode 100644 index 00000000..7cc8b462 --- /dev/null +++ b/examples/common/testkit/testkit_main.cpp @@ -0,0 +1,36 @@ +// SPDX-License-Identifier: Apache-2.0 +// +// Qt-owning Catch2 main, copied from tests/qt/test_qt_websocket.cpp's pattern: +// the application object must outlive every QObject Catch2 constructs during +// the run and be destroyed before static teardown, or Qt's cleanup runs +// against a torn-down app (observed upstream as a heap-corruption abort on +// shutdown). +// +// MORPH_LADDER_TESTKIT_GUI_APP (defined by morph_add_rung() for a rung whose +// test binary carries the offscreen QML engine-load smoke test, and by nothing +// else) upgrades that object from QCoreApplication to QGuiApplication. +// QGuiApplication *is* a QCoreApplication, so every existing test behaves +// identically; what it adds is a platform integration, without which Qt Quick +// cannot instantiate a window at all. Left off, this file is byte-for-byte the +// plain QCoreApplication main ladder_common_tests has always used — which is +// what keeps examples/TESTING.md presenter rule 1 ("presenters must +// instantiate under a plain QCoreApplication") honestly exercised somewhere. + +#include +#include + +#ifdef MORPH_LADDER_TESTKIT_GUI_APP +#include +using LadderTestApplication = QGuiApplication; +#else +#include +using LadderTestApplication = QCoreApplication; +#endif + +int main(int argc, char* argv[]) { + LadderTestApplication app{argc, argv}; + int result = Catch::Session().run(argc, argv); + QCoreApplication::sendPostedEvents(nullptr, QEvent::DeferredDelete); + QCoreApplication::processEvents(QEventLoop::AllEvents); + return result; +} diff --git a/examples/common/wasm_spike/CMakeLists.txt b/examples/common/wasm_spike/CMakeLists.txt new file mode 100644 index 00000000..1893440f --- /dev/null +++ b/examples/common/wasm_spike/CMakeLists.txt @@ -0,0 +1,36 @@ +# SPDX-License-Identifier: Apache-2.0 +# +# WASM-remote spike (examples/LADDER.md rung 0): proves QtWebSocketBackend +# works from an Emscripten build, which examples/TESTING.md says has never +# been exercised before this. Only built in an Emscripten configure. +# +# main_wasm.cpp has no QML/Quick UI at all -- it is a plain QCoreApplication + +# QTimer + morph bridge console-style program that logs to qDebug(). Unlike +# bank's gui_wasm (which does have a QML UI and pulls in Qt6::Qml/Quick), this +# target only needs Qt6::Core plus whatever morph::qt itself requires -- which +# already pulls in Qt6::WebSockets via its own target_link_libraries (see +# ../../../CMakeLists.txt's morph_qt INTERFACE target). qt_add_executable (not +# plain add_executable) is still correct/needed here independent of the +# missing QML UI: it is what makes Emscripten's HTML/JS shell generation work +# for a Qt-for-WebAssembly target in general. +find_package(Qt6 REQUIRED COMPONENTS Core) +qt_standard_project_setup(REQUIRES 6.5) + +qt_add_executable(morph_ladder_wasm_spike main_wasm.cpp) +# morph::qt is header-only (INTERFACE); the compiled QtWebSocketBackend +# constructor/registerModelAsync/setConnectHandler bodies live in +# morph_qt_impl (see ../../../CMakeLists.txt's `add_library(morph_qt_impl +# STATIC ...)`). main_wasm.cpp constructs a QtWebSocketBackend directly, so +# without this the WASM link fails on undefined symbols -- every other real +# consumer in the repo (examples/common/CMakeLists.txt, tests/qt/CMakeLists.txt, +# tests/net_qt_interop/CMakeLists.txt) links both targets for the same reason. +target_link_libraries(morph_ladder_wasm_spike PRIVATE morph::morph morph::qt morph_qt_impl Qt6::Core) +target_compile_features(morph_ladder_wasm_spike PRIVATE cxx_std_23) + +if(NOT DEFINED MORPH_LADDER_WASM_SPIKE_SERVER_URL) + set(MORPH_LADDER_WASM_SPIKE_SERVER_URL "ws://127.0.0.1:9999" CACHE STRING + "URL the WASM spike client connects to; override to point at a real out-of-band server for the browser smoke test.") +endif() +target_compile_definitions(morph_ladder_wasm_spike PRIVATE + MORPH_LADDER_WASM_SPIKE_SERVER_URL="${MORPH_LADDER_WASM_SPIKE_SERVER_URL}" +) diff --git a/examples/common/wasm_spike/README.md b/examples/common/wasm_spike/README.md new file mode 100644 index 00000000..ba63f0a9 --- /dev/null +++ b/examples/common/wasm_spike/README.md @@ -0,0 +1,76 @@ +# WASM-remote spike + +Proves `morph::qt::QtWebSocketBackend` works from a WASM client — per +[`../../TESTING.md`](../../TESTING.md), "Bank's WASM build is local-only... a +WASM client over `QtWebSocketBackend` has never been run." This is a client +only; point it at a native `RemoteServer` + `QtWebSocketServer` hosting +`SpikeEchoModel` (see `spike_model.hpp`), started separately — for example +`ladder_common_tests`' own `[wasm-spike]`-tagged test case +(`../testkit/test_wasm_registration_path_native.cpp`) demonstrates the exact +registration/execute call sequence natively; a standalone server binary +hosting `SpikeEchoModel` for the browser smoke would be built the same way. + +## Environment note (as of this task, and still true) + +This spike's source (`spike_model.hpp`, `main_wasm.cpp`, this +`CMakeLists.txt`) was written and reviewed, but **no Emscripten toolchain +(`emcc`/`emcmake`) was available in the environment this was authored in**, so +the actual WASM compile gate below has never been run against it. The CMake +is written in good faith against `../../../CMakeLists.txt`'s existing +`MORPH_BUILD_QT` wiring and bank's `gui_wasm` as a template, but until it is +actually configured under `emcmake`, treat it as unverified. Rung 1's task 13 +hit the identical wall (`emcmake: command not found`) while writing pastebin's +WASM client, and added `.github/workflows/wasm-ladder.yml` — a compile gate +that builds *this* target by name alongside every rung's `gui_wasm` client. Its +first green run is what retires this note. In particular: +`morph::qt` (which this target links) only exists when the top-level +`MORPH_BUILD_QT=ON`, which itself runs `find_package(Qt6 COMPONENTS +WebSockets REQUIRED)` — whether a standard Qt-for-WebAssembly install +actually ships a working `Qt6::WebSockets` component is itself part of what +the first real `emcmake` attempt against this target needs to establish. + +## Manual verification + +1. Configure and build for `wasm32-emscripten` (see `../../bank/gui_wasm` for + the toolchain setup this mirrors). +2. Start a server hosting `SpikeEchoModel` on a known port. +3. Configure with `-DMORPH_LADDER_WASM_SPIKE_SERVER_URL=ws://127.0.0.1:`, + build `morph_ladder_wasm_spike`, serve the output over plain HTTP (no + COOP/COEP headers needed — this target avoids `-pthread`, same as bank's + WASM GUI). +4. Open the page, check the browser console for + `morph-ladder-wasm-spike: connected` followed by + `morph-ladder-wasm-spike: result= 99`. + +## Fallback plan, if step 4 does not show `result= 99` + +Per `TESTING.md`'s framework-gaps list and `LADDER.md`'s framework +prerequisites, the two most likely failure modes and their owning findings: + +- **Page aborts before "connected" logs.** Something in the registration path + still nests a synchronous event loop despite `asyncRegistrationEnabled = + true` — re-open finding `001` (async shared/keyed attach) even though this + spike deliberately avoids the *shared* path; if the *plain* async path also + aborts, that is a new, more severe finding (the plain path was supposed to + already be WASM-safe per `[issue26]`'s native tests) — file it as the next + available id in `docs/findings/` (017 as of this writing; check the + highest-numbered file currently present, per `CLAUDE.md`'s numbering rule) + with a name like `NNN-plain-async-registration-aborts-wasm.md`, + `severity: blocker`, and this rung's exit criteria (per + `examples/FINDINGS.md`) are **not met** until it is at least triaged. +- **"connected" logs but no "result=" ever appears.** The action dispatch + itself is hanging — check whether `Completion` needs finding `002`'s + execute-deadline fix to surface the failure at all (today it would just + hang silently, matching `002`'s description exactly). + +If either failure mode reproduces, do **not** silently work around it in this +spike — record it as a finding (per the two bullets above) and mark rung 0's +Task 10 complete anyway with a "documents a real blocker" note; `FINDINGS.md`'s +rung exit criteria explicitly allow a rung to exit with findings still +`open`/`fix-scheduled`, just not un-triaged. + +If the Emscripten configure itself fails before either failure mode above +becomes observable (for example, `find_package(Qt6 COMPONENTS WebSockets +REQUIRED)` failing under `emcmake`, per the environment note above), that is +also a real finding, not a CMake bug in this directory to quietly work +around — file it the same way, citing the specific configure error. diff --git a/examples/common/wasm_spike/main_wasm.cpp b/examples/common/wasm_spike/main_wasm.cpp new file mode 100644 index 00000000..24b0ce36 --- /dev/null +++ b/examples/common/wasm_spike/main_wasm.cpp @@ -0,0 +1,112 @@ +// SPDX-License-Identifier: Apache-2.0 +// +// WASM-remote spike: proves a WASM-compiled QtWebSocketBackend client can +// register a model and execute one action against a real remote server, +// using the two WASM-mandatory patterns documented in examples/TESTING.md, +// "WASM reality": asyncRegistrationEnabled=true (the plain synchronous +// registerModel aborts the page) and setConnectHandler (waitForConnected() +// hangs the page on WASM). +// +// This binary is the client half only — point MORPH_LADDER_WASM_SPIKE_SERVER_URL +// (baked in at build time via a CMake compile definition, since a browser +// page cannot read environment variables) at a real morph::qt::RemoteServer + +// QtWebSocketServer hosting SpikeEchoModel, started out-of-band (see this +// directory's README.md for how the nightly Playwright smoke wires that up). +// +// IMPORTANT ordering constraint discovered while building this spike: +// QtWebSocketBackend::registerModelAsync() now queues a registration issued +// before the socket has connected and retries it once the connection comes +// up (docs/spec/core/backend.md, "Asynchronous registration") -- but the +// *reconnect* handler Bridge installs only fires on a *subsequent* +// reconnect, never on the first connect, so this spike still defers to +// setConnectHandler rather than relying on the pre-connect queue. The +// registering call (here, constructing the BridgeHandler, whose constructor +// itself registers) is deferred to fire from inside the `setConnectHandler` +// callback instead, which is fully WASM-safe (no nested event loop) and +// simpler to reason about than the queue. + +#include "spike_model.hpp" + +#include +#include +#include +#include +#include +#include + +#include +#include +#include + +BRIDGE_REGISTER_MODEL(SpikeEchoModel, "SpikeEchoModel") +BRIDGE_REGISTER_ACTION(SpikeEchoModel, SpikeEchoAction, "SpikeEchoAction") + +int main(int argc, char* argv[]) { + QCoreApplication app{argc, argv}; + + QUrl url{QStringLiteral(MORPH_LADDER_WASM_SPIKE_SERVER_URL)}; + // QtWebSocketBackend's constructor has no `tls` parameter at all on an + // SSL-less Qt build (QT_NO_SSL) -- see its class doc comment's "SSL-less + // Qt builds" section. A WASM build is always QT_NO_SSL, so the 4th + // positional argument here is `cfg`, not `tls`; passing `std::nullopt` + // unconditionally (as if `tls` always existed) is a link-time-only bug + // that never surfaces on a native build, where `QT_NO_SSL` is unset -- + // mirrors backend_rig.hpp's identical split for QtWebSocketServer. +#ifdef QT_NO_SSL + auto backendPtr = std::make_unique( + url, morph::qt::QtWebSocketBackend::Config{.asyncRegistrationEnabled = true}); +#else + auto backendPtr = std::make_unique( + url, std::nullopt, morph::qt::QtWebSocketBackend::Config{.asyncRegistrationEnabled = true}); +#endif + auto* rawBackend = backendPtr.get(); // stays valid: Bridge below co-owns the same object + + morph::qt::QtExecutor qtExec; + morph::bridge::Bridge bridge{std::move(backendPtr)}; + + // Holds the one BridgeHandler this spike ever constructs. Must outlive + // the timer lambda below: a lambda-local BridgeHandler is destroyed the + // instant its enclosing lambda invocation returns, and ~BridgeHandler() + // deregisters the model (resetting its binding's currentId to 0) -- + // which would race the still-in-flight server reply to the execute() + // call the same lambda just made. + std::optional> handler; + + // waitForConnected() would nest an event loop and abort the page on WASM + // (TESTING.md, "WASM reality") — setConnectHandler is the mandated + // substitute. Constructing BridgeHandler (whose default constructor + // registers against the default model factory) here, not before, is + // what the ordering-constraint comment above requires: this is the + // earliest point at which the async registration call is guaranteed to + // see a live connection. + rawBackend->setConnectHandler([&bridge, &qtExec, &handler] { + qDebug() << "morph-ladder-wasm-spike: connected"; + handler.emplace(bridge, &qtExec); + }); + + // Poll (via a QTimer, not waitForConnected/pumpUntil — this is real page + // code, not a test) until the async registration completes, then fire + // one action and log the result to the browser console, where the + // nightly Playwright smoke (this directory's README) asserts on it. + // `BridgeHandler::isBound()` observes the same settlement that used to + // require polling `HandlerBinding::currentId` directly (docs/findings/019, + // reach-in #3), without this file ever naming + // `morph::bridge::detail::HandlerBinding`. + auto* timer = new QTimer{&app}; + QObject::connect(timer, &QTimer::timeout, [&handler] { + if (!handler.has_value() || !handler->isBound()) { + return; + } + static bool fired = false; + if (fired) { + return; + } + fired = true; + handler->execute(SpikeEchoAction{99}) + .then([](int value) { qDebug() << "morph-ladder-wasm-spike: result=" << value; }) + .onError([](const std::exception_ptr&) { qDebug() << "morph-ladder-wasm-spike: error"; }); + }); + timer->start(50); + + return app.exec(); +} diff --git a/examples/common/wasm_spike/spike_model.hpp b/examples/common/wasm_spike/spike_model.hpp new file mode 100644 index 00000000..b00dd193 --- /dev/null +++ b/examples/common/wasm_spike/spike_model.hpp @@ -0,0 +1,21 @@ +// SPDX-License-Identifier: Apache-2.0 +#pragma once + +/// @file +/// The smallest possible model for the WASM-remote spike: proves +/// registration + one round-trip action work over QtWebSocketBackend from a +/// WASM client, nothing more. +/// +/// Deliberately at namespace scope, not inside an anonymous namespace: glz's +/// reflection (which `BRIDGE_REGISTER_MODEL`/`BRIDGE_REGISTER_ACTION` rely on +/// to serialize these types across the wire) needs external linkage on the +/// type — see glaze/reflection/get_name.hpp's `extern const T external`, and +/// examples/common/testkit/test_fault_proxy.cpp's identical note. + +struct SpikeEchoAction { + int value = 0; +}; + +struct SpikeEchoModel { + int execute(SpikeEchoAction action) { return action.value; } +}; diff --git a/examples/crm/README.md b/examples/crm/README.md new file mode 100644 index 00000000..c1a21aa0 --- /dev/null +++ b/examples/crm/README.md @@ -0,0 +1,183 @@ +# crm — rung 7 of the [application ladder](../LADDER.md) + +**Status: design annex** ([round-7 program decision](../LADDER.md)) — this +README is the deliverable; the rung's defining framework question (runtime +custom fields) runs earlier as the standalone **extension-bag spike**, and +building 7a is a post-rung-4 decision. A mini-Salesforce: accounts, +contacts, leads, +opportunities in a pipeline, quotes with exact pricing, per-field +permissions, field-level audit history — and, as the endgame, runtime custom +fields. This rung tests whether morph can carry *metadata-driven* production +business software, the defining property of the Salesforce/SAP class. + +Per review, the rung is split: **7a** = steps 1–8 (a conventional CRM on +compiled types), **7b** = steps 9–10 (runtime custom fields), with an +explicit **go/no-go gate** between them — the extension-bag question has a +different risk profile, and a negative answer must not stall the ladder. + +## Reference implementations + +The open source CRM/ERP world spans a spectrum of "where does the data model +live", and each anchor marks one point on it: + +- **[EspoCRM](https://github.com/espocrm/espocrm)** (PHP, AGPL) — **read + this first.** The whole system, backend and frontend, is driven by merged + JSON metadata: `entityDefs/{Entity}.json` (fields, types, links), + `layouts/*.json` (form layouts), with admin-created custom fields written + as JSON overlays into `custom/`. The Backbone client fetches merged + metadata and renders every form from it — exactly morph's + schema-served-forms model, including enum options and link fields + (analogous to `forms::Choice` action-backed combos). Its **Dynamic Logic** + (JSON condition trees driving visible/required/read-only) is the spec to + copy for conditional forms. Also a precedent: EspoCRM ships with + polling-only notifications. Docs: + +- **[Tryton](https://github.com/tryton/tryton)** (Python, GPL) — the + cleanest ERP codebase and **the only serious open source ERP that runs on + SQLite** (its whole test suite does). Exact `Decimal` everywhere for + money; generic clients render forms from server-served view definitions + (`fields_view_get`) — same shape as a morph Qt client. The reference for + the quotes/pricing and document state machines here. +- **[Frappe / ERPNext](https://github.com/frappe/frappe)** (Python, MIT + framework) — the most complete customization spec in open source: one + **DocType** JSON defines schema, DB table, form UI, list view, and REST + API; custom fields are rows merged into the Meta at load time; child + tables put order lines inside an order form (maps to morph's + nested-aggregate schema recursion, #35). Submitted documents are + immutable + amendable — a natural fit for an append-only journal. Docs: + +- Runtime ceiling, for orientation only: + [Twenty](https://github.com/twentyhq/twenty) (metadata in DB tables, + GraphQL API regenerated at runtime) and + [Corteza](https://github.com/cortezaproject/corteza) (Apache-2.0, Go — + the license-safest design to borrow; modules/fields/pages purely runtime + data). [Odoo](https://github.com/odoo/odoo) is the scope benchmark — + study its docs, not its source. + +## What to implement + +Models: `AccountModel`, `ContactModel`, `LeadModel`, `OpportunityModel` +(shared instances keyed by record id), `QuoteModel`, `MetaModel` (serves +schemas/layouts). Build order (each step is a usable milestone): + +1. **Core objects + CRUD** — Account, Contact, Lead, Opportunity; list + actions with filters/pagination; schema-served forms for every edit view + (validates the existing forms subsystem at real scale). +2. **Relations in forms** — lookup fields via `forms::Choice` backed by + list actions ("account" combo on a contact); child collections (contacts + of an account; quote line items via nested aggregates). +3. **Pipeline + lead conversion** — Opportunity stages as guarded, journaled + transitions (kanban client reuses [`kanban`](../kanban) pieces). + `ConvertLead` → creates Account + Contact + Opportunity **atomically + across three models** — the multi-model transactional action morph's + per-model strands make interesting. Review sharpened both options: + an orchestrating model that *waits* on sub-actions **blocks a pool + thread — N concurrent conversions exhaust the pool and deadlock** + (`Completion` has no chaining to do it non-blockingly); the saga + alternative leaks partial state on a mid-saga crash (no cross-model + transactions, and the three per-model journal entries carry **no causal + link**, so no replay reconstructs the invariant). Decide the idiom + (recommended: one orchestrating model owning the whole conversion on + *its own* strand with compensations) — and **write the pool-starvation + test that shows why naive orchestration is wrong**, plus the + crash-between-legs test showing what the journal can and cannot say. +4. **Quotes/pricing** — line items with exact `Rational` unit prices, + discounts, tax; total recomputation as an action (Tryton semantics). +5. **Authorization depth** — role-based per-entity *and per-field* + permissions via `session::Principal` + `IAuthorizer`; ownership/team + record scoping (Odoo record-rules style). Served schemas must reflect the + caller's rights (read-only fields arrive read-only). +6. **Field-level audit + undo** — EspoCRM's Stream / Frappe's Version + rendered from the morph journal; undo last change per record. +7. **Dynamic logic** — conditional required/visible/read-only encoded in + the served schema. **Round-5 correction: EspoCRM's condition *trees* + cannot be adopted as-is** — morph's `x-rules` vocabulary is closed + single-node conditions (no `and`/`or`/`not`, no `in`-lists, and lookup + fields support only `engaged`/`equals` — `Choice` has no ordering). + The rung maps EspoCRM logic onto the closed vocabulary and files + combinators as a framework proposal where the mapping fails. +8. **Offline** — edit queue in `SqliteOfflineQueue`, replay with conflict + surfacing; no CRM in this class does offline well — it is morph's chance + to differentiate. +9. **Runtime custom fields — the endgame.** Admin action + `AddCustomField { entity, name, type, unit?, required? }` extends the + *served* schema at runtime and persists values. Compiled C++ action + structs cannot grow members, so this decides the framework question this + rung exists to ask: can a morph model carry an open extension bag + (`map` alongside typed members) whose fields appear in + schemas, forms, validation, and the journal like first-class ones? + EspoCRM (file overlay), Frappe (merged Meta rows), and Twenty (runtime + schema regen) are the three prior answers. +10. *(stretch)* Saved views/filters as stored definitions executed by list + actions. Report builders, dashboards, email sync, and workflow timers + are **out of scope** — every researched product implements these as + background/push machinery; note it and stop. + +## morph subsystems exercised + +Forms as the product (not a feature); nested aggregates + action-backed +choices; per-field authorization; multi-model atomic actions vs. per-model +strands; journal as field-level history; offline for business records; the +compiled-types vs. runtime-metadata boundary. + +## Expected strain points + +- `ConvertLead` atomicity across three strands and one SQLite database + (pool-starvation and crash-between-legs tests above). +- Schemas become per-caller (rights) and per-tenant (custom fields) — + schema serving turns from static reflection into computed data. + **Round-5 ground truth**: `schemaJson()` is one cached, unversioned + string per compiled type, and `x-readonly`/`x-hidden` are compile-time + presentation only ("not a security control") — per-caller shaping means + app-side JSON post-processing *plus* independent server-side per-field + enforcement; neither has a framework hook [framework gap]. That includes + **`x-optionsAction` under authorization**: a `forms::Choice` combo whose + backing list action the caller cannot run renders as a dead control — + and its sibling failure, a *filtered* options action returning zero rows, + makes a required Choice permanently unsubmittable. Also mandatory + (review D6): **Choice membership is never validated** — a stale id + (row deleted between fetch and submit) passes the forms layer; the + model-level referential re-check is the binding convention for every + lookup field. +- **The shipped form renderer auto-fires on validity and re-fires per + edit** — there is no submit button (review B4/D7). A CRM of + side-effectful mutations needs the **explicit-submit / presenter-gated + mode** (presenter owns the single `submitIfValid`) built before any form + ships; the two-phase duplicate-detection flow is impossible without it. +- **Nested line items get schemas but no enforcement** (review D3): + `allRequiredEngaged` and precision reconciliation stop at top level, and + the QML renderer has no array/child-table control — quote lines need an + app-level recursive validator plus a child-table renderer [framework + gap]. Empty-vs-zero also bites here: a computed total with a + never-entered discount computes to *empty*, not `qty × price` — decide + per field. +- **Per-field authz vs. one journal**: journal payloads are stored whole, + so field-level history naively shows restricted users values they cannot + read. Redaction-on-serve is app logic; test that a restricted principal + leaks nothing through history *or undo replay*. +- **Custom-field lifecycle races (7b)**: admin deletes a custom field while + (a) a client holds an open form containing it, (b) an offline client has + queued edits carrying it, (c) journal replay carries it. Decide + reject / drop / preserve-as-orphan and test all three arrival paths. +- **Stable pagination**: keyset-cursor lists as the ladder idiom; test + cursor stability while another client renames/deletes rows mid-walk. +- The extension-bag design: validation, journaling, and forms for fields + the C++ type system has never heard of. + +Two review-added features that stress *new interaction shapes* (not bulk), +**both deferred to a "7-later" bucket per the delivery review** (each is a +mini-rung; neither gates 7a/7b): **duplicate detection on create** ("this +contact may already exist — create anyway?") as a two-phase action — +execute → warnings + confirmation token → re-execute; and **record merge** +(two contacts, each with journal history and possibly live shared +instances — two attached handler sets, one survivor), the hardest +journal + instance-directory interaction in the ladder. + +## Definition of done + +- A rep works a lead → conversion → opportunity → quote → won, entirely on + generated forms, on desktop and WASM, local and remote. +- A second user with a restricted role sees the same records with fields + hidden/read-only, enforced server-side. +- An admin adds a custom field at runtime; existing clients render it on + next schema fetch; its values persist, validate, and journal. diff --git a/examples/forge/README.md b/examples/forge/README.md new file mode 100644 index 00000000..038ac3dc --- /dev/null +++ b/examples/forge/README.md @@ -0,0 +1,192 @@ +# forge — rung 8 of the [application ladder](../LADDER.md) + +**Status: design annex** ([round-7 program decision](../LADDER.md)) — this +README is the deliverable; the rung's *framework* content (polling at +500–2,000 sockets, unbounded notification instances, epoch resync, +hardened-config latency) ships earlier as the **forge load script against +synthetic models**; building the product phases is a post-rung-4 decision. +A software forge — the GitLab class: organizations, +teams, repositories, issues, labels, milestones, notifications, wiki, pull +requests with reviews, webhooks, CI status. The ladder's ceiling: every +subsystem and every known framework limit at once, at multi-client scale. + +## Reference implementations + +- **[Gitea](https://github.com/go-gitea/gitea) / + [Forgejo](https://codeberg.org/forgejo/forgejo)** (Go, MIT) — the anchor. + Decisive facts, verified: + - **SQLite is a first-class supported database** — a full forge runs on + morph's persistence tier. + - Even Gitea's own UI **treats push as an optional enhancement over + polling**: notification counts poll (SSE optional and distrusted, see + [gitea#25661](https://github.com/go-gitea/gitea/issues/25661)), CI + runners **poll** `FetchTask` + ([#24543](https://github.com/go-gitea/gitea/issues/24543) to change that + is still open), and the CI log view polls a JSON endpoint + ([#33606](https://github.com/go-gitea/gitea/issues/33606)). A + request/response-only forge is therefore *precedented*, not a + compromise. + - Architecture to study: layered monolith `routers → services → models + (XORM) → modules`; background work behind a unified queue abstraction + (persistable-channel/LevelDB — analogous to morph's SQLite offline + queue); `hook_tasks` table for webhook delivery + retry. + Overview: + Note also: a `git push` over SSH **bypasses morph entirely**, yet repo + viewers must see the new branch on their next poll — the post-receive + hook needs the server-side internal-dispatch seam established in + [`bookmarks`](../bookmarks); the drift test is "push via sidecar, assert + a polling client converges." +- **[Gogs](https://github.com/gogs/gogs)** (Go, MIT) — Gitea's ancestor, + deliberately minimal, single binary + SQLite: the best small-codebase read + for "what is the true minimum forge". +- **[Zulip's events system](https://zulip.readthedocs.io/en/stable/subsystems/events-system.html)** + — the notification transport blueprint: per-client server-side event + queues, register-with-snapshot then incremental `getEventsSince`, queue + GC + full-state resync on expiry. Proves an entire real-time product + ships on request/response alone. This rung scales the pattern introduced + in [`polls`](../polls) to many clients per user across many entities. +- **GitLab itself** — the architecture *lesson*, not a code reference: Rails + keeps typed app logic; **Workhorse** (large/slow transfers) and **Gitaly** + (all git object access, gRPC) bypass it. The shape to copy: typed actions + in morph; bytes in sidecars. +- [Pagure](https://github.com/Pagure/pagure) — curiosity worth knowing: + issue/PR metadata stored as JSON *in git*, i.e. metadata history = git + history — a cousin of morph's replayable journal. + +## What to implement + +Build order follows verified complexity ranking; each phase ships usable. + +**Phase 1 — the tracker (morph sweet spot).** +Models: `OrgModel`, `RepoModel` (shared instance per repo), `IssueModel` +(shared instance per issue), `NotificationModel` (per user). + +Two review-mandated design rules up front: **key models by immutable ids, +never by mutable attributes** — "instances never change key" is load-bearing +in the shared-instance design, and repo rename/transfer (a table-stakes +forge feature this rung must include) collides head-on with a name-keyed +`RepoModel`; and **per-user notification instances are unbounded** — N users +each pinning a live shared instance forever collides with +`LimitPolicy::maxLiveModels` and the absence of idle eviction; the load +script measures instances/memory vs. connected users deliberately, to +motivate an eviction policy [framework gap to expose]. + +1. Users, orgs, teams; repo create/settings; permission matrix + (owner/admin/write/read) via `IAuthorizer` — Gitea's permission checks + transliterated. +2. Issues: CRUD, comments, labels, milestones, assignees, state machine. + **Issue history comes free from the journal** — Gitea maintains a + `comment` row type per event; here the journal *is* that table. +3. Notifications: fan-out-on-write to per-user rows; clients poll unread + counts (exactly what Gitea does); Zulip-pattern event queues for list + deltas — including the Zulip design's *expiry half*: **event-queue GC + and server-restart epochs**. A client holding `lastEventId` across a + restart must detect the epoch change and full-resync; without it the + load test silently measures the wrong thing after the first restart. +4. Search: SQL `LIKE`/FTS5 fallback (Gitea ships a DB fallback too); + indexing pipelines are out of scope. + +**Phase 2 — git enters (the sidecar).** + +5. Repo browsing: tree/blob/commit/branch/log/README rendering. Git object + access lives in a **sidecar module shelling out to git** (Gitea's + `modules/git` approach) exposed as read-only actions; large blobs and + raw-file/archive downloads go over a plain HTTP endpoint next to the + WebSocket server — **the Gitaly/Workhorse lesson: bytes never travel the + JSON action protocol.** Clone/push (smart HTTP/SSH) is served by that + sidecar entirely outside morph. +6. Wiki: a git repo of markdown reusing the same sidecar. + +**Phase 3 — collaboration machinery (the hard 20%).** + +7. Webhooks: config as CRUD actions; delivery as a **durable outbound job + queue in SQLite** (Gitea's `hook_tasks`) with retry + dead-letter — + the background-job pattern from [`bookmarks`](../bookmarks) at + production shape. +8. Pull requests + reviews: diff computation in the sidecar, paginated diff + actions (response-size bounds get measured here), review threads + anchored to diff positions, approve/request-changes state machine. + **Merge is the submit→poll job idiom** from [`ledger`](../ledger): + `SubmitMerge` → job id → poll status (no `Completion` chaining, no + cancellation — this is where those limits show). +9. CI status: an external runner **polls** `FetchTask` (Gitea's actual + protocol), posts status/logs up; the UI polls `GetLogsSince(offset)` for + log tailing — incremental delivery within request/response, the honest + stress test of one-callback-per-outcome. + +## morph subsystems exercised + +All of them, at scale: authorization at real granularity, shared instances +(repo/issue) with many concurrent viewers, journal as product feature +(issue history, audit), event-queue polling under N clients × M +subscriptions (the scale test for no-push), durable background queues, the +sidecar boundary for everything binary. + +## Expected strain points (the point of the rung) + +- **Polling at scale**: notification freshness vs. server load. Review + quantified the meaningful load: **500–2,000 concurrent sockets at + ~1 poll/s** — the ceiling is the single Qt thread that receives every + frame and marshals every reply, not the worker pool; "dozens of clients" + finds nothing. Measure p99 poll latency vs. N, including during a + `closeGracefully` drain, plus the rate-limiter interaction (dropped + frames hang unwrapped completions — the rung-3 helper's timeout is + load-bearing here). +- **Payload bounds**: large diffs/file lists through JSON actions; + pagination as a first-class action idiom — including **diff-cursor + staleness under force-push** (cursors and review comments anchored to + positions that no longer exist; put a diff id/epoch in the cursor). +- **Long operations**: merge/CI without composable completions or + cancellation — the submit→poll idiom's limits. Test **duplicate + `SubmitMerge`** (double-click → two jobs racing on one repo's git lock) + and **client disconnect mid-poll** (the job registry must be + server-scoped, not connection-scoped: the job completes and is + re-pollable from a new connection). +- **Permission revocation mid-session**: a demoted user's attached + `IssueModel`/`RepoModel` handlers must go fully inert — reads included — + not just fail new registrations (kanban's revocation answer at forge + scale). +- **The protocol boundary**: keeping git bytes, archives, and log streams + cleanly outside the action model without the two worlds drifting; webhook + deliveries signed via the [`vetted_hmac`](../vetted_hmac) pattern. +- **Right-to-erasure vs. permanent journal** (written deliverable): the + journal never prunes; GDPR-class user deletion against an immutable audit + trail is an unresolved framework question (rotation exists, redaction + does not). Document the position. + +## Security posture — the hardened-configuration demonstration + +Delivery review found the ladder tested security features piecemeal but +never *composed* them; this rung closes that. The forge server binary's +default configuration is the full `docs/spec/security.md` checklist: TLS +(`tlsVerifyingConfig`/`tlsPinnedConfig`), `MORPH_REQUIRE_VETTED_HMAC=ON` +with a `vetted_hmac` adapter, a `SigningAuthorizer` subclass overriding +**both** `authorizeRegister` and `authorizeInstance`, full `LimitPolicy`, +full server bounds, and `hello` version negotiation — and the **load script +runs against this hardened config** (the limiter, in-flight caps, and TLS +change the latency curve; measuring only the unbounded server measures a +configuration the spec says never to deploy). + +## Phase gating (delivery review) + +Phases 1–2 constitute a shippable forge-lite. Phase 3's items (webhooks, +PRs/reviews, CI protocol) each get an individual go/no-go, like crm's 7b — +phase 3 is effectively a second product and must not be entered as a block. + +## Explicit non-goals + +Sub-second collaborative editing (Etherpad-class OT — genuinely requires +push), federation, code search indexing, and **public-internet exposure / +red-teaming** — but note the hardened *configuration* is in scope, per the +security section above. + +## Definition of done + +- Two orgs, several repos, issues + PRs + reviews end-to-end from Qt + desktop and WASM clients against the remote backend, SQLite storage. +- A demo runner executes a job and the UI tails its log by polling. +- Webhook deliveries survive a server restart (durable queue) and retry. +- A load script sweeping to 500–2,000 polling connections (process-pool + clients per [`../TESTING.md`](../TESTING.md)), with p99 latency and + live-instance/memory measurements written up in this folder — including + a run across a server restart (epoch resync) and a graceful drain. diff --git a/examples/kanban/README.md b/examples/kanban/README.md new file mode 100644 index 00000000..a05d001f --- /dev/null +++ b/examples/kanban/README.md @@ -0,0 +1,165 @@ +# kanban — rung 4 of the [application ladder](../LADDER.md) + +**Status: planned — committed scope, and the ladder's designated +showcase.** A multi-project kanban board: columns, swimlanes, tasks, +drag-and-drop moves, WIP limits, comments, per-project roles, an activity +stream, and automation rules. The mid-tier flagship: the first app where +concurrency, authorization, offline, and the journal are all load-bearing at +once. As the one polished showcase (round-7 audience decision), this rung +alone may spend effort on visual presentation; every other rung stays +deliberately unstyled. + +## Reference implementations + +- **[Kanboard](https://github.com/kanboard/kanboard)** (PHP, MIT, SQLite + first-class, maintenance-mode = a reference that won't shift under you) — + the anchor, for two exceptional properties: + - Its official API is **JSON-RPC 2.0** — a documented catalog of named, + permission-checked procedures (`createTask`, `moveTaskPosition`, + `assignTask`, …) that is effectively a pre-written, battle-tested typed + action vocabulary. Transliterate it into morph actions nearly 1:1: + + - Its full SQLite schema is checked in at `app/Schema/Sql/sqlite.sql` + (40+ tables) — copy the core subset. +- [Focalboard](https://github.com/mattermost-community/focalboard) (Go, + SQLite default; unmaintained — study, don't depend) — secondary: its + "everything is a block with JSON props" model and its + broadcast-is-only-an-optimization WebSocket design confirm last-writer-wins + CRUD + polling is enough for boards. + [Planka](https://github.com/plankanban/planka) is the maintained equivalent. + +## What to implement + +Models: `BoardModel` keyed by project id (shared instance — every viewer of a +board attaches to the same server-side instance), `ProjectAdminModel`. +Entities (Kanboard subset): project, column (+ WIP limit), swimlane, task, +subtask, comment, tag, user/role (`project_has_users`), automatic action, +activity event. + +Build order: + +1. Project/column/task CRUD + `GetBoard` (lift `GetEventsSince` polling from + [`polls`](../polls)). +2. **`MoveTaskPosition { taskId, columnId, position, swimlaneId }`** — the + centerpiece. Two users dragging tasks on the same board concurrently is a + precise test of per-model strand ordering: actions serialize, positions + stay consistent, both clients converge on the next poll. Write the + many-clients stress test around exactly this action. +3. WIP limit enforcement — server-side validation rejecting a move; the + client renders the typed error. +4. Per-project RBAC (viewer/member/manager) via `IAuthorizer` consulting + `project_has_roles` — Kanboard enforces permissions per procedure; mirror + that per action. +5. Activity stream — Kanboard's `project_activities` table is a journal + cousin: derive the stream *from the morph journal* instead of a parallel + table. +6. **Automatic actions** — Kanboard's event→condition→mutation rules (e.g. + "task moved to Done ⇒ assign to closer, add tag"). One client action + cascades into further model mutations. **Review sharpened the decision — + both naive answers diverge on replay**: unjournaled cascades make replay + incomplete, but journaled cascades *double-apply* when replay re-executes + the trigger and the rules re-fire. Choose one of: journal cascades with + a causal parent-id and suppress rule evaluation during replay, or don't + journal cascades and require rule determinism (which breaks when rules + are edited — see [`ledger`](../ledger)'s rule-versioning). State the + choice in writing with a divergence test; note morph today provides + neither replay-mode signaling nor causal links [framework gap]. +7. **Offline drag-a-card** — this rung's framework-level deliverable, with + a **scope correction from review: the offline stack does not run on WASM + today.** `NetworkMonitor` is a background probe thread (WASM build is + single-threaded) and `SqliteOfflineQueue` needs a durable filesystem + (Emscripten = async IDBFS). So: offline is **desktop-first** here using + `SqliteOfflineQueue` (`MORPH_BUILD_OFFLINE_SQLITE`), `NetworkMonitor`, + `SyncWorker`, `ReconnectCoordinator`; a browser-native equivalent + (IndexedDB-backed `IOfflineQueue`, online/offline DOM events feeding + the coordinator) is a stretch goal, explicitly not assumed — and per + round-7 T5 it is **framework-candidate code**: an `IOfflineQueue` + implementation belongs in morph or nowhere, never as app code in this + rung. Queued moves + replay on reconnect; conflicts (column deleted while offline) surface + through the model's `onBackendChanged` reconciliation, not silently. +8. Task attachments — first blob answer: bytes over a side channel (plain + HTTP endpoint next to the WebSocket server), metadata through actions. + +## morph subsystems exercised + +Strand ordering under real contention (2), typed server-side validation (3), +authorization at Kanboard's granularity (4), journal-derived activity + undo +(5, 6), the full offline stack (7), shared board instances throughout. + +## Expected strain points + +- Position renumbering under interleaved moves — the classic ordering bug; + the strand should prevent it, the stress test must prove it. +- **Exactly-once has no owner in the stack [this rung establishes the + pattern]**: the wire `Envelope` carries no idempotency key (only an + ephemeral per-connection `callId`). Precision from verification: the + durable queues *do* dedup at **enqueue time** on a non-empty + `idempotencyKey` (SQLite partial unique index / file-queue scan) — what + nothing provides is **replay-time exactly-once**: the *server* cannot + recognize a replayed operation, so a reply frame lost *after* the server + committed makes `SyncWorker` retry → double-apply. + `MoveTaskPosition` is non-idempotent even replayed verbatim once another + client's move interleaves. Answer: an op-id inside the action payload + + a server-side applied-ops ledger in the model. Test with the + fault-injection proxy ([`../TESTING.md`](../TESTING.md)): drop exactly + the reply frame of one execute; assert exactly-once semantics. +- **Dead-letter is user-facing, not a log line**: the `SyncWorker` retry cap + is a hard-coded 5 *cumulative* attempts, durable across restarts, and a + reconnect flap cannot preempt a running replay — five flaky reconnects + dead-letter every queued move while the server never saw them. Extend the + kill-the-network demo to "kill it during each replay, five times"; wire a + `DeadLetterSink` and show "N changes could not be synced" in the GUI. +- **Two clients' queues replaying interleaved**: assert the board invariant + (positions dense and unique, all tasks present), not any specific final + order. +- **Permission revocation while attached**: a member demoted mid-session + gets their next move rejected (authorization is per-execute), but nothing + detaches them and their `GetEventsSince` keeps returning board contents + unless the authorizer distinguishes reads. Test that reads are cut off + and the GUI degrades gracefully. +- **SQLite contention × pool starvation — the sharpest data-corruption test + in the ladder**: K writing board models = K connections contending for + SQLite's single writer; each `SQLITE_BUSY` wait pins a pool thread; a + 2–4-thread pool starves, `executeTimeout` fires "timeout" while the + models *eventually commit anyway* → clients retry → double-apply. Test: + pool=4, 32 boards writing concurrently, WAL on and off; measure + throughput collapse; assert no timeout-then-committed double-apply. +- **Offline queue growth is unbounded**: no depth bound exists on any + shipped queue — define an overflow policy [framework gap]. (Scope + correction from verification: the linear-scan/quadratic enqueue applies + to `FileOfflineQueue` only; this rung's `SqliteOfflineQueue` dedups via + an index. Measure depth growth on the SQLite queue; the 10⁴–10⁵-item + enqueue-latency measurement belongs to `FileOfflineQueue` as the + alternative-queue comparison.) +- Attachment bytes must bypass the JSON protocol; only metadata is an + action — and the side channel is **the largest new attack surface in the + ladder** (a hand-written HTTP server beside the WebSocket server): it + must reuse `TokenVerifier` (same secret, same clock), enforce its own + size bound, and its request parser joins the fuzz corpus. Test the + upload dying after metadata commit (dangling row). + +## Deferred within this rung (delivery review) + +Steps 6 (automation rules) and 8 (attachments) are each independently +large, and the attachments answer is duplicated at forge phase 2. They move +to a "later" bucket: steps 1–5 + 7 deliver every DoD bullet except the +cascade divergence test — and [`ledger`](../ledger) needs only the +cascade-journaling *decision*, which is written from a spike, not from a +full rules engine. + +## Definition of done + +- Concurrent-move stress test green under ThreadSanitizer (N=4, seeded + scripts, run in **Local rig mode on `ThreadPoolExecutor`** — the repo's + CI deliberately keeps Qt stacks out of the sanitizer matrix; see + [`../TESTING.md`](../TESTING.md)). +- Exactly-once proven under reply-frame loss (fault-injection proxy in the + testkit by this rung). +- Kill the network mid-drag: client keeps queuing, reconnect replays, board + converges; the five-flap dead-letter path surfaces in the GUI; demo + scripted. The offline tests assert the framework's own + `morph::observe` metrics (`queueDepth`, reconnect attempt/outcome) — the + observability seam gains its first app-scale coverage here. +- Activity stream rendered from the journal, with the cascade-journaling + decision recorded and its divergence test green. diff --git a/examples/ledger/README.md b/examples/ledger/README.md new file mode 100644 index 00000000..39bfbe09 --- /dev/null +++ b/examples/ledger/README.md @@ -0,0 +1,164 @@ +# ledger — rung 5 of the [application ladder](../LADDER.md) + +**Status: design annex** ([round-7 program decision](../LADDER.md)) — this +README is the deliverable; construction is a post-rung-4 decision, and +ledger is first in line among the annex rungs (the only one with a +genuinely app-shaped core; its sharpest content runs earlier as the +Rational fuzz and journal-evolution spikes). Double-entry personal finance: accounts, transactions +with multiple legs that must balance exactly, budgets, multi-currency, rules, +and a full audit trail. This rung exists to put morph's exact-value types +(`math::Rational`) under *invariants*, not just arithmetic — and to benchmark +morph's journal against the two opposing sync philosophies in the wild. + +It deliberately **upgrades, not duplicates, [`bank`](../bank)**: bank has +accounts/payments/statements; ledger adds what bank lacks — the double-entry +invariant, multi-currency, budget math, and rule cascades. + +## Reference implementations + +- **[Firefly III](https://github.com/firefly-iii/firefly-iii)** (PHP/Laravel, + AGPL) — the anchor. Its data model documentation is unusually explicit: + `TransactionJournal` (the financial event) contains ≥2 `Transaction` rows + (debit/credit legs) that must sum to zero — double-entry enforced + structurally. Fully specified JSON API = a ready action catalog: + . Its audit-log currency bug + ([firefly-iii#12014](https://github.com/firefly-iii/firefly-iii/issues/12014)) + is field evidence that exact-money audit trails are genuinely hard — the + bug class this rung must show morph prevents by construction. +- **[Actual Budget](https://github.com/actualbudget/actual)** (TypeScript, + MIT, SQLite everywhere) — the sync counter-reference. Every mutation + becomes field-level CRDT messages `(dataset, row, column, value)` with + hybrid-logical-clock timestamps and a merkle tree for divergence detection; + the sync server is ~300 lines; undo is layered on the same messages + (`packages/loot-core/src/server/undo.ts`). Best explanation: + [Using CRDTs in the Wild](https://archive.jlongster.com/using-crdts-in-the-wild) + and the annotated companion + [crdt-example-app](https://github.com/clintharris/crdt-example-app_annotated). +- [Kimai](https://github.com/kimai/kimai) — supplementary for one hard + numeric corner: documented duration-rounding and rate policies + () as explicit action + parameters. + +## What to implement + +Models: `LedgerModel` (accounts + transactions, keyed by ledger/book id), +`BudgetModel`, `RuleModel`. Entities (Firefly subset): account +(asset/expense/revenue/liability), transaction journal, transaction leg, +currency, category, budget + budget limit, rule (trigger/action pairs). + +Build order: + +1. Accounts + `StoreTransaction { description, date, legs[] }` — one + composite, all-or-nothing action creating the journal and all legs. + **Server-side invariant: legs sum to exactly zero, checked in `Rational` + arithmetic** — the model rejects, never rounds. Review correction: + *define the invariant per-currency first* — legs in different currencies + cannot sum, so the rule is "legs sum to zero within each currency, with + foreign-amount pairs balancing across" (Firefly's actual model); the + property test below is unfalsifiable until this definition is written. +2. Multi-currency: legs carry amount + currency, foreign-amount pairs with + exact exchange rates (`Rational`), per-currency decimal precision via + `withDecimalPlaces`. +3. Budgets: monthly limits, spent-so-far aggregation — exact summation over + many rows; measure `Rational` overflow headroom (int64 pair, no bignum) + and document the practical magnitude/precision envelope. +4. Rules: "description contains X ⇒ set category Y" applied during store — + reuse the cascade-journaling answer from [`kanban`](../kanban), with the + money-grade sharpening: **rules are runtime data, so replay must pin the + rule-set version** (journal entries carry the rule version, or replay + suppresses rule evaluation entirely). Edit a rule between record and + replay and the naive audit trail lies — exactly the Firefly bug class. + Named test, not a bullet. +5. **Undo = compensating action, by design.** Review verdict: replay-based + undo is the wrong tool for a SQLite+outbox model (the journal spec says + replay is exact only for pure in-memory models, and `undoLast()`'s + replay is O(all remaining actions) — a performance cliff at ledger + scale). Undo of `StoreTransaction` is a reversing journal entry, + Firefly-style. Test the compensation path. +6. **CSV/OFX import with dedup** (added per review — table stakes in every + anchor): chunked bulk actions, content-hash idempotency keys at scale, + duplicate detection across re-imports — the natural production home of + the exactly-once discipline from [`kanban`](../kanban). +7. Reports (monthly statement, budget report) — **the document-generation + pattern**, this rung's framework-level deliverable: `SubmitReport` → + job id → `GetReportStatus` polling → fetch result; the submit→poll idiom + for long-running work that `Completion`'s one-shot callbacks can't + express directly. **Snapshot semantics must be specified**: the job runs + off the strand and can otherwise see mid-action state across + `LedgerModel`/`BudgetModel` — use a SQLite WAL read transaction; the + byte-identical DoD is only meaningful against that snapshot. +8. **Sync benchmark** (written deliverable, not code): reproduce one + concurrent-edit scenario from Actual (two offline clients edit the same + transaction's different fields) and one from ODK-style base-version + conflict, run both through morph's action-replay journal + offline queue, + and document where action-level replay (intent-preserving, coarser) lands + versus field-level LWW merge (fine-grained, intent-blind). State + explicitly: **morph's ordering authority is server arrival order, full + stop** (no HLC), and show one scenario where that differs from Actual's + hybrid-logical-clock merge. Include the clock-skew test: two clients + with injected ±5-minute clocks writing to one ledger — the audit view + orders by journal order and displays payload timestamps as + claimed-not-authoritative. + +Forms: transaction entry uses `morph::forms` schemas — amount fields as +`Rational` with per-currency `x-decimalPlaces`, category combo via +`forms::Choice` backed by a list action. + +## morph subsystems exercised + +Exact `Rational` arithmetic under a hard invariant; schema-driven money +forms; journal-as-audit with the store/log divergence handled via +`setOutboxManaged` + `journal::OutboxRelay` (the SQLite-transactional model +opts in — see `docs/spec/journal/journal.md`); offline queue with financial +data; the submit→poll job idiom. + +## Expected strain points + +- `Rational` is a fixed-width int64 pair: budget aggregation over thousands + of rows probes overflow behavior (currently UB on overflow — document what + the app must do to stay safe). **Sharper, per review: intermediates + overflow before results do** — `amount × exchange-rate` with high-dp + currencies can overflow the num/den pair even when the final value is + representable. Ship a property/fuzz test over `Rational` arithmetic at + ledger-realistic magnitudes; expect it to motivate a checked-arithmetic + mode [probable framework gap]. +- Wire input is clamped, not rejected, on malformed rationals — and the + round-5 review verified **there is no pre-decode seam to catch it**: + every dispatch path decodes first, then validates the already-clamped, + perfectly plausible value (`{"num":5,"den":0,"dp":2}` arrives as exactly + `5/1`; `{}` as canonical zero). The test to write (D2): prove only the + model's own zero-sum invariant (or an app-added num/den echo check) + rejects — i.e. the mitigation is app-built scaffolding, and a pre-decode + validation hook is a named framework gap. +- **Zero-decimal currencies are unrepresentable at true precision**: + `DecimalPlaces` has a floor of 1, so JPY/KRW need an app convention + (dp 1 + an integer-only `x-rules` gate) with a named test. +- **Locale entry**: in de-DE the group separator is "." and the shipped + normalizer strips it anywhere — typing `1.5` submits **15**, a silent 10× + money error. Pin the behavior, fix (positional grouping validation or + reject), and mirror the vectors through `normalizeLocaleNumber` (D5). + Related: result *display* in the shipped renderer goes through `double` + division — balances beyond 2^53 drift on readback while the payload is + exact; presenter display must use the exact formatter. +- **Recurring transactions (time-scheduled jobs — this rung owns the + shape)**: Firefly-style schedules are the ladder's one cron-shaped + server job — who ticks, on what thread, under what principal, journaled + how. Forge's webhook retry loop assumes this answer exists. +- **Empty-principal writes**: a token expiring between authorize and + authenticate dispatches with a cleared principal; deterministic test via + the injectable `TokenVerifier` clock — assert no successful mutating + journal entry ever carries an empty principal (the model must refuse). +- Local-time month boundaries vs. UTC storage: the 23:30 local transaction + landing in the right budget month is a presenter-layer conversion — a + dual-mode GUI test. + +## Definition of done + +- Property test: no sequence of stores/edits/undos ever leaves any journal + violating the per-currency zero-sum invariant defined in step 1. +- Rule-version pinning proven: editing a rule after recording does not + change what replay reconstructs. +- Statement generation via submit→poll, output byte-identical on re-run + against its declared snapshot. +- The sync-philosophy comparison (including the arrival-order-vs-HLC + scenario) written up in this folder. diff --git a/examples/lims/README.md b/examples/lims/README.md new file mode 100644 index 00000000..35b44830 --- /dev/null +++ b/examples/lims/README.md @@ -0,0 +1,174 @@ +# lims — rung 6 of the [application ladder](../LADDER.md) + +**Status: design annex** ([round-7 program decision](../LADDER.md)) — this +README is the deliverable; construction is a post-rung-4 decision, and the +rung's sharpest content (forms conformance D1–D8, journal payload +evolution) runs earlier as no-app spikes. A lightweight Laboratory +Information Management System: +register samples, assign analyses, capture results with real units and +detection limits on versioned forms, verify and publish, keep a regulatory +audit trail — with offline data capture in the field. The deepest test of +morph's headline claim ("exact values for financial/lab data") and of the +forms subsystem at full depth. + +## Reference implementations + +Three anchors, each for a different layer: + +- **[SENAITE](https://github.com/senaite/senaite.core)** (Python/Plone, GPL) — + the *domain* reference. Its code is Zope-era and not worth reading; its + **requirements** are gold: sample → analysis request → result → verify → + publish workflow, detection limits (`< LOD`, `> UDL`), instrument + interfaces, and an immutable per-change audit trail built for 21 CFR Part + 11-style compliance. Mine the docs and data model, reimplement clean: + +- **[InvenTree](https://github.com/inventree/InvenTree)** (Python/Django, + MIT) — the *units* reference. It embeds the pint unit library end-to-end: + parameter templates declare a base unit, users enter values in **any + compatible unit** ("1500 mA against a template in A") and the system + converts exactly, including in API filters; custom units are definable. + Reproduce this flow with `morph::units::Quantity` + + `UnitTraits::relations` (entry-unit alternatives with exact ratios). + Docs: +- **[ODK Central](https://github.com/getodk/central)** (Node, Apache-2.0) — + the *forms + offline* reference. Its entire product is "upload a versioned + form schema, clients render data-entry UIs from it, offline". Two features + to reproduce: + - versioned form definitions (XLSForm/XForms → here: versioned + `morph::forms` schemas served by the model); + - **offline Entities** (v2024.3+): field workers create *and update* + shared records offline; every update carries a target **base version**; + the server flags a conflict when the base is stale and a human resolves + it. This is exactly morph's shared-instances + offline-queue + replay, + with a published conflict-semantics answer to compare against. Design + discussion: , spec: + + +## What to implement + +Models: `SampleModel` keyed by sample id (shared instance — bench and office +clients attach to the same sample), `AnalysisCatalogModel` (analysis +definitions = form schemas, versioned), `WorksheetModel`. + +Entities: client/project, sample, analysis definition (name, unit, entry +units, decimal places, specification range, LOD/UDL), analysis result, +verification record, audit entry. + +Build order: + +1. Analysis catalog: define an analysis with unit, precision, and spec range + → the served JSON Schema *is* the result-entry form (`x-decimalPlaces`, + `ExtUnits`, `x-unitAlternatives`, bounds). +2. Sample registration + lifecycle state machine + (registered → received → in-progress → to-be-verified → published), each + transition a guarded, journaled action. +3. **Result entry with units**: `Quantity` fields; entry-unit + conversion (mg/L ↔ µg/L exact); empty-Quantity = "not measured"; + detection limits as typed values. **Resolved by the round-5 review — the + forms palette has no sum types (closed by design)**: `ResultValue = + quantity | belowLOD | aboveUDL` is implemented as the *multi-field + encoding* (a `Quantity` plus a qualifier `Choice`) glued by + `mutuallyExclusive`/`exactlyOneOf` `x-rules`; the rung proves that + encoding round-trips distinguishably through wire, journal, and offline + payloads (three "no number" meanings — D-test in the review). Native + sum types go on the framework-gap ledger, not this rung's critical path. +4. **Schema versioning**: editing an analysis definition creates version + N+1; old results stay bound to their version; clients render the version + the result was captured with (ODK's form-version model). **Scope + correction (round 5)**: serving stored v-N schema text renders fine (the + client machinery is data-driven), but **validation, `x-rules`, and + precision reconciliation always run against the *current compiled* + struct** — "bound to their version" holds for rendering only; validating + a v-N payload under v-N rules is a named framework gap. The + render-v1/validate-v2 skew test (review D4) is mandatory and needs no + socket. +5. Conditional form logic: fields required/visible depending on other + fields (e.g. dilution factor only when diluted). The boundary is now + known (round 5): `requiredWhen`/`visibleWhen`/`readonlyWhen` with + single-node conditions exist and are enforced client- and server-side; + there are **no `and`/`or`/`not` combinators** (closed vocabulary), a + hidden field's draft value still travels (decide clear-on-hide), and + comparison rules are vacuously true on unengaged operands while `equals` + is false — test the parity suite on *served* schema data including a + fail-closed unknown rule kind (review D8). +6. Verification + audit: four-eyes verify step gated by `IAuthorizer` role; + the full audit trail rendered from the journal (SENAITE's immutable + snapshot requirement). +7. **Offline field capture** — the rung's centerpiece: a WASM/desktop client + takes samples in the field, disconnected; results queue in + `SqliteOfflineQueue`; each queued update carries the sample's **base + version**; on reconnect, replay detects stale bases server-side and flags + conflicts for human resolution instead of silently merging (the ODK + answer, implemented on morph primitives). + +## morph subsystems exercised + +Unit algebra + exact conversion end-to-end; runtime schema-driven forms at +their hardest (tagged unions, conditionals, versioning); shared sample +instances; offline queue with explicit conflict semantics; role-gated +transitions; journal as regulatory audit. + +## Expected strain points + +- Tagged-union result values and cross-field conditional logic are beyond + plain JSON Schema — this rung maps the exact edge of `morph::forms`. + Wire-level corollary: **three distinct "no number" meanings** (empty + `Quantity`, `belowLOD`, `aboveUDL`) must round-trip distinguishably + through glaze *and* through the offline queue's opaque payloads. +- Schema versioning: morph serves schemas from compiled C++ types; versioned + catalogs mean schemas become *data*. Bridges toward rung 7's runtime + custom fields. +- **Journal payload evolution — this rung owns the ladder's answer + [framework gap]**: replay decodes stored payloads with the *current* + action structs; rename or retype a field and old entries decode + leniently, silently dropping data — the "reconstructible from the journal + alone" DoD is then false. Versioned analyses make this unavoidable: + per-entry schema/app-version pinning plus a migration story (the journal + format's `v` covers the line format only). Rungs 5 and 7 reuse whatever + is decided here. +- **Stale-schema submission**: schema `required`/bounds are client-side + only — the server runs whatever payload arrives. A v-N payload against a + v-N+1 server (narrowed spec range) must be accepted-under-old-rules, + rejected, or migrated — pick one and prove it. Extend to real binary + skew: build an old client with `MORPH_CLIENT_ONLY` and run it against a + new server (additive field must work; a renamed field must fail *loudly*, + not decode a lab result to a default). +- **Self-conflict in the offline chain**: one field client editing the same + sample twice offline — the second queued update's base version must + reference the first *queued* update, not the server state, or replay + flags the client's own second edit as a conflict (ODK hit exactly this). +- **Precision through unit relations — the rule exists; test it, don't + redesign it** (round-5 correction): conversion carries the dp tag through + unchanged, the renderer always submits in the canonical unit at the + schema's `x-decimalPlaces`, and alternative-unit display rounds half-up. + What to test instead: (a) **retag-vs-round** — `x-decimalPlaces` + "enforcement" retags the tag without changing the value, so a hand-built + over-precise payload stores `1.23456` displayed as `1.2` (spec text and + code disagree; display ≠ stored is disqualifying in a LIMS — this rung + owns the decision test, review D1); (b) `x-unitAlternatives` lists + **direct relation edges only**, so InvenTree-style "enter in any + compatible unit" needs a deliberately complete relations array; chained + ratios are not cross-checked; (c) the shipped QML converter silently + clears input above a 1e12 divisor — exactly the fine-ratio range of + trace-concentration relations (ng/L↔mg/L); (d) + `std::optional>` silently loses all unit annotations — use + empty `Quantity`/`optionalFields`, and lint for the optional spelling. +- **Empty-principal audit entries**: the authorize/authenticate TOCTOU can + dispatch with a cleared principal; in a 21-CFR-framed audit trail that is + disqualifying. Deterministic test via the injectable token clock; models + refuse empty principals on mutating actions. +- Base-version conflict detection is app logic today — evaluate whether a + reusable morph primitive should exist. +- Offline field capture in the browser inherits kanban's WASM-offline scope + limits ([`../kanban/README.md`](../kanban/README.md)) — desktop-first. + +## Definition of done + +- The "1500 mA vs A" InvenTree flow works with exact conversion in a + generated form. +- Offline capture demo: two field clients update the same sample offline; + reconnect flags exactly the stale-base update as a conflict. +- Audit trail passes the SENAITE-style test: every state a sample was ever + in is reconstructible from the journal alone — **under the payload + evolution scheme this rung defines**, verified by replaying a journal + recorded before a schema migration. diff --git a/include/morph/core/registry.hpp b/include/morph/core/registry.hpp index 85dec65c..8169bba9 100644 --- a/include/morph/core/registry.hpp +++ b/include/morph/core/registry.hpp @@ -671,7 +671,11 @@ bool registerActionExecutorOnce(std::string_view modelId, std::string_view actio static constexpr ::morph::model::Loggable loggable = (LOGGABLE); \ static std::string toJson(const A& action) { \ std::string out; \ - if (auto errCode = glz::write_json(action, out)) { \ + /* EscapingWriteOpts, not write_json: a raw control byte in any */ \ + /* caller-supplied string field would otherwise produce a body the */ \ + /* peer's reader rejects, or be silently mangled by glaze's chunked */ \ + /* fast path — see its doc comment in registry.hpp. */ \ + if (auto errCode = glz::write<::morph::model::detail::EscapingWriteOpts{}>(action, out)) { \ throw morph::model::detail::ParseError{glz::format_error(errCode, out)}; \ } \ return out; \ @@ -689,7 +693,10 @@ bool registerActionExecutorOnce(std::string_view modelId, std::string_view actio } \ static std::string resultToJson(const Result& result) { \ std::string out; \ - if (auto errCode = glz::write_json(result, out)) { \ + /* EscapingWriteOpts: see toJson() above — a result body carries */ \ + /* caller data back (a paste's content, a fetched record) and needs */ \ + /* the identical treatment. */ \ + if (auto errCode = glz::write<::morph::model::detail::EscapingWriteOpts{}>(result, out)) { \ throw morph::model::detail::ParseError{glz::format_error(errCode, out)}; \ } \ return out; \ diff --git a/include/morph/core/remote.hpp b/include/morph/core/remote.hpp index 31c05432..cda8946e 100644 --- a/include/morph/core/remote.hpp +++ b/include/morph/core/remote.hpp @@ -10,6 +10,7 @@ #include #include #include +#include #include #include #include @@ -214,9 +215,7 @@ class RemoteServer : public std::enable_shared_from_this { /// @param msg JSON-encoded `morph::wire::Envelope` (via `wire::encode`). /// @param reply Callback invoked with the JSON-encoded reply envelope. void handle(std::string msg, std::function reply) { - auto self = shared_from_this(); - _pool.post( - [self, msg = std::move(msg), reply = std::move(reply)]() mutable { self->dispatchMessage(msg, reply); }); + handleImpl(std::move(msg), std::move(reply), 0); } /// @brief Like `handle(msg, reply)`, but additionally attributes any @@ -234,10 +233,7 @@ class RemoteServer : public std::enable_shared_from_this { /// @param cid Connection scope to attribute a `register` in @p msg to; /// `0` means unscoped. void handle(std::string msg, std::function reply, ConnectionId cid) { - auto self = shared_from_this(); - _pool.post([self, msg = std::move(msg), reply = std::move(reply), cid]() mutable { - self->dispatchMessage(msg, reply, cid); - }); + handleImpl(std::move(msg), std::move(reply), cid); } /// @brief Synchronously processes a JSON `Envelope` on the calling thread and returns the reply. @@ -286,6 +282,46 @@ class RemoteServer : public std::enable_shared_from_this { return reply; } + private: + /// @brief Shared body of both `handle()` overloads. + /// + /// Finding 035: peeks at @p msg's `kind`/`modelId` — a cheap, best-effort + /// decode, thrown away immediately either way — and, for an `execute` + /// naming a `modelId`, takes an execute-ordering ticket (see + /// `takeExecuteTicket`'s own doc comment on the class-private members + /// above) *before* posting to `_pool`, so two same-model `execute`s + /// posted back-to-back always take their tickets in call order — the + /// same order the transport called `handle()` in, i.e. send order. If + /// this peek fails to decode at all, or isn't an `execute`, no ticket is + /// taken; `dispatchMessage` still does the real (only) decode moments + /// later on the pool thread and produces the canonical error for + /// genuinely malformed input — this peek only ever *adds* a ticket for a + /// well-formed `execute`, it never changes what gets sent to + /// `dispatchMessage` or how errors are reported. + /// @param msg JSON-encoded `morph::wire::Envelope` (via `wire::encode`). + /// @param reply Callback invoked with the JSON-encoded reply envelope. + /// @param cid Connection scope; `0` means unscoped (see `handle()`'s own doc). + void handleImpl(std::string msg, std::function reply, ConnectionId cid) { + auto self = shared_from_this(); + std::optional> ticket; + try { + if (auto peek = ::morph::wire::decode(msg); peek.kind == "execute" && peek.modelId != 0) { + ::morph::exec::detail::ModelId const mid{peek.modelId}; + ticket.emplace(mid, takeExecuteTicket(mid)); + } + } catch (const std::exception&) { // NOLINT(bugprone-empty-catch) + // Malformed input: no ticket taken (there is no well-formed + // execute to order). dispatchMessage's own decode, on the pool + // thread, produces the canonical decode-error reply for this — + // duplicating that error path here would serve no purpose since + // this peek's only job is deciding whether to take a ticket. + } + _pool.post([self, msg = std::move(msg), reply = std::move(reply), cid, ticket]() mutable { + self->dispatchMessage(msg, reply, cid, ticket); + }); + } + + public: /// @brief Opens a new connection scope and returns its id. /// /// Call once per accepted transport connection (e.g. from a WebSocket @@ -758,8 +794,15 @@ class RemoteServer : public std::enable_shared_from_this { // One flat switch over the wire's `kind` discriminator. Splitting it would // scatter the authorization sequence each branch depends on across helpers, // with no reader benefit. + // + // `executeTicket`, when engaged, is this call's execute-ordering ticket + // from `handleImpl` (finding 035) — forwarded straight through to + // `dispatchExecute`, the only branch below that consults it. Every other + // `kind` ignores it; `handleImpl` never takes one for a non-`execute` + // envelope in the first place, so it is always `std::nullopt` for those. // NOLINTNEXTLINE(readability-function-cognitive-complexity) - void dispatchMessage(const std::string& msg, std::function& reply, ConnectionId cid = 0) { + void dispatchMessage(const std::string& msg, std::function& reply, ConnectionId cid = 0, + std::optional> executeTicket = {}) { ::morph::wire::Envelope env; try { env = ::morph::wire::decode(msg); @@ -1023,7 +1066,7 @@ class RemoteServer : public std::enable_shared_from_this { } reply(::morph::wire::encode(::morph::wire::makeOk(env.callId))); } else if (env.kind == "execute") { - dispatchExecute(std::move(env), reply); + dispatchExecute(std::move(env), reply, executeTicket); } else if (env.kind == "hello") { const std::uint32_t minV = _minVersion.load(); const std::uint32_t maxV = _maxVersion.load(); @@ -1045,8 +1088,31 @@ class RemoteServer : public std::enable_shared_from_this { // A single ordered gate sequence — limits, authorize, authenticate, lookup, // per-instance authorize — whose *order* is the security contract itself // (see docs/spec/security.md), so it is deliberately not broken up. + // + // `executeTicket`, when engaged, is this call's finding-035 execute- + // ordering ticket from `handleImpl`. Every early-return branch below + // that follows the ticket-taking site must release it (via + // `releaseExecuteTicket`) before returning — an unreleased ticket + // permanently stalls every later ticket for the same model. The one + // path that actually reaches the strand releases it via + // `awaitExecuteTurn` + `releaseExecuteTicket` bracketing the pre-existing + // `_strand.post(mid, ...)` call instead of releasing immediately, since + // that call site is the entire point of taking a ticket in the first + // place — see the class-private members' own doc comment for the full + // design (finding 035). // NOLINTNEXTLINE(readability-function-cognitive-complexity) - void dispatchExecute(::morph::wire::Envelope env, std::function reply) { + void dispatchExecute(::morph::wire::Envelope env, std::function reply, + std::optional> executeTicket = {}) { + // Releases executeTicket (if engaged) exactly once, then calls reply + // with an error envelope. Used by every early-return branch below so + // the "always release what you took" rule can't be missed at a call + // site — this is the only way any of these branches produce a reply. + auto rejectAndRelease = [this, &executeTicket, &env, &reply](const char* message) { + if (executeTicket) { + releaseExecuteTicket(executeTicket->first, executeTicket->second); + } + reply(::morph::wire::encode(::morph::wire::makeErr(message, env.callId))); + }; LimitPolicy limits; { std::scoped_lock const lock{_limitsMtx}; @@ -1059,11 +1125,11 @@ class RemoteServer : public std::enable_shared_from_this { // and a registry lookup. if (limits.maxInFlightExecutes != 0 && _inFlightExecutes.load(std::memory_order_relaxed) >= limits.maxInFlightExecutes) { - reply(::morph::wire::encode(::morph::wire::makeErr("server busy", env.callId))); + rejectAndRelease("server busy"); return; } if (!_authorizer->authorize(env.session, env.modelType, env.actionType)) { - reply(::morph::wire::encode(::morph::wire::makeErr("unauthorized", env.callId))); + rejectAndRelease("unauthorized"); return; } // Make the identity authoritative. A verifying authorizer returns the @@ -1097,7 +1163,14 @@ class RemoteServer : public std::enable_shared_from_this { } } if (!holder) { - reply(::morph::wire::encode(::morph::wire::makeErr("model not found", env.callId))); + // The one path this whole mechanism exists to keep fast (finding + // 035, and the reverted first attempt this doc comment on the + // class-private members describes): a lookup against a modelId + // that is not (or no longer) live must resolve immediately, + // never waiting on some other, unrelated model's strand — this + // ticket is released right here, before any wait could ever be + // introduced by a future change to this function. + rejectAndRelease("model not found"); return; } // Per-instance (row-level) authorization. `authorize` above only saw the @@ -1107,7 +1180,7 @@ class RemoteServer : public std::enable_shared_from_this { // now carries the verified principal (stamped just above), so an // ownership authorizer compares the recorded owner against it. if (known && !_authorizer->authorizeInstance(env.session, env.modelType, env.actionType, mid.v, owner)) { - reply(::morph::wire::encode(::morph::wire::makeErr("unauthorized", env.callId))); + rejectAndRelease("unauthorized"); return; } // Capture a strong self-reference so the server (and therefore @@ -1137,7 +1210,7 @@ class RemoteServer : public std::enable_shared_from_this { std::size_t current = _inFlightExecutes.load(std::memory_order_relaxed); for (;;) { if (current >= limits.maxInFlightExecutes) { - reply(::morph::wire::encode(::morph::wire::makeErr("server busy", env.callId))); + rejectAndRelease("server busy"); return; } // compare_exchange_weak refreshes `current` on failure, so a @@ -1190,6 +1263,21 @@ class RemoteServer : public std::enable_shared_from_this { } } + // Finding 035's actual fix: 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 + // 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 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. + if (executeTicket) { + awaitExecuteTurn(executeTicket->first, executeTicket->second); + } _strand.post(mid, [self, env = std::move(env), holder = std::move(holder), complete, timeoutHandle]() mutable { ::morph::exec::detail::ModelId const targetMid{env.modelId}; auto const start = std::chrono::steady_clock::now(); @@ -1256,6 +1344,17 @@ class RemoteServer : public std::enable_shared_from_this { complete(::morph::wire::encode(::morph::wire::makeErr(exc.what(), env.callId))); } }); + // The ticket's whole job was ordering *this* `_strand.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 + // its entire job), so holding this ticket any longer would only + // delay a *different* execute's own pre-strand work for no ordering + // benefit. + if (executeTicket) { + releaseExecuteTicket(executeTicket->first, executeTicket->second); + } } /// @brief Returns the next opaque model id. @@ -1282,6 +1381,123 @@ class RemoteServer : public std::enable_shared_from_this { ::morph::model::detail::ActionDispatcher& _dispatcher; ::morph::model::detail::ModelRegistryFactory& _registry; std::shared_ptr<::morph::session::IAuthorizer> _authorizer; + + // ── Per-model execute-ordering gate ────────────────────────────────────── + // `handle()`'s two overloads dispatch to `_pool`, a multi-worker + // ThreadPoolExecutor: two `execute` envelopes for the *same* model, + // 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 + // 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 + // broke `test_remote_connection_scope.cpp`'s "an in-flight execute + // completes safely across a disconnect" guarantee: a lookup against a + // since-reclaimed `modelId` must resolve immediately without waiting on + // 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 + // fast-reject path exactly as fast as it always was. + // + // The gate orders only the *moment of the `_strand.post()` call itself*, + // not the pipeline before it: a ticket is handed out synchronously in + // `dispatchDecoded` (called directly from `handle()`, which runs on + // whatever single thread the transport calls it from -- in true send + // order, nothing async yet) for every `execute` with a known `modelId`, + // *before* posting to `_pool`. `dispatchExecute` waits for its ticket's + // turn only immediately before the pre-existing `_strand.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 + // ever reach the strand, so their ticket must not block anyone behind + // it). This keeps the fast-reject path exactly as fast as it always was + // (`test_remote_connection_scope.cpp`'s "an in-flight execute completes + // safely across a disconnect" test — a lookup against a since-reclaimed + // modelId must resolve without waiting on some other blocked model's + // strand — never touches this gate at all, since it never gets a ticket + // for a model that turns out to be gone... except it does get a ticket, + // and must release it immediately rather than hold up a live ticket + // behind it; see `releaseExecuteTicket`'s own doc comment). + // + // Keyed by ModelId, not held forever: a model with no outstanding + // tickets has no entry in `_executeGates` at all (erased once its last + // ticket is released), so this never grows unbounded across the + // server's lifetime the way a per-model map with no cleanup would. + struct ExecuteGate { + std::uint64_t nextTicket = 0; + std::uint64_t nextToRun = 0; + std::condition_variable cv; + }; + std::mutex _executeGateMtx; + std::unordered_map<::morph::exec::detail::ModelId, std::shared_ptr, ::morph::exec::detail::ModelIdHash> + _executeGates; + + /// @brief Hands out the next ticket for @p mid, in call order. + /// + /// Called synchronously from `dispatchDecoded` (i.e. from `handle()`'s + /// own calling thread, before anything is posted anywhere) — the ticket + /// numbers two calls receive for the same `mid` are therefore always in + /// the order `handle()` was called, which is the order the transport + /// received them in. + /// @param mid The model the upcoming `execute` targets. + /// @return This call's ticket number. + [[nodiscard]] std::uint64_t takeExecuteTicket(::morph::exec::detail::ModelId mid) { + std::scoped_lock const lock{_executeGateMtx}; + auto& gate = _executeGates[mid]; + if (!gate) { + gate = std::make_shared(); + } + return gate->nextTicket++; + } + + /// @brief Blocks until @p ticket is next in line for @p mid, then returns. + /// + /// Called from a pool thread, immediately before the pre-existing + /// `_strand.post(mid, ...)` call in `dispatchExecute` — nothing else + /// about that call site changes; this only delays *when* it happens; it + /// still runs on the pool, never blocks the strand itself. + /// @param mid The model the caller is about to `_strand.post()` to. + /// @param ticket This call's ticket, from `takeExecuteTicket`. + void awaitExecuteTurn(::morph::exec::detail::ModelId mid, std::uint64_t ticket) { + std::unique_lock lock{_executeGateMtx}; + auto iter = _executeGates.find(mid); + if (iter == _executeGates.end()) { + return; // Nothing left to wait for -- every ticket for mid already released. + } + auto gate = iter->second; // Keep it alive even if releaseExecuteTicket erases the map entry mid-wait. + gate->cv.wait(lock, [&gate, ticket] { return gate->nextToRun == ticket; }); + } + + /// @brief Releases @p ticket for @p mid, letting the next ticket (if any) proceed. + /// + /// Called exactly once per ticket taken, from every path that took one — + /// whether that path went on to `_strand.post()` (a live model) or bailed + /// out early (model not found, unauthorized, over limit, a decode/ + /// validation throw). A ticket that is taken but never released would + /// permanently stall every later ticket for the same `mid`; this is why + /// every early-return branch in `dispatchExecute` that follows + /// `takeExecuteTicket` must call this before returning, not just the + /// branch that reaches the strand. + /// @param mid The model @p ticket was taken for. + /// @param ticket The ticket to release. + void releaseExecuteTicket(::morph::exec::detail::ModelId mid, std::uint64_t ticket) { + std::scoped_lock const lock{_executeGateMtx}; + auto iter = _executeGates.find(mid); + if (iter == _executeGates.end()) { + return; // Defensive; should not happen (this ticket's own take() created the entry). + } + iter->second->nextToRun = ticket + 1; + if (iter->second->nextToRun == iter->second->nextTicket) { + // No ticket is currently waiting and none can arrive for a ticket + // number already handed out — safe to drop the entry so a model + // with no in-flight executes leaves no trace in this map. + _executeGates.erase(iter); + } else { + iter->second->cv.notify_all(); + } + } // mutable: health() is const and must still be able to lock this to read // _models.size() safely from any thread. mutable std::mutex _regMtx; diff --git a/scripts/coverage.sh b/scripts/coverage.sh index 9133fe34..023080c1 100644 --- a/scripts/coverage.sh +++ b/scripts/coverage.sh @@ -15,10 +15,56 @@ TEST_EXE="$OUT/tests/morph_tests" MERGED="$OUT/merged.profdata" REPORT_DIR="$OUT/html" -# Restrict coverage to the library headers. Test files, demo src/, system -# headers and fetched dependencies are excluded by passing this as the -# positional source filter to llvm-cov. -SOURCES="include/morph" +# Second binary, only present when this configure also built the ladder +# (MORPH_BUILD_LADDER=ON — see the "coverage leg only" Qt install step in +# ci.yml). llvm-cov takes one binary positionally and every additional one +# via -object; OBJECT_ARGS stays empty (and every ${OBJECT_ARGS[@]} +# expansion below a no-op) when the ladder wasn't built, so this script +# still works unchanged for a plain `cmake --preset clang-coverage` with no +# -DMORPH_BUILD_LADDER=ON. +LADDER_TEST_EXE="$OUT/examples/common/ladder_common_tests" +OBJECT_ARGS=() +if [ -x "$LADDER_TEST_EXE" ]; then + OBJECT_ARGS+=(-object "$LADDER_TEST_EXE") +fi + +# Per-rung test binaries, added on exactly the same "only if it was built" +# terms. Each rung's models are what examples/IMPLEMENTATION.md rule 5's +# 100% bar actually names, so a rung that ships models must contribute its +# profile data or the gate below measures nothing. A rung that hasn't been +# built (or doesn't exist yet) simply contributes nothing, so this list can +# grow one line per rung with no other change. +PASTEBIN_TEST_EXE="$OUT/examples/pastebin/ladder_pastebin_tests" +if [ -x "$PASTEBIN_TEST_EXE" ]; then + OBJECT_ARGS+=(-object "$PASTEBIN_TEST_EXE") +fi + +# Positional source-path filters to llvm-cov: include/morph is the library +# proper; examples/common is the ladder's hand-written GUI/testkit code +# (examples/IMPLEMENTATION.md rule 5 — presenter/BackendRig/etc. logic is +# real coverage of morph's own client stack, per examples/TESTING.md's +# "round-7 T4 reframe"). examples/pastebin (rung 1) adds the first real rung +# models — the sole subject of rule 5's own 100% bar — plus its hand-written +# presenter/QML-adapter layer, held to the same bar as examples/common's for +# the same reason. AUTOMOC's generated +# mocs_compilation.cpp lives under $OUT (the build tree), never under a +# source-tree path named here, so moc output is excluded automatically — +# no separate exclusion mechanism needed. Test files, demo src/, system +# headers and fetched dependencies are excluded the same way. +SOURCES=(include/morph) +if [ -x "$LADDER_TEST_EXE" ]; then + SOURCES+=(examples/common) +fi +if [ -x "$PASTEBIN_TEST_EXE" ]; then + # include/ + src/ are the rung's DTOs and models (rule 5's own 100% bar); + # gui_lib/ is its hand-written presenter/adapter code, held to the same bar + # for the same reason examples/common/gui is — it is real coverage of + # morph's own client stack, not app-specific domain logic. gui/ and + # gui_wasm/ are deliberately absent: those are `main()` shells (engine + # setup, argv parsing, setInitialProperties) with no unit-testable seam, + # exercised only by the offscreen QML smoke test and by hand. + SOURCES+=(examples/pastebin/include examples/pastebin/src examples/pastebin/gui_lib) +fi PROFILES=$(find "$OUT" -name "*.profraw" 2>/dev/null | tr '\n' ' ') if [ -z "$PROFILES" ]; then @@ -31,21 +77,24 @@ ${LLVM_PROFDATA} merge -sparse $PROFILES -o "$MERGED" mkdir -p "$REPORT_DIR" ${LLVM_COV} show "$TEST_EXE" \ + "${OBJECT_ARGS[@]}" \ -instr-profile="$MERGED" \ -format=html \ -output-dir="$REPORT_DIR" \ - "$SOURCES" + "${SOURCES[@]}" echo "Coverage report: $REPORT_DIR/index.html" ${LLVM_COV} report "$TEST_EXE" \ + "${OBJECT_ARGS[@]}" \ -instr-profile="$MERGED" \ - "$SOURCES" + "${SOURCES[@]}" ${LLVM_COV} export "$TEST_EXE" \ + "${OBJECT_ARGS[@]}" \ -instr-profile="$MERGED" \ -format=lcov \ - "$SOURCES" \ + "${SOURCES[@]}" \ > "$OUT/coverage.lcov.raw" # llvm-cov emits branch (BRDA) records once per template instantiation, so a @@ -55,8 +104,9 @@ ${LLVM_COV} export "$TEST_EXE" \ # matching the aggregate that `llvm-cov report` already prints above. Branch # coverage is preserved (not skipped); only the per-instantiation noise is removed. ${LLVM_COV} export "$TEST_EXE" \ + "${OBJECT_ARGS[@]}" \ -instr-profile="$MERGED" \ - "$SOURCES" \ + "${SOURCES[@]}" \ > "$OUT/coverage.json" python3 scripts/aggregate_lcov_branches.py \ diff --git a/src/qt/forms/CMakeLists.txt b/src/qt/forms/CMakeLists.txt index 7b78f285..bbf3cb7a 100644 --- a/src/qt/forms/CMakeLists.txt +++ b/src/qt/forms/CMakeLists.txt @@ -37,7 +37,13 @@ target_compile_features(morph_forms_module PUBLIC cxx_std_23) # exact digit arithmetic, unit conversion, readiness) -- independent of any # app/demo. Later tasks add more tst_*.qml files here; -input (below) picks # up every tst_*.qml in this directory with no further CMake changes. -if(MORPH_BUILD_TESTS) +# +# NOT EMSCRIPTEN: the module itself builds for wasm (a WASM ladder client +# imports MorphForms), but these two test executables do not belong in a +# browser build -- ctest cannot run a .wasm binary, and MORPH_BUILD_TESTS is +# never part of a WASM configure anyway (examples/common/CMakeLists.txt's own +# Emscripten note). This keeps that true even if someone sets it. +if(MORPH_BUILD_TESTS AND NOT EMSCRIPTEN) find_package(Qt6 REQUIRED COMPONENTS QuickTest) qt_add_executable(morph_forms_qml_tests tests/tst_main.cpp) diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index fb1dd36b..4897343f 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -31,6 +31,7 @@ add_executable(morph_tests test_remote_extra.cpp test_remote_connection_scope.cpp test_remote_step_interleaving.cpp + test_remote_execute_ordering.cpp test_action_validation.cpp test_security_fixes.cpp test_bridge_lifetime.cpp diff --git a/tests/test_coverage_push95.cpp b/tests/test_coverage_push95.cpp index d2b45de5..68a2a759 100644 --- a/tests/test_coverage_push95.cpp +++ b/tests/test_coverage_push95.cpp @@ -545,3 +545,37 @@ TEST_CASE("morph::backend::LocalBackend: setConnectHandler/setDisconnectHandler REQUIRE_NOTHROW(backend.setConnectHandler(nullptr)); REQUIRE_NOTHROW(backend.setDisconnectHandler(nullptr)); } + +// ── remote.hpp: SimulatedRemoteBackend::assignPrimary's early-return guard ─── + +TEST_CASE("morph::backend::SimulatedRemoteBackend::assignPrimary: an empty primary or a zero ModelId is a no-op, " + "not a wire round-trip", + "[coverage][remote]") { + // remote.hpp's assignPrimary bails out before building/sending an "assign" + // envelope at all when either precondition fails (empty primary, or no + // instance to promote) -- this is reachable through the public IBackend + // interface directly, unlike SimulatedRemoteBackend's server-side + // concurrency-race branches, which need two genuinely racing calls to + // reach at all. + ::morph::exec::ThreadPoolExecutor pool{2}; + auto& env = emptyThrowEnv(); + auto server = std::make_shared<::morph::backend::RemoteServer>(pool, env.dispatcher, env.registry); + ::morph::backend::SimulatedRemoteBackend backend{*server}; + + auto mid = backend.registerModel("Cov_EmptyThrowModel", {}); + + // Empty primary: bails out regardless of mid. + REQUIRE_NOTHROW(backend.assignPrimary(mid, "Cov_EmptyThrowModel", "")); + + // Zero ModelId: bails out regardless of primary. + REQUIRE_NOTHROW( + backend.assignPrimary(::morph::exec::detail::ModelId{0}, "Cov_EmptyThrowModel", "some-key")); + + // Confirms the no-op actually didn't file anything under "some-key": a + // fresh shared registration under that key gets its own new id, not + // mid's -- if assignPrimary's guard had been bypassed, this would instead + // reach mid. + auto attached = + backend.registerModelShared("Cov_EmptyThrowModel", {}, ::morph::backend::detail::InstanceIdentity{.primary = "some-key"}); + REQUIRE(attached.v != mid.v); +} diff --git a/tests/test_quantity_forms.cpp b/tests/test_quantity_forms.cpp index 6bc9d8e4..3993759a 100644 --- a/tests/test_quantity_forms.cpp +++ b/tests/test_quantity_forms.cpp @@ -810,9 +810,8 @@ TEST_CASE("Forms::FieldMeta::UnknownFieldNameIsIgnored", "[forms][field_meta]") // FieldMeta::i18nKey — a stem override for morph::forms::i18n's explicit-key // derivation (docs/spec/forms/forms.md, "Field metadata"): consumed as a -// *stem*, not a complete key (docs/superpowers/plans/2026-07-20-gui-i18n.md's -// resolved key-derivation contract), and emitted verbatim as x-i18nKey only -// when non-empty. +// *stem*, not a complete key, and emitted verbatim as x-i18nKey only when +// non-empty. struct QFFieldMetaI18nKey { std::int64_t sampleId = 0; std::int64_t plainField = 0; diff --git a/tests/test_remote_connection_scope.cpp b/tests/test_remote_connection_scope.cpp index 7dcad38f..ea887450 100644 --- a/tests/test_remote_connection_scope.cpp +++ b/tests/test_remote_connection_scope.cpp @@ -148,6 +148,23 @@ struct CsEnv { morph::model::detail::ModelRegistryFactory registry; }; +// A model whose registered factory sleeps once (exchange-guarded, same idiom +// as test_remote_execute_ordering.cpp's SlowFirstAuthorizer) before returning +// a plain ModelHolder. Used to force the exact interleaving +// acquireSharedInstance's second attachExistingLocked check (remote.hpp) is +// for: two attaches to the same not-yet-existing key racing each other, +// where only one of the two _registry.create() calls can win the insert. +struct CsRaceModel { + static inline std::atomic slowFactoryTaken{false}; + + int execute(const CsSquareAction& act) { return act.x * act.x; } +}; + +template <> +struct morph::model::ModelTraits { + static constexpr std::string_view typeId() { return "CS_RaceModel"; } +}; + static CsEnv& csEnv() { static CsEnv env = [] { CsEnv env2; @@ -156,6 +173,14 @@ static CsEnv& csEnv() { env2.dispatcher.registerAction("CS_SquareModel", "CS_SquareFail"); env2.registry.registerModel("CS_SlowModel"); env2.dispatcher.registerAction("CS_SlowModel", "CS_SlowAction"); + env2.registry.registerModel( + "CS_RaceModel", []() -> std::unique_ptr<::morph::model::detail::IModelHolder> { + if (!CsRaceModel::slowFactoryTaken.exchange(true)) { + std::this_thread::sleep_for(std::chrono::milliseconds{200}); + } + return std::make_unique >(); + }); + env2.dispatcher.registerAction("CS_RaceModel", "CS_SquareAction"); return env2; }(); return env; @@ -339,6 +364,59 @@ TEST_CASE( REQUIRE(gone.env.message == "model not found"); } +TEST_CASE( + "morph::backend::RemoteServer: attach on an already-closed connection scope replies " + "\"connection closed\" instead of recording a bogus attachment", + "[remote][connection-scope]") { + // Race window remote.hpp's attachExistingLocked() exists to handle: a + // client sends attach, but its connection is gone by the time the server + // gets to noteScopeAttachLocked() -- e.g. the socket dropped between the + // client sending the request and the server processing it. There is + // nothing timing-dependent to reproduce here: closeConnection() and + // handle() are both synchronous under _regMtx, so calling closeConnection + // on a cid and then handle()-ing an attach carrying that same (now-closed) + // cid deterministically presents the exact precondition + // noteScopeAttachLocked() checks for, on every run. + morph::exec::ThreadPoolExecutor pool{2}; + auto& env = csEnv(); + auto server = std::make_shared(pool, env.dispatcher, env.registry); + + auto cidA = server->openConnection(); + auto cidB = server->openConnection(); + + // A creates the shared instance and keeps a live reference to it, so the + // directory entry B is about to attach to still exists. + WaitReply regA; + server->handle(morph::wire::encode(morph::wire::makeRegisterShared("CS_SquareModel", "42")), std::ref(regA), + cidA); + REQUIRE(regA.await()); + REQUIRE(regA.env.kind == "ok"); + auto modelId = regA.env.modelId; + + // B's connection drops before its attach is processed. + server->closeConnection(cidB); + + WaitReply attachB; + server->handle(morph::wire::encode(morph::wire::makeAttach("CS_SquareModel", "42")), std::ref(attachB), cidB); + REQUIRE(attachB.await()); + REQUIRE(attachB.env.kind == "err"); + REQUIRE(attachB.env.message == "connection closed"); + + // The instance itself is untouched -- A's own reference survives B's + // failed, already-dead attach attempt. + morph::wire::Envelope execReq; + execReq.kind = "execute"; + execReq.modelId = modelId; + execReq.modelType = "CS_SquareModel"; + execReq.actionType = "CS_SquareAction"; + execReq.body = R"({"x":6})"; + WaitReply stillAlive; + server->handle(morph::wire::encode(execReq), std::ref(stillAlive)); + REQUIRE(stillAlive.await()); + REQUIRE(stillAlive.env.kind == "ok"); + REQUIRE(stillAlive.env.body == "36"); +} + TEST_CASE("morph::backend::RemoteServer: the unscoped two-argument handle() never populates any connection scope", "[remote][connection-scope][regression]") { morph::exec::ThreadPoolExecutor pool{2}; @@ -696,6 +774,103 @@ TEST_CASE("morph::backend::RemoteServer: assign never displaces the incumbent ho REQUIRE(again.env.modelId == incumbent.env.modelId); } +TEST_CASE("morph::backend::RemoteServer: assign with an empty primary or an unregistered modelId is a silent " + "no-op, still reporting \"ok\"", + "[remote][connection-scope][shared-instances]") { + // applyAssignLocked's own early-return guard (env.primary.empty() || + // !_models.contains(mid)) never actually fires in any of this file's + // other assign tests -- every one of them assigns a real, just-created + // mid onto a non-empty primary. The wire handler replies "ok" + // unconditionally after applyAssignLocked() returns, whether it filed + // anything or silently declined to, so this is the one place that + // distinction is externally observable: check what a subsequent + // register-shared onto the same key actually reaches. + morph::exec::ThreadPoolExecutor pool{2}; + auto& env = csEnv(); + auto server = std::make_shared(pool, env.dispatcher, env.registry); + auto cid = server->openConnection(); + + WaitReply anon; + server->handle(morph::wire::encode(morph::wire::makeRegister("CS_SquareModel")), std::ref(anon), cid); + REQUIRE(anon.await()); + REQUIRE(anon.env.kind == "ok"); + + // Empty primary: the guard's first disjunct. + WaitReply emptyPrimary; + server->handle(morph::wire::encode(morph::wire::makeAssign("CS_SquareModel", "", anon.env.modelId)), + std::ref(emptyPrimary), cid); + REQUIRE(emptyPrimary.await()); + REQUIRE(emptyPrimary.env.kind == "ok"); + + // An unregistered modelId: the guard's second disjunct. 0 is never a real + // id (nextOpaqueId() never hands it out), so _models never contains it. + WaitReply unknownMid; + server->handle(morph::wire::encode(morph::wire::makeAssign("CS_SquareModel", "300", 0)), std::ref(unknownMid), + cid); + REQUIRE(unknownMid.await()); + REQUIRE(unknownMid.env.kind == "ok"); + + // Neither call filed anything: a fresh register-shared under "300" gets + // its own new instance, not anon's -- had the guard been bypassed, this + // would instead reach anon.env.modelId. + WaitReply attached; + server->handle(morph::wire::encode(morph::wire::makeRegisterShared("CS_SquareModel", "300")), std::ref(attached), + cid); + REQUIRE(attached.await()); + REQUIRE(attached.env.modelId != anon.env.modelId); +} + +TEST_CASE("morph::backend::RemoteServer: assign stamps the caller's authenticated principal, not just the " + "unauthenticated-empty case", + "[remote][connection-scope][shared-instances][auth]") { + // "assign"'s authenticate() call has two branches: the caller is + // unauthenticated (env.session.principal cleared -- already covered by + // every other assign test in this file, none of which configure an + // authenticating IAuthorizer), and the caller *is* authenticated (the + // verified principal is stamped onto env.session.principal instead). + // OwnershipAuthorizer::authenticate returns ctx.principal verbatim + // whenever it's non-empty, so a session carrying one exercises the + // second branch deterministically. + struct EchoAuthorizer : morph::session::IAuthorizer { + [[nodiscard]] bool authorize(const morph::session::Context&, std::string_view, + std::string_view) const override { + return true; + } + [[nodiscard]] std::optional authenticate(const morph::session::Context& ctx) const override { + return ctx.principal.empty() ? std::nullopt : std::make_optional(ctx.principal); + } + }; + + morph::exec::ThreadPoolExecutor pool{2}; + auto& env = csEnv(); + auto authz = std::make_shared(); + auto server = std::make_shared(pool, authz, env.dispatcher, env.registry); + auto cid = server->openConnection(); + + WaitReply anon; + server->handle(morph::wire::encode(morph::wire::makeRegister("CS_SquareModel")), std::ref(anon), cid); + REQUIRE(anon.await()); + REQUIRE(anon.env.kind == "ok"); + + morph::wire::Envelope assignReq = morph::wire::makeAssign("CS_SquareModel", "300", anon.env.modelId); + assignReq.session.principal = "alice"; + WaitReply promoted; + server->handle(morph::wire::encode(assignReq), std::ref(promoted), cid); + REQUIRE(promoted.await()); + REQUIRE(promoted.env.kind == "ok"); + + // The instance is filed under the key regardless of which authenticate() + // branch stamped the principal -- assign's own authorization gate is + // authorizeRegister, not ownership, so this is the same observable + // outcome as the unauthenticated case; the point of this test is that the + // authenticated branch runs at all, not a different result. + WaitReply attached; + server->handle(morph::wire::encode(morph::wire::makeRegisterShared("CS_SquareModel", "300")), std::ref(attached), + cid); + REQUIRE(attached.await()); + REQUIRE(attached.env.modelId == anon.env.modelId); +} + TEST_CASE("morph::backend::RemoteServer: the new kinds reject an empty typeId", "[remote][connection-scope][shared-instances]") { morph::exec::ThreadPoolExecutor pool{2}; @@ -827,3 +1002,245 @@ TEST_CASE("morph::backend::SimulatedRemoteBackend: deregisterModel releases this server->closeConnection(cidB); REQUIRE(server->health().liveModels == 0U); } + +TEST_CASE( + "morph::backend::RemoteServer: two attaches racing the creation of the same not-yet-existing " + "shared key still resolve to one instance", + "[remote][connection-scope][shared-instances]") { + // acquireSharedInstance's create path (remote.hpp) builds a holder + // *outside* _regMtx, then re-checks the directory under the lock before + // inserting -- because a concurrent request for the same key may have + // already won that insert while this one's holder was under + // construction. CsRaceModel's factory sleeps once (exchange-guarded), so + // of two attaches fired back-to-back for the same brand-new key, the + // first one dispatched is reliably the one still sleeping in + // _registry.create() when the second (unslowed) one's own create/insert + // completes -- forcing the first to find the directory already + // populated on its own re-check, rather than hoping real thread + // scheduling happens to interleave that way. + CsRaceModel::slowFactoryTaken.store(false); + morph::exec::ThreadPoolExecutor pool{2}; + auto& env = csEnv(); + auto server = std::make_shared(pool, env.dispatcher, env.registry); + + WaitReply first; + server->handle(morph::wire::encode(morph::wire::makeAttach("CS_RaceModel", "race-key")), std::ref(first)); + + WaitReply second; + server->handle(morph::wire::encode(morph::wire::makeAttach("CS_RaceModel", "race-key")), std::ref(second)); + + REQUIRE(first.await(std::chrono::milliseconds{2000})); + REQUIRE(second.await(std::chrono::milliseconds{2000})); + REQUIRE(first.env.kind == "ok"); + REQUIRE(second.env.kind == "ok"); + + // One instance, not two, regardless of which call's create() actually won + // the race -- the load-bearing assertion this test exists for. + REQUIRE(first.env.modelId == second.env.modelId); + REQUIRE(server->health().liveModels == 1U); +} + +TEST_CASE( + "morph::backend::RemoteServer: maxLiveModels' authoritative re-test under the insert lock rejects a " + "register the advisory pre-check let through", + "[remote][connection-scope][limits]") { + // The private register path checks maxLiveModels twice: an early, + // advisory load (before authorize()/authenticate()/_registry.create() + // run, so it can reject a request cheaply without paying for any of + // that) and an authoritative re-test in the same locked section as the + // actual insert. The comment right above that second check explains why + // the first one alone is not a real bound: every concurrent register + // that passes the advisory check while the server is still under cap + // proceeds to authenticate/create, so a burst can overshoot the cap by + // up to the worker pool's width -- exactly what the authoritative + // re-test exists to catch. CsRaceModel's factory sleeps once + // (exchange-guarded), so the first of two back-to-back registers is + // reliably the one still in _registry.create() when the second's + // fast create()+insert completes, forcing the first to find the cap + // already reached at its own authoritative re-test. + CsRaceModel::slowFactoryTaken.store(false); + morph::exec::ThreadPoolExecutor pool{2}; + auto& env = csEnv(); + auto server = std::make_shared(pool, env.dispatcher, env.registry); + morph::backend::LimitPolicy policy; + policy.maxLiveModels = 1; + server->setLimitPolicy(policy); + + WaitReply first; + server->handle(morph::wire::encode(morph::wire::makeRegister("CS_RaceModel")), std::ref(first)); + + WaitReply second; + server->handle(morph::wire::encode(morph::wire::makeRegister("CS_RaceModel")), std::ref(second)); + + REQUIRE(first.await(std::chrono::milliseconds{2000})); + REQUIRE(second.await(std::chrono::milliseconds{2000})); + + // Exactly one of the two must have won the single slot; the other must + // have been rejected by the authoritative re-test, not silently admitted + // past the cap. + const bool firstOk = first.env.kind == "ok"; + const bool secondOk = second.env.kind == "ok"; + REQUIRE(firstOk != secondOk); + const auto& loser = firstOk ? second : first; + REQUIRE(loser.env.kind == "err"); + REQUIRE(loser.env.message == "too many models"); + REQUIRE(server->health().liveModels == 1U); +} + +namespace { + +/// @brief Allow-all authorizer whose `authorize()` sleeps once, for the first +/// call it sees -- every later call returns immediately. Same idiom as +/// test_remote_execute_ordering.cpp's SlowFirstAuthorizer, reused here +/// to force a different race: two executes reaching +/// dispatchExecute's maxInFlightExecutes compare-exchange loop +/// (remote.hpp) close enough together that the first one dispatched +/// is reliably still held up in authorize() when the second's own +/// pre-CAS work finishes and wins the increment. +class CsSlowFirstAuthorizer : public morph::session::IAuthorizer { + public: + [[nodiscard]] bool authorize(const morph::session::Context&, std::string_view, std::string_view) const override { + if (!_slowCallTaken.exchange(true)) { + std::this_thread::sleep_for(std::chrono::milliseconds{200}); + } + return true; + } + + private: + mutable std::atomic _slowCallTaken{false}; +}; + +} // namespace + +TEST_CASE( + "morph::backend::RemoteServer: maxInFlightExecutes' compare-exchange loop rejects an execute that " + "loses the race for the last slot", + "[remote][connection-scope][limits]") { + // Distinct from test_limit_policy.cpp's "rejects a second execute while + // the first is in flight" case: that test deliberately waits for the + // first execute to have already started (and therefore already + // incremented _inFlightExecutes) before sending the second, so the + // second is rejected by the plain load further up dispatchExecute, never + // reaching the compare-exchange loop's own reject branch at all. This + // test forces the two executes to race the increment itself. + CsSlowModel::started.store(false); + CsSlowModel::proceed.store(false); + morph::exec::ThreadPoolExecutor pool{2}; + auto& env = csEnv(); + auto authorizer = std::make_shared(); + auto server = std::make_shared(pool, authorizer, env.dispatcher, env.registry); + morph::backend::LimitPolicy policy; + policy.maxInFlightExecutes = 1; + server->setLimitPolicy(policy); + + WaitReply reg; + server->handle(morph::wire::encode(morph::wire::makeRegister("CS_SlowModel")), std::ref(reg)); + REQUIRE(reg.await()); + auto modelId = reg.env.modelId; + + // CS_SlowModel, not CS_SquareModel: the slot must still be held (i.e. the + // winner's own decrement must not yet have run) by the time the loser's + // CAS loop checks it -- an instantly-completing action can finish its + // whole execute (including the decrement) before the other side ever + // gets scheduled, which starves this race of the window it needs. + morph::wire::Envelope reqA; + reqA.kind = "execute"; + reqA.callId = 1; + reqA.modelId = modelId; + reqA.modelType = "CS_SlowModel"; + reqA.actionType = "CS_SlowAction"; + reqA.body = R"({})"; + WaitReply replyA; + server->handle(morph::wire::encode(reqA), std::ref(replyA)); + + morph::wire::Envelope reqB = reqA; + reqB.callId = 2; + WaitReply replyB; + server->handle(morph::wire::encode(reqB), std::ref(replyB)); + + // Whichever call wins the slot is now blocked inside CS_SlowModel::execute + // until proceed is set, holding the slot open long enough for the loser's + // CAS loop to observe it -- unlike the plain-CS_SquareModel version of + // this test, which raced the decrement itself and was flaky (~40% + // failure across repeated local runs) for exactly that reason. + REQUIRE(morph::testing::waitUntil([] { return CsSlowModel::started.load(); })); + + // Exactly one of the two already has its reply: the CAS loop rejects + // synchronously, before ever reaching the strand, so the loser's + // WaitReply settles immediately -- well before the winner's, which is + // still blocked in execute() until released below. Poll `.ready`, not + // `.env` directly: `.env` is written by the reply callback on a pool + // thread with no synchronization of its own beyond `.ready`'s + // release-store/acquire-load pair (see WaitReply's own doc comment) -- + // reading `.env.kind` before observing `.ready == true` is a real data + // race (caught by this file's own TSan CI leg the first time this test + // was written this way). + REQUIRE(morph::testing::waitUntil( + [&] { return replyA.ready.load() || replyB.ready.load(); }, std::chrono::milliseconds{2000})); + + const bool aErr = replyA.ready.load() && replyA.env.kind == "err"; + const bool bErr = replyB.ready.load() && replyB.env.kind == "err"; + REQUIRE(aErr != bErr); + const auto& loser = aErr ? replyA : replyB; + REQUIRE(loser.env.message == "server busy"); + + CsSlowModel::proceed.store(true); + auto& winner = aErr ? replyB : replyA; + REQUIRE(winner.await(std::chrono::milliseconds{2000})); + REQUIRE(winner.env.kind == "ok"); +} + +TEST_CASE("morph::backend::RemoteServer: an action that throws still cancels its own executeTimeout, not just " + "one that returns normally", + "[remote][connection-scope][limits]") { + // dispatchExecute's strand task cancels timeoutHandle in two places: the + // try block's normal-completion path, and the catch block's + // exception path. Every existing executeTimeout test in + // test_limit_policy.cpp either lets the timeout actually fire (a slow + // action outliving its budget) or lets a normal action complete well + // inside it -- none combine a *throwing* action with executeTimeout + // configured, so the catch block's own cancel() call had never run. + // CsSquareFail (already registered in csEnv()) throws synchronously, + // well inside any reasonable timeout. + morph::exec::ThreadPoolExecutor pool{2}; + auto& env = csEnv(); + auto server = std::make_shared(pool, env.dispatcher, env.registry); + morph::backend::LimitPolicy policy; + policy.executeTimeout = std::chrono::milliseconds{500}; + server->setLimitPolicy(policy); + + WaitReply reg; + server->handle(morph::wire::encode(morph::wire::makeRegister("CS_SquareModel")), std::ref(reg)); + REQUIRE(reg.await()); + + morph::wire::Envelope req; + req.kind = "execute"; + req.callId = 1; + req.modelId = reg.env.modelId; + req.modelType = "CS_SquareModel"; + req.actionType = "CS_SquareFail"; + req.body = "{}"; + WaitReply reply; + server->handle(morph::wire::encode(req), std::ref(reply)); + + // The action throws immediately, well before the 500ms budget -- if this + // reply is "err" with the action's own message (not "timeout"), the + // catch block's cancel() ran and prevented the timeout from firing a + // second, stale reply later. + REQUIRE(reply.await(std::chrono::milliseconds{2000})); + REQUIRE(reply.env.kind == "err"); + REQUIRE(reply.env.message == "square failed"); + + // Waiting past the configured timeout confirms it was actually + // cancelled, not merely that this reply beat it to the punch: a second, + // stale "timeout" reply landing here (which WaitReply has no way to + // observe, since it only keeps the first) would indicate the cancel + // didn't take -- there is nothing further to assert beyond "the server + // is still fine," which the next call demonstrates. + std::this_thread::sleep_for(std::chrono::milliseconds{600}); + + WaitReply again; + server->handle(morph::wire::encode(morph::wire::makeRegister("CS_SquareModel")), std::ref(again)); + REQUIRE(again.await()); + REQUIRE(again.env.kind == "ok"); +} diff --git a/tests/test_remote_execute_ordering.cpp b/tests/test_remote_execute_ordering.cpp new file mode 100644 index 00000000..8d475afa --- /dev/null +++ b/tests/test_remote_execute_ordering.cpp @@ -0,0 +1,208 @@ +// SPDX-License-Identifier: Apache-2.0 + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "test_support.hpp" + +// Regression test for docs/findings/035 +// (remote-server-execute-reordering.md): RemoteServer::handle() posts every +// envelope to the shared worker pool before any per-model ordering exists, so +// two `execute` envelopes for the *same* model, sent back-to-back on one +// connection, can reach the model's own strand out of send order the moment +// more than one pool worker is free to race the pre-strand work +// (decode/authorize/authenticate/registry-lookup) ahead of the other. +// +// examples/common/testkit/test_fault_proxy.cpp's `FaultProxy::dropReply` test +// caught this incidentally (it happens to send two calls close together) but +// relies on real OS thread scheduling to hit the race, so it passed on every +// quiet/fast run and only failed, intermittently, under CI load — not a +// reliable reproduction on its own. +// +// This test forces the exact interleaving instead of hoping for it: a real +// ThreadPoolExecutor{2} (so the two calls' pre-strand work can genuinely run +// concurrently, on separate threads, exactly as in production), paired with a +// custom IAuthorizer whose `authorize()` deliberately sleeps for call A's +// invocation only. That guarantees call B's pre-strand work (which never +// sleeps) finishes first on every run, deterministically — call B's own +// pool thread reaches the point where it would call `_strand.post(mid, ...)` +// while call A's thread is still sleeping inside `authorize()`, on every +// single run of this test, not just probabilistically. A +// DeterministicExecutor-based version (single-threaded, step-driven) was +// tried first and does not work for this: it cannot model "B's pool thread +// blocks waiting for A to make progress" without a second real thread to +// make that progress — DeterministicExecutor only runs one task to +// completion at a time, so a fix that makes B legitimately wait for A +// deadlocks it. Real threads are required to exercise the actual blocking +// wait finding 035's fix introduces. + +namespace { + +// Deliberately at namespace scope, not inside an anonymous namespace: glz's +// reflection (which the model/action registration below relies on to +// serialize these types across the wire) needs external linkage on the type +// -- see glaze/reflection/get_name.hpp's `extern const T external`, and this +// file's own sibling examples/common/testkit/test_fault_proxy.cpp's identical +// note on FaultProbeAdd/FaultProbeCounter. (This anonymous namespace wraps +// only the authorizer and helper functions below, none of which need +// external linkage; EroAddAction/EroCounterModel are defined just outside +// it, further down, for exactly that reason.) + +/// @brief Allow-all authorizer whose `authorize()` sleeps once, for the +/// first call it sees carrying `EroAddAction::by == kSlowByValue` — +/// every other call (including a second `by == kSlowByValue` call, +/// should a future edit to this test ever add one) returns +/// immediately. This is what turns "the race might happen" into "the +/// race always happens": call A's pre-strand work is held up right +/// here, in `dispatchExecute`'s own authorize() step, for long enough +/// that call B's identical pre-strand work — running concurrently on +/// the pool's other thread — reliably finishes first and reaches the +/// ticket-wait point before A ever does. +class SlowFirstAuthorizer : public morph::session::IAuthorizer { + public: + [[nodiscard]] bool authorize(const morph::session::Context&, std::string_view, std::string_view) const override { + if (!_slowCallTaken.exchange(true)) { + std::this_thread::sleep_for(std::chrono::milliseconds{200}); + } + return true; + } + + private: + mutable std::atomic _slowCallTaken{false}; +}; + +} // namespace + +struct EroAddAction { + int by = 0; +}; + +// A running total, not a pure function of the action -- mirrors +// FaultProbeCounter in test_fault_proxy.cpp: only an accumulator can +// distinguish "processed out of order" from "processed in order", since the +// wrong order still produces *a* plausible-looking total, just the wrong one. +struct EroCounterModel { + int value = 0; + int execute(EroAddAction action) { + value += action.by; + return value; + } +}; + +template <> +struct morph::model::ModelTraits { + static constexpr std::string_view typeId() { return "ERO_CounterModel"; } +}; +template <> +struct morph::model::ActionTraits { + using Result = int; + static constexpr std::string_view typeId() { return "ERO_AddAction"; } + static std::string toJson(const EroAddAction& action) { return "{\"by\":" + std::to_string(action.by) + "}"; } + static EroAddAction fromJson(std::string_view json) { + EroAddAction action; + // Minimal hand-rolled parse -- the fixed shape ({"by":N}) doesn't + // justify pulling in glaze here; every sibling RemoteServer test in + // this directory (test_remote_connection_scope.cpp's CsSquareAction, + // etc.) round-trips through the real ActionDispatcher via glaze + // instead, but this model only needs `execute()` reached directly + // from RemoteServer's own decode path, which calls fromJson() itself. + auto pos = json.find(':'); + if (pos != std::string_view::npos) { + action.by = std::stoi(std::string{json.substr(pos + 1, json.find('}') - pos - 1)}); + } + return action; + } + 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}); } +}; + +namespace { + +using morph::testing::WaitReply; + +morph::model::detail::ActionDispatcher& eroDispatcher() { + static morph::model::detail::ActionDispatcher dispatcher = [] { + morph::model::detail::ActionDispatcher d; + d.registerAction("ERO_CounterModel", "ERO_AddAction"); + return d; + }(); + return dispatcher; +} + +morph::model::detail::ModelRegistryFactory& eroRegistry() { + static morph::model::detail::ModelRegistryFactory registry = [] { + morph::model::detail::ModelRegistryFactory r; + r.registerModel("ERO_CounterModel"); + return r; + }(); + return registry; +} + +} // namespace + +TEST_CASE("RemoteServer::handle() preserves send order for two same-model executes " + "even when the second one's pre-strand work finishes first", + "[remote][execute-ordering]") { + morph::exec::ThreadPoolExecutor pool{2}; + auto authorizer = std::make_shared(); + auto server = std::make_shared(pool, authorizer, eroDispatcher(), eroRegistry()); + + WaitReply regReply; + server->handle(morph::wire::encode(morph::wire::makeRegister("ERO_CounterModel")), std::ref(regReply)); + REQUIRE(regReply.await()); + REQUIRE(regReply.env.kind == "ok"); + const auto modelId = regReply.env.modelId; + REQUIRE(modelId != 0U); + + // Two execute envelopes for the SAME model, sent back-to-back on the + // same (simulated) connection -- call A (by=10) first, call B (by=100) + // second, exactly like two requests arriving close together. handle() + // returns immediately in both cases (it only posts to the pool), so + // these two calls are made in strict program order here, mirroring two + // messages arriving in that order over one WebSocket connection. + morph::wire::Envelope reqA; + reqA.kind = "execute"; + reqA.callId = 1; + reqA.modelId = modelId; + reqA.modelType = "ERO_CounterModel"; + reqA.actionType = "ERO_AddAction"; + reqA.body = R"({"by":10})"; + WaitReply replyA; + server->handle(morph::wire::encode(reqA), std::ref(replyA)); + + morph::wire::Envelope reqB = reqA; + reqB.callId = 2; + reqB.body = R"({"by":100})"; + WaitReply replyB; + server->handle(morph::wire::encode(reqB), std::ref(replyB)); + + // SlowFirstAuthorizer guarantees B's authorize() call (and everything + // after it in B's pre-strand work) finishes before A's does -- A is the + // first call reaching authorize() program-order, so it is the one held + // up. Without finding 035's fix, this is precisely the interleaving that + // lets B's execute reach the model's strand before A's, even though the + // client sent A first. + REQUIRE(replyA.await(std::chrono::milliseconds{5000})); + REQUIRE(replyB.await(std::chrono::milliseconds{5000})); + REQUIRE(replyA.env.kind == "ok"); + REQUIRE(replyB.env.kind == "ok"); + + // The load-bearing assertion: A (by=10) must be applied before B + // (by=100) resolves, because the client sent A first. If B's effect was + // applied first (the bug), replyA.env.body is "110" and replyB.env.body + // is "100" -- still internally consistent, still both "ok", but + // backwards relative to send order. Correct behaviour is A settles at + // 10, B settles at 110, in THAT order -- matching send order, not + // whichever pool thread happened to finish its pre-strand work first. + CHECK(replyA.env.body == "10"); + CHECK(replyB.env.body == "110"); +} diff --git a/tests/test_support.hpp b/tests/test_support.hpp index d0851294..2eea1737 100644 --- a/tests/test_support.hpp +++ b/tests/test_support.hpp @@ -13,6 +13,7 @@ #include #include +#include #include #include #include @@ -20,6 +21,7 @@ #include #include #include +#include namespace morph::testing { @@ -116,6 +118,95 @@ class StepExecutor : public ::morph::exec::IExecutor { std::deque> _queue; }; +/// @brief An `IExecutor` that queues every posted task and runs them only +/// when explicitly stepped — never on its own thread. +/// +/// 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 +/// 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. +/// +/// Single-threaded by construction: `post()` just appends to a deque under a +/// mutex (posts can legitimately arrive from other threads — e.g. code under +/// test posting a continuation from inside a running task — but every task +/// itself runs synchronously on whichever thread calls `step()`/ +/// `runSchedule()`). +/// +/// Duplicated from `examples/common/testkit/strand_interleaver.hpp` rather +/// than shared across the two build trees — that header has no reachable +/// include path from `tests/` (`morph_ladder_testkit`'s own include +/// directories do not cover the repo-root `tests/` directory, and +/// `test_support.hpp` is a private header for `morph_tests`' own +/// translation units, not an installed/exported one) — matching this +/// 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 +/// 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 +/// rather than have silently swallowed. +class DeterministicExecutor : public ::morph::exec::IExecutor { + public: + void post(std::function task) override { + std::lock_guard lock{_mtx}; + _queue.push_back(std::move(task)); + } + + /// @return The number of tasks currently queued and not yet run. + [[nodiscard]] std::size_t pending() const { + std::lock_guard lock{_mtx}; + return _queue.size(); + } + + /// @brief Runs the oldest-queued task. Throws if the queue is empty. + void step() { + std::function task; + { + std::lock_guard lock{_mtx}; + if (_queue.empty()) { + throw std::runtime_error("DeterministicExecutor::step: queue is empty"); + } + task = std::move(_queue.front()); + _queue.pop_front(); + } + task(); + } + + /// @brief Runs tasks in the exact order given, by *current* queue + /// position at the moment each entry is consumed — so a task that + /// posts new work mid-schedule is reflected in later indices. + /// `order` must name every index that will exist by the time it's + /// reached; the simplest correct schedule is just `{0, 1, ..., n-1}` + /// run one at a time via repeated `step()` calls when a test only + /// wants strict FIFO — `runSchedule` exists for tests that + /// deliberately want a *non*-FIFO interleaving. + /// @param order The queue indices to run, in caller-chosen order, each + /// read against the queue's *current* contents at the + /// moment it is consumed (see above). + void runSchedule(const std::vector& order) { + for (auto index : order) { + std::function task; + { + std::lock_guard lock{_mtx}; + if (index >= _queue.size()) { + throw std::runtime_error("DeterministicExecutor::runSchedule: index beyond current queue size"); + } + task = std::move(_queue[index]); + _queue.erase(_queue.begin() + static_cast(index)); + } + task(); + } + } + + private: + mutable std::mutex _mtx; + std::deque> _queue; +}; + /// @brief Default polling budget for `waitUntil`. Picked to cover the slowest /// TSan/Valgrind runs without making green tests visibly slow. inline constexpr std::chrono::milliseconds kDefaultWaitBudget{2000}; diff --git a/vcpkg.json b/vcpkg.json index f2f74610..bf08b156 100644 --- a/vcpkg.json +++ b/vcpkg.json @@ -4,7 +4,9 @@ "version": "0.1.0", "dependencies": [ "glaze", - "catch2" + "catch2", + "yaml-cpp", + "libzip" ], "builtin-baseline": "c3867e714dd3a51c272826eea77267876517ed99" }