Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
78 changes: 59 additions & 19 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -802,6 +802,15 @@ jobs:
QT_QPA_PLATFORM: offscreen
run: cmake --build --preset clang-tsan

# The same assertion linux-sanitizers and ladder-sanitizers make, for the
# same reason (morph#542): this job's whole claim is that kanban's stress
# test ran under ThreadSanitizer, and an uninstrumented binary makes that
# claim while proving nothing. Keyed on `__tsan_`, not `__asan_` — an
# ASan-only assertion on a TSan leg is itself a control that measures
# nothing.
- name: Every ctest binary is instrumented
run: bash scripts/check_sanitizer_instrumentation.sh build/clang-tsan tsan

# Every ladder ctest case only ever carries the "ladder"/"ladder-<rung>"
# labels morph_add_rung() applies (cmake/morph_add_rung.cmake) — Catch2's
# own tags ([kanban][stress][tsan]) are never translated into ctest
Expand All @@ -814,6 +823,14 @@ jobs:
- name: Test (kanban's TSan-tagged stress test only)
env:
QT_QPA_PLATFORM: offscreen
# The suppression file linux-sanitizers' Test step already passes,
# missing here (morph#542). Both entries are libstdc++ refcounts for
# a shared exception, decremented inside an uninstrumented
# libstdc++.so.6 (morph#476); the evidence is in cmake/tsan.supp.
# Without it this leg is a coin toss on a false positive that the
# other TSan leg already knows is one — and the failure mode of a
# known false positive is that the next real one is waved past.
TSAN_OPTIONS: suppressions=${{ github.workspace }}/cmake/tsan.supp
run: ctest --preset clang-tsan -L ladder-kanban -R ThreadSanitizer --output-on-failure

# ── Linux: Qt WebSocket backend build + tests ─────────────────────────
Expand Down Expand Up @@ -1502,26 +1519,19 @@ jobs:
# checked". That is not hypothetical — before the AF_SANITIZER blocks
# landed in cmake/morph_add_rung.cmake and examples/common/CMakeLists.txt,
# -DAF_SANITIZER=asan instrumented morph_tests and nothing under
# examples/. Assert the instrumentation is really there before trusting
# a green run.
- name: Verify rung binaries are actually instrumented
# examples/.
#
# This used to be a hand-rolled `nm | grep __asan_` over
# `examples/*/ladder_*_tests` alone, which is the same shape of blind
# spot one level up: it asserted the instrumentation of the binaries
# someone thought to name, and this job also builds tests/qt,
# examples/qt_tls_client and examples/concepts, none of which the glob
# reaches (morph#542). The shared script walks what ctest will actually
# run instead of a pattern, so a suite added tomorrow is covered by
# having been added.
- name: Every ctest binary is instrumented
if: steps.filter.outputs.run == 'true'
run: |
set -euo pipefail
missing=0
for exe in build/clang-asan/examples/*/ladder_*_tests; do
[ -x "$exe" ] || continue
if nm -C "$exe" 2>/dev/null | grep -q "__asan_"; then
echo "instrumented: $exe"
else
echo "NOT INSTRUMENTED: $exe" >&2
missing=1
fi
done
if [ "$missing" -ne 0 ]; then
echo "::error::ladder test binaries were built without ASan instrumentation" >&2
exit 1
fi
run: bash scripts/check_sanitizer_instrumentation.sh build/clang-asan asan

# detect_leaks=0: LeakSanitizer reports allocations the Qt platform
# plugins and QML engine intentionally keep for process lifetime, which
Expand All @@ -1536,6 +1546,36 @@ jobs:
UBSAN_OPTIONS: print_stacktrace=1:halt_on_error=1
run: ctest --preset clang-asan -L ladder -LE stress --output-on-failure

# The Qt transport suites, which no sanitizer leg ran. They were not even
# instrumented until morph#542, and instrumenting a suite nothing runs
# would swap one vacuous control for another: this is the only leg that
# builds Qt under a sanitizer, so if these run anywhere it is here.
# linux-sanitizers deliberately keeps Qt out of its matrix (see its
# Configure comment), and kanban-tsan runs exactly one named stress test.
#
# Measured before adding this step, on clang 22 with these same options:
# all 67 cases across tests/qt, tests/net_qt_interop and
# qt_tls_example_runs pass clean under -fsanitize=address,undefined. This
# job's configure leaves MORPH_BUILD_NET off, so the filter below selects
# the 57 that exist here -- tests/qt plus the TLS example.
#
# `--no-tests=error` because `-R` over test names is exactly the filter
# that goes stale silently: a renamed TEST_CASE would otherwise leave
# ctest reporting success having run nothing (morph#466 is the same trap
# one level down).
- name: Test (the Qt transport suites, which no sanitizer leg ran)
if: steps.filter.outputs.run == 'true'
env:
QT_QPA_PLATFORM: offscreen
# detect_leaks=0 for the reason the ladder step above gives: Qt's
# platform plugins and QML engine keep allocations for process
# lifetime. The memory-error and UB checks stay fully on.
ASAN_OPTIONS: detect_leaks=0
UBSAN_OPTIONS: print_stacktrace=1:halt_on_error=1
run: |
ctest --preset clang-asan --no-tests=error --output-on-failure \
-R 'morph::qt::|qt_tls_example_runs'

# ── 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
Expand Down
20 changes: 20 additions & 0 deletions .github/workflows/drift-guard.yml
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,14 @@ jobs:
name: Spec-citation, banned-terminology & CI-clang-pin lint
runs-on: ubuntu-24.04
steps:
# fetch-depth: 0 for the spec-sync self-test at the end of this job:
# morph#560's two acceptance cases are named commits (9445bc04 and
# a72787ed), and under the default depth-1 checkout the self-test skips
# them rather than running them -- which would leave the one case that
# reproduces the reported defect never actually exercised in CI.
- uses: actions/checkout@v4
with:
fetch-depth: 0

# Same terms as rung-filter-lint below, and ci.yml's deprecation-lint and
# test-type-name-lint: a lint gate nobody tests reports green whether or
Expand Down Expand Up @@ -134,6 +141,19 @@ jobs:
- name: Self-test the Catch2 test-name checker
run: bash scripts/test_check_catch_test_names.sh

# ── The header <-> spec sync gate's own rule (#560) ──────────────────
# The gate runs in .github/workflows/spec-sync.yml, on a pull_request
# merge ref; its *rule* is scripts/check_spec_sync.sh, and a rule that is
# only ever exercised on the event it gates is one nobody has seen go
# red. It had not: include/morph/net/** was missing from its sub-domain
# list, so an interop-visible WebSocket behaviour change shipped with no
# documentation past a green tick (9445bc04). This drives the rule
# against that commit and against the one that documented it, plus every
# rejection and acceptance it claims -- here, where it costs a few
# seconds and no compilation.
- name: Self-test the header/spec sync gate
run: bash scripts/test_check_spec_sync.sh

- name: Check every test name selects itself as a Catch2 filter
run: bash scripts/check_catch_test_names.sh

Expand Down
45 changes: 17 additions & 28 deletions .github/workflows/spec-sync.yml
Original file line number Diff line number Diff line change
@@ -1,8 +1,13 @@
name: Spec sync

# Enforces that a change to a header sub-domain (include/morph/<sub>/**) is
# accompanied by a change to the matching design-spec folder (docs/spec/<sub>/**),
# so the specs cannot silently drift behind the code they describe.
# accompanied by a change to the docs that document that sub-domain, so the
# specs cannot silently drift behind the code they describe.
#
# Most sub-domains mirror their folder (docs/spec/<sub>/**); morph::net and
# morph::render do not have one and name the files that document them instead.
# The mapping, and the requirement that every sub-domain be either mapped or
# explicitly exempt, are in scripts/check_spec_sync.sh (morph#560).
#
# The gate is skipped when the PR carries the label "no docs update" — a visible,
# reviewable opt-out for changes that genuinely need no spec update.
Expand Down Expand Up @@ -78,29 +83,13 @@ jobs:
echo "$changed"
echo "───────────────────"

# Sub-domains that have a mirrored spec folder. include/morph/detail/**
# and include/morph/qt/** have no specs and are intentionally exempt.
subdomains="core journal offline session forms util"

missing=""
for sub in $subdomains; do
if echo "$changed" | grep -qE "^include/morph/$sub/"; then
if ! echo "$changed" | grep -qE "^docs/spec/$sub/"; then
missing="$missing $sub"
fi
fi
done

if [ -n "$missing" ]; then
echo ""
echo "::error::Header changes without a matching spec update in:$missing"
for sub in $missing; do
echo " - include/morph/$sub/** changed but docs/spec/$sub/** did not."
done
echo ""
echo "Update the design spec(s) for the affected sub-domain(s), or add the"
echo "label 'no docs update' to this PR if no spec change is warranted."
exit 1
fi

echo "Spec sync OK: every touched header sub-domain has a matching spec change."
# The rule itself lives in scripts/check_spec_sync.sh, driven by the
# list of changed paths. It used to be an inline word list here --
# `subdomains="core journal offline session forms util"` -- which
# left include/morph/net/** and include/morph/render/** silently
# exempt, so no change to the reference transport could make this
# gate red (morph#560). A rule in a script can be run against a list
# of paths, which is what lets scripts/test_check_spec_sync.sh drive
# it against the commits it must reject rather than asserting it
# works.
printf '%s\n' "$changed" | bash scripts/check_spec_sync.sh
19 changes: 19 additions & 0 deletions CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -682,6 +682,25 @@ if(MORPH_BUILD_QT)
target_link_libraries(morph_qt_impl PUBLIC morph_qt)
set_target_properties(morph_qt_impl PROPERTIES AUTOMOC ON)

# The Qt WebSocket server and backend are the only *compiled* translation
# units in morph's own transport: everything else a Qt test exercises is
# header-only and so gets instrumented with the test target. Left out, a
# sanitizer leg watched every caller of this code and none of the code
# (morph#542).
#
# Safe here, unlike morph_test_main above, because every consumer of this
# archive is itself instrumented on a sanitizer preset -- the ladder
# targets, examples/common, examples/bank, tests/qt,
# tests/net_qt_interop and examples/qt_tls_client all carry an
# AF_SANITIZER block. apply_sanitizers()'s link options are PRIVATE and do
# not propagate out of a static library, so a consumer that did not would
# fail to link with `__asan_init` undefined rather than quietly lose its
# instrumentation. (The WASM spike links it too and is never built with
# AF_SANITIZER defined -- Emscripten has no such preset here.)
if(DEFINED AF_SANITIZER)
apply_sanitizers(morph_qt_impl ${AF_SANITIZER})
endif()

if(MORPH_BUILD_TESTS)
add_subdirectory(tests/qt)
endif()
Expand Down
14 changes: 14 additions & 0 deletions cmake/compiler_options.cmake
Original file line number Diff line number Diff line change
Expand Up @@ -507,6 +507,20 @@ function(apply_sanitizers target mode)
-fsanitize=undefined -fno-sanitize-recover=undefined -fno-omit-frame-pointer -g)
target_link_options(${target} PRIVATE
-fsanitize=undefined -fno-sanitize-recover=undefined)
else()
# No silent fall-through (morph#541). Without this arm, -DAF_SANITIZER=msan,
# =ASAN or a typo produced a *fully uninstrumented* build that configured,
# compiled and ran the entire suite green -- a sanitizer job reporting
# success having sanitized nothing, which is the failure mode this
# repository has hit most often. A configure-time error is the only place
# the mistake is still cheap: by build time every binary looks normal, and
# scripts/check_sanitizer_instrumentation.sh is the only thing that would
# notice, and only on the legs that run it.
message(FATAL_ERROR
"morph: apply_sanitizers(${target}): unknown sanitizer mode '${mode}'. "
"AF_SANITIZER must be one of: asan, tsan, ubsan (lower case). An "
"unrecognised value would otherwise leave this target uninstrumented "
"while the job that asked for a sanitizer reported success.")
endif()
endfunction()

Expand Down
Loading
Loading