From c54ae03c0a319aa846bf7dcb72d78981b71204ce Mon Sep 17 00:00:00 2001 From: Yaraslau Tamashevich Date: Fri, 14 Aug 2026 15:17:50 +0300 Subject: [PATCH 1/7] ladder: shared infrastructure, docs, and framework prerequisites (rung 0) Split out of the application-ladder branch (236 files, 32.8k insertions) into its own reviewable PR, per LADDER.md's own framing: rung 0 has no app of its own, only the shared foundation every later rung builds on. This is that foundation, with no rung's application code (pastebin, bookmarks, polls) included -- those land as their own follow-up PRs. Contents: - CI/CMake: .github/workflows/ci.yml, wasm-ladder.yml, cmake/morph_add_rung.cmake, cmake/compiler_options.cmake, codecov.yml, top-level CMakeLists.txt/vcpkg.json changes needed to build an opt-in ladder (MORPH_BUILD_LADDER) alongside the existing example/test targets without disturbing them. - Cross-rung docs: examples/LADDER.md, IMPLEMENTATION.md, TESTING.md, FINDINGS.md -- the two binding companion documents (how apps are written, how they're tested) every rung is held to, plus the finding pipeline's scoreboard/triage process. - Design-annex README stubs: examples/{crm,forge,kanban,ledger,lims}/README.md -- rungs 4-8, each a finished requirements study; building any of them is a separate decision taken after rung 4, per LADDER.md's own program scope note. No code, docs only. - Shared examples/common: the GUI presenter base (presenter.hpp, non- template QObject bookkeeping every rung's presenters build on), AppContext (LocalBackend/QtWebSocketBackend/WASM mode switch), the injectable clock, and the full testkit (BackendRig, DbFixture family, fault-injection proxy, strand interleaver, event poller) every rung's test suite depends on -- all covered by its own unit tests (ladder_common_tests). - Framework prerequisites the rungs needed and that landed here first: registry.hpp/remote.hpp changes, exercised by tests/test_quantity_forms.cpp and the new tests/test_remote_execute_ordering.cpp. - The lint-gate scripts/tests already merged via #85 (check_test_type_names.sh + its test fixtures), carried forward from the earlier merge into this branch. Verified standalone: configures and builds with -DMORPH_BUILD_LADDER=ON -DMORPH_BUILD_QT=ON against master's examples/CMakeLists.txt loop, which already tolerates rung 1+ directories not existing yet ("no rung exists yet at rung 0" is a real, working code path, not a placeholder). Full suite passes standalone: morph_tests (9773 assertions), morph_qt_tests (496 assertions), ladder_common_tests (212 assertions against its SQLite default). Spec-citation, banned-terminology, and test-type-name lints all pass. Co-Authored-By: Claude Sonnet 5 --- .github/workflows/ci.yml | 237 +++++++- .github/workflows/wasm-ladder.yml | 149 +++++ .gitignore | 4 + CMakeLists.txt | 42 +- cmake/compiler_options.cmake | 3 + cmake/morph_add_rung.cmake | 494 ++++++++++++++++ codecov.yml | 158 ++++- docs/spec/core/backend.md | 11 + docs/spec/core/shared_instances.md | 50 +- docs/spec/forms/forms.md | 4 + examples/CMakeLists.txt | 38 ++ examples/FINDINGS.md | 86 +++ examples/IMPLEMENTATION.md | 265 +++++++++ examples/LADDER.md | 308 ++++++++++ examples/TESTING.md | 456 +++++++++++++++ examples/common/CMakeLists.txt | 247 ++++++++ examples/common/clock.hpp | 84 +++ examples/common/gui/app_context.cpp | 87 +++ examples/common/gui/app_context.hpp | 144 +++++ examples/common/gui/event_poller.cpp | 32 + examples/common/gui/event_poller.hpp | 552 ++++++++++++++++++ examples/common/gui/presenter.cpp | 6 + examples/common/gui/presenter.hpp | 158 +++++ examples/common/testkit/backend_rig.hpp | 412 +++++++++++++ examples/common/testkit/db_busy_fixture.hpp | 123 ++++ examples/common/testkit/db_fault_fixture.hpp | 56 ++ examples/common/testkit/db_fixture.hpp | 120 ++++ examples/common/testkit/db_pool_drain.hpp | 57 ++ examples/common/testkit/fault_proxy.cpp | 162 +++++ examples/common/testkit/fault_proxy.hpp | 246 ++++++++ examples/common/testkit/pump.hpp | 136 +++++ .../common/testkit/strand_interleaver.hpp | 107 ++++ examples/common/testkit/test_backend_rig.cpp | 280 +++++++++ examples/common/testkit/test_clock.cpp | 55 ++ .../common/testkit/test_db_busy_fixture.cpp | 114 ++++ .../common/testkit/test_db_fault_fixture.cpp | 66 +++ examples/common/testkit/test_db_fixture.cpp | 110 ++++ .../common/testkit/test_db_pool_drain.cpp | 54 ++ examples/common/testkit/test_event_poller.cpp | 495 ++++++++++++++++ examples/common/testkit/test_fault_proxy.cpp | 390 +++++++++++++ examples/common/testkit/test_presenter.cpp | 258 ++++++++ examples/common/testkit/test_pump.cpp | 114 ++++ .../testkit/test_strand_interleaver.cpp | 124 ++++ .../test_wasm_registration_path_native.cpp | 110 ++++ examples/common/testkit/testkit_main.cpp | 36 ++ examples/common/wasm_spike/CMakeLists.txt | 36 ++ examples/common/wasm_spike/README.md | 76 +++ examples/common/wasm_spike/main_wasm.cpp | 112 ++++ examples/common/wasm_spike/spike_model.hpp | 21 + examples/crm/README.md | 183 ++++++ examples/forge/README.md | 192 ++++++ examples/kanban/README.md | 165 ++++++ examples/ledger/README.md | 164 ++++++ examples/lims/README.md | 174 ++++++ include/morph/core/registry.hpp | 11 +- include/morph/core/remote.hpp | 246 +++++++- scripts/coverage.sh | 66 ++- src/qt/forms/CMakeLists.txt | 8 +- tests/CMakeLists.txt | 1 + tests/test_quantity_forms.cpp | 5 +- tests/test_remote_execute_ordering.cpp | 208 +++++++ tests/test_support.hpp | 91 +++ vcpkg.json | 4 +- 63 files changed, 8950 insertions(+), 53 deletions(-) create mode 100644 .github/workflows/wasm-ladder.yml create mode 100644 cmake/morph_add_rung.cmake create mode 100644 examples/CMakeLists.txt create mode 100644 examples/FINDINGS.md create mode 100644 examples/IMPLEMENTATION.md create mode 100644 examples/LADDER.md create mode 100644 examples/TESTING.md create mode 100644 examples/common/CMakeLists.txt create mode 100644 examples/common/clock.hpp create mode 100644 examples/common/gui/app_context.cpp create mode 100644 examples/common/gui/app_context.hpp create mode 100644 examples/common/gui/event_poller.cpp create mode 100644 examples/common/gui/event_poller.hpp create mode 100644 examples/common/gui/presenter.cpp create mode 100644 examples/common/gui/presenter.hpp create mode 100644 examples/common/testkit/backend_rig.hpp create mode 100644 examples/common/testkit/db_busy_fixture.hpp create mode 100644 examples/common/testkit/db_fault_fixture.hpp create mode 100644 examples/common/testkit/db_fixture.hpp create mode 100644 examples/common/testkit/db_pool_drain.hpp create mode 100644 examples/common/testkit/fault_proxy.cpp create mode 100644 examples/common/testkit/fault_proxy.hpp create mode 100644 examples/common/testkit/pump.hpp create mode 100644 examples/common/testkit/strand_interleaver.hpp create mode 100644 examples/common/testkit/test_backend_rig.cpp create mode 100644 examples/common/testkit/test_clock.cpp create mode 100644 examples/common/testkit/test_db_busy_fixture.cpp create mode 100644 examples/common/testkit/test_db_fault_fixture.cpp create mode 100644 examples/common/testkit/test_db_fixture.cpp create mode 100644 examples/common/testkit/test_db_pool_drain.cpp create mode 100644 examples/common/testkit/test_event_poller.cpp create mode 100644 examples/common/testkit/test_fault_proxy.cpp create mode 100644 examples/common/testkit/test_presenter.cpp create mode 100644 examples/common/testkit/test_pump.cpp create mode 100644 examples/common/testkit/test_strand_interleaver.cpp create mode 100644 examples/common/testkit/test_wasm_registration_path_native.cpp create mode 100644 examples/common/testkit/testkit_main.cpp create mode 100644 examples/common/wasm_spike/CMakeLists.txt create mode 100644 examples/common/wasm_spike/README.md create mode 100644 examples/common/wasm_spike/main_wasm.cpp create mode 100644 examples/common/wasm_spike/spike_model.hpp create mode 100644 examples/crm/README.md create mode 100644 examples/forge/README.md create mode 100644 examples/kanban/README.md create mode 100644 examples/ledger/README.md create mode 100644 examples/lims/README.md create mode 100644 tests/test_remote_execute_ordering.cpp 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..1b8cc3c1 --- /dev/null +++ b/.github/workflows/wasm-ladder.yml @@ -0,0 +1,149 @@ +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. + - 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 + cmake --build build-wasm-ladder --target ladder_pastebin_gui_wasm + cmake --build build-wasm-ladder --target ladder_bookmarks_gui_wasm + cmake --build build-wasm-ladder --target ladder_polls_gui_wasm + # 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..97914848 --- /dev/null +++ b/examples/common/testkit/test_presenter.cpp @@ -0,0 +1,258 @@ +// 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} {} + + 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 +} 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_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_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" } From fb1a1aceb3b20df87a0076b4b7088c9460fa1ca7 Mon Sep 17 00:00:00 2001 From: Yaraslau Tamashevich Date: Fri, 14 Aug 2026 23:09:07 +0300 Subject: [PATCH 2/7] ci: gate wasm-ladder.yml's per-rung build targets on the rung existing The foundation PR (this branch) deliberately ships no rung code -- the per-rung PRs (pastebin/bookmarks/polls) land separately, based on this branch. wasm-ladder.yml's "build every rung's WASM client by name" tripwire step hardcoded all three targets unconditionally, so the job failed here with "ninja: error: unknown target 'ladder_pastebin_gui_wasm'" -- there is no examples/pastebin on this branch to produce that target. Fix: each named target now only builds if its rung's directory exists in the checkout. This is the same "no rung exists yet" case morph_add_rung.cmake's own header comment already documents as a silent, expected outcome (a rung with no gui_wasm/ yet simply gets no ladder__gui_wasm target) -- not the regression this tripwire exists to catch. Once a rung's directory is present (every other branch/PR, including once the per-rung PRs merge here), the tripwire is unchanged: morph_add_rung() skipping a rung's gui_wasm target for any other reason (missing gui_lib, missing QML module, MORPH_BUILD_FORMS_QML off) still fails the job. Co-Authored-By: Claude Sonnet 5 --- .github/workflows/wasm-ladder.yml | 21 ++++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/.github/workflows/wasm-ladder.yml b/.github/workflows/wasm-ladder.yml index 1b8cc3c1..dd34fce0 100644 --- a/.github/workflows/wasm-ladder.yml +++ b/.github/workflows/wasm-ladder.yml @@ -132,13 +132,28 @@ jobs: # 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 - cmake --build build-wasm-ladder --target ladder_pastebin_gui_wasm - cmake --build build-wasm-ladder --target ladder_bookmarks_gui_wasm - cmake --build build-wasm-ladder --target ladder_polls_gui_wasm + 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 From a2ec27e3316d5d2083bf991bd79bccd733117e8d Mon Sep 17 00:00:00 2001 From: Yaraslau Tamashevich Date: Sat, 15 Aug 2026 12:47:06 +0300 Subject: [PATCH 3/7] tests: close three of remote.hpp's codecov/patch coverage gaps PR #88's patch coverage was 94.31% against a 97.37% target, almost entirely traced to remote.hpp's new AllowShared/keyed-model attach-path logic (registry.hpp's own two changed lines are fully exercised already by every registered model's toJson()/resultToJson() call). Of the 31 uncovered lines there, three clusters were reachable deterministically through the public API, with no timing race needed at the test level: - attachExistingLocked's "connection closed mid-attach" reply (remote.hpp:647-650): closeConnection() and handle() are both synchronous under _regMtx, so calling closeConnection(cid) before an attach carrying that same cid presents noteScopeAttachLocked()'s precondition directly, every run. - "assign"'s authenticate()-succeeds branch (remote.hpp:1002): every existing assign test leaves the caller unauthenticated; a session carrying a principal against an authenticator that echoes it back exercises the other side of the same if/else. - SimulatedRemoteBackend::assignPrimary's early-return guard (remote.hpp:1724-1725): a plain black-box call with an empty primary or a zero ModelId, confirmed by checking the key was never filed. The remaining ~24 lines are genuine concurrency races (two calls racing the same locked section within microseconds -- "a concurrent request for the same key may have won the race," maxLiveModels/maxInFlightExecutes overshoot windows, ticket-release edge cases) or pure defensive assertions unreachable through the public API (a malformed reply body RemoteServer itself never produces). These are left as the source's own comments already frame them -- deliberately rare, self-documenting branches -- rather than forcing fragile, precisely-timed tests to chase the last few points of patch coverage. Co-Authored-By: Claude Sonnet 5 --- tests/test_coverage_push95.cpp | 34 ++++++++ tests/test_remote_connection_scope.cpp | 104 +++++++++++++++++++++++++ 2 files changed, 138 insertions(+) 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_remote_connection_scope.cpp b/tests/test_remote_connection_scope.cpp index 7dcad38f..edcd5152 100644 --- a/tests/test_remote_connection_scope.cpp +++ b/tests/test_remote_connection_scope.cpp @@ -339,6 +339,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 +749,57 @@ TEST_CASE("morph::backend::RemoteServer: assign never displaces the incumbent ho REQUIRE(again.env.modelId == incumbent.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}; From 98bb28f96fd1af9f10160c5f63a899e87be8a991 Mon Sep 17 00:00:00 2001 From: Yaraslau Tamashevich Date: Sat, 15 Aug 2026 13:46:11 +0300 Subject: [PATCH 4/7] tests: force three more of remote.hpp's genuine concurrency races Follow-up to a2ec27e: closes four more of the previously-uncovered lines (31 -> conservatively down to the low 20s once codecov re-ingests), using the same slow-first idiom test_remote_execute_ordering.cpp established (a factory/authorizer that sleeps once, exchange-guarded, on the first call it sees) to force a deterministic winner/loser instead of hoping real thread scheduling happens to interleave correctly: - acquireSharedInstance's second attachExistingLocked check (remote.hpp): two attaches to the same not-yet-existing shared key, where the first dispatched is reliably still in its slow _registry.create() when the second's fast create+insert completes, forcing the first to find the directory already populated on its own re-check. - The private register path's maxLiveModels authoritative re-test (remote.hpp): same idiom, applied to two plain registers racing the cap instead of a shared-key insert. - applyAssignLocked's early-return guard (remote.hpp): no timing needed here at all -- an empty primary or an unregistered modelId is a deterministic precondition. Every existing assign test in this file assigns a real, just-created mid onto a non-empty primary, so this branch (env.primary.empty() || !_models.contains(mid)) had simply never been exercised. One more attempt -- maxInFlightExecutes' compare-exchange loop losing a race -- needed a second iteration to stop being flaky: the first version used CS_SquareModel (an instantly-completing action), which let the winning call's entire execute -- including the decrement back out of _inFlightExecutes -- finish before the losing call's own CAS check ever ran, so both calls could observe an empty slot and both would succeed; measured at roughly 40% failure across 20 repeated local runs. Switched to CS_SlowModel (blocks in execute() until released), which keeps the winner's slot genuinely held until the loser's CAS check has run; confirmed clean across 20 repeated local runs (and the full suite, 3x) after the fix. Line 747 (acquireSharedInstance's own final "ok" reply, immediately after the closing brace of a fully-covered locked block) is left as an llvm-cov region-boundary artifact, not a real gap: every line inside that block is covered, this test file's existing "two connections sharing a key reach one instance" case already reaches it, and it is not reachable through any different path this session could add coverage against. Co-Authored-By: Claude Sonnet 5 --- tests/test_remote_connection_scope.cpp | 252 +++++++++++++++++++++++++ 1 file changed, 252 insertions(+) diff --git a/tests/test_remote_connection_scope.cpp b/tests/test_remote_connection_scope.cpp index edcd5152..e6e0ae6b 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; @@ -749,6 +774,52 @@ 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]") { @@ -931,3 +1002,184 @@ 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. + REQUIRE(morph::testing::waitUntil( + [&] { return replyA.env.kind == "err" || replyB.env.kind == "err"; }, std::chrono::milliseconds{2000})); + + const bool aErr = replyA.env.kind == "err"; + const bool bErr = 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"); +} From dfed89820704873d110908931ad91af332f3d3e6 Mon Sep 17 00:00:00 2001 From: Yaraslau Tamashevich Date: Sat, 15 Aug 2026 13:52:56 +0300 Subject: [PATCH 5/7] tests: fix a genuine TSan-caught data race in the maxInFlightExecutes race test CI's clang-tsan leg caught a real bug in 98bb28f's new test: it polled replyA.env.kind/replyB.env.kind directly from the main thread while a pool worker thread was concurrently writing WaitReply::env in the reply callback. WaitReply's only synchronization is its `ready` atomic (release-store on write, meant to be acquire-loaded before env is read -- see its own doc comment/every other use in this file, all gated by .await()); reading .env before observing .ready == true is exactly the race TSan flagged, not a false positive. Fix: poll .ready.load() instead of .env.kind directly, and only read .env after confirming .ready is true (short-circuited via &&), matching the synchronization every other WaitReply-based assertion in this file already relies on via .await(). Verified 20x locally after the fix, plus a clean full-suite run; the other two new race tests in the same commit already followed this pattern correctly (each gates its .env access behind .await()) and needed no change. Co-Authored-By: Claude Sonnet 5 --- tests/test_remote_connection_scope.cpp | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/tests/test_remote_connection_scope.cpp b/tests/test_remote_connection_scope.cpp index e6e0ae6b..0e5c1125 100644 --- a/tests/test_remote_connection_scope.cpp +++ b/tests/test_remote_connection_scope.cpp @@ -1168,12 +1168,18 @@ TEST_CASE( // 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. + // 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.env.kind == "err" || replyB.env.kind == "err"; }, std::chrono::milliseconds{2000})); + [&] { return replyA.ready.load() || replyB.ready.load(); }, std::chrono::milliseconds{2000})); - const bool aErr = replyA.env.kind == "err"; - const bool bErr = replyB.env.kind == "err"; + 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"); From 42a5c292c87bafd3f16b7dcc8d70f6428e153777 Mon Sep 17 00:00:00 2001 From: Yaraslau Tamashevich Date: Sat, 15 Aug 2026 13:59:02 +0300 Subject: [PATCH 6/7] tests: cover dispatchExecute's exception-path executeTimeout cancel remote.hpp's uncovered-line count is down to 11 as of dfed898 (from the original 31): every existing executeTimeout test either lets the timeout actually fire, or lets a normal action complete well inside the budget -- none combine a *throwing* action with executeTimeout configured, so the catch block's own _timeoutScheduler->cancel() call had never run. CsSquareFail (already registered in this file's csEnv()) throws synchronously; pairing it with executeTimeout confirms the "err"/message reaches the caller (not a stale "timeout" landing later) and that the server is still healthy afterward. Remaining uncovered lines in remote.hpp, left as-is: - 716-717: releaseScopedLocked's call inside the create-path race branch, for the specific sub-case where the *losing* side of that race is itself re-pointing from an existing instance (releaseCurrent != 0) -- a three-actor setup (existing instance + two racing attaches) on top of the slow-factory technique already used for 715/718/719; not attempted this round. - 747: acquireSharedInstance's own final "ok" reply, immediately after a fully-covered locked block -- confirmed (previous commit) to be an llvm-cov region-boundary artifact, not a real gap. - 1467-1468, 1489-1490: awaitExecuteTurn/releaseExecuteTicket's defensive checks -- reachable only by a genuine multi-ticket race (the former) or a state the codebase's own invariants say cannot happen at all (the latter, per its own "should not happen" comment). - 1743-1744: SimulatedRemoteBackend::listInstances' decode-failure throw -- RemoteServer's own reply is always well-formed JSON, so nothing reachable through the public API can trigger this. Co-Authored-By: Claude Sonnet 5 --- tests/test_remote_connection_scope.cpp | 55 ++++++++++++++++++++++++++ 1 file changed, 55 insertions(+) diff --git a/tests/test_remote_connection_scope.cpp b/tests/test_remote_connection_scope.cpp index 0e5c1125..ea887450 100644 --- a/tests/test_remote_connection_scope.cpp +++ b/tests/test_remote_connection_scope.cpp @@ -1189,3 +1189,58 @@ TEST_CASE( 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"); +} From 1c62c2aab080aa3d49ea2d5beaf67d75df304e0d Mon Sep 17 00:00:00 2001 From: Yaraslau Tamashevich Date: Sat, 15 Aug 2026 14:16:15 +0300 Subject: [PATCH 7/7] tests: cover Presenter::trackBound(), previously never exercised trackBound() (presenter.hpp) had no test at all: every ProbePresenter test in this file drives track()'s success/error paths, none touch bound()/trackBound(). Two cases: - Local mode's handler is already bound by construction, so whenBound()'s Completion settles on the first event-loop turn; asserts bound() fires exactly once, observed via a real Qt connection. - trackBound()'s QPointer guard exists specifically for a presenter destroyed before whenBound()'s posted completion resolves -- constructs one in a nested scope, destroys it, then pumps to let the still-pending completion actually run. Nothing to assert beyond "does not crash" (a real use-after-free here would be caught by the ASan/UBSan CI legs, not by a plain logic assertion). Also investigated (not fixed) three further apparent gaps in this PR's diff, each traced to the same root cause -- llvm-cov's per-instantiation line-coverage reporting not merging cleanly across many distinct template instantiations of the same header-only function: - examples/common/testkit/pump.hpp: pumpUntil's timeout branch (lines 74-79) and awaitQt's deadline-throw (112-113) each show up to 15+ times in the report, once per distinct lambda-type instantiation across the whole test suite. Both branches are genuinely tested (test_pump.cpp's own "pumpUntil returns false on timeout without hanging" and "awaitQt timeout does not leave dangling references" cases) -- every *other* call site's own instantiation just never happens to time out, which is the correct, intended behavior for a passing test, not a coverage gap to chase. - examples/common/gui/presenter.hpp lines 23-25/63 (Q_OBJECT, constructor, trackBound's signature) and testkit/fault_proxy.hpp lines 109-134 (FaultProxy's own Q_OBJECT/declarations): moc-adjacent declaration lines reporting 0 hits despite their out-of-line definitions/call sites being exercised elsewhere -- consistent with the same class of report-level artifact already confirmed twice this session (remote.hpp:747, backend_rig.hpp's client() throw) via CI logs proving the relevant tests actually ran and passed. Co-Authored-By: Claude Sonnet 5 --- examples/common/testkit/test_presenter.cpp | 47 ++++++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/examples/common/testkit/test_presenter.cpp b/examples/common/testkit/test_presenter.cpp index 97914848..523bd44c 100644 --- a/examples/common/testkit/test_presenter.cpp +++ b/examples/common/testkit/test_presenter.cpp @@ -46,6 +46,12 @@ class ProbePresenter : public morph::ladder::gui::Presenter { 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; }); } @@ -256,3 +262,44 @@ TEST_CASE("AppContext{Remote} defers readiness to the first connect", 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"); +}