Skip to content

core/cmake: stop a serial dispatch rebuilding a std::deque it never fills, and say what -Wno-shadow-uncaptured-local really suppresses (fixes #660, refs #662) - #671

Merged
Yaraslaut merged 3 commits into
masterfrom
laneSTRAND-batch-660-662
Sep 21, 2026
Merged

Yaraslaut merged 3 commits into
masterfrom
laneSTRAND-batch-660-662

Conversation

@Yaraslaut

Copy link
Copy Markdown
Member

Two tickets on disjoint trees. One closes, one does not — see the #662
section, which is the part worth reading first.

Plus one labelled commit repointing a gate line hint that #660's diff moved.


#660 — commit 1

Re-measured first, as instructed

The 4 allocations / 752 bytes in the ticket came from a pre-#661 tree and #661
changed the dispatch path, so nothing was touched until the instrument #661
landed had been re-run. On 7a343e6f, x86-64 Linux, GCC 16.2.1 / libstdc++,
-O2, tests/bench/bench_dispatch_allocations.cpp:

local execute round-trips  : 200
heap allocations total     : 4159 (20.80 per call)
bytes allocated total      : 398072 (1994.3-1999.9 per call across runs)

Per-line attribution from a backtrace() added to the counting hook — the
method the bench's own header documents — resolved through dladdr (the binary
is PIE, so raw addresses give ??) and addr2line -f -i -C:

  #7    32 bytes  strand.hpp:104   _strands map node
  #8   152 bytes  strand.hpp:106   make_shared<Strand>
  #9    64 bytes  <stl_deque>      the Strand's std::queue deque map
  #10  512 bytes  <stl_deque>      the Strand's std::queue first buffer

760 of 1990 bytes, 38% — the ticket's figure reproduces, at 760 rather than
752 (the make_shared<Strand> block is 152 here, not 144).

What the re-measurement changed about the fix

576 of those 760 bytes are not the strand's lifetime at all. They are
libstdc++'s std::deque allocating a node map and a 512-byte first buffer
in its default constructor, for a queue that in this workload never holds more
than one task. That is morph#660's own direction 2, and it is the one that
needs no change to the lifetime rules.

So std::queue<std::function<void()>> becomes PendingQueue: the head task
lives inside the Strand, and a std::deque is constructed behind it only when
a second task is genuinely queued behind a running one. The locking is
untouched
— same two scoped_locks, same order, same {_mapMtx, strand->mtx}
pair, erase still fires when empty() becomes true.

heap allocations total     : 3786 (18.93 per call)
bytes allocated total      : 279208 (1396.0 per call)

#7    32 bytes   _strands map node
#8   120 bytes   make_shared<Strand>

2 allocations and 608 bytes per local dispatch, 30% of the total.

Directions 1 and 3 are not taken and not smuggled in. Keeping the slot alive
would remove the last two allocations but trades this churn for a per-model
entry nothing reclaims — StrandExecutor has no deregistration hook, so the
erase is the only bound on the map. Filed as #670 with the leftover measured.

The safety claim, and how it was tested

The one claim this branch rests on: PendingQueue changes what the
Strand allocates and nothing about when it is created, erased, or locked —
so the {_mapMtx, strand->mtx} atomicity the comments at strand.hpp:81 and
:167 record survives untouched.

"The suite passes" is not evidence for that, so:

Full ctest under the clang-tsan preset — clang 22.1.8,
-DMORPH_BUILD_NET=ON -DMORPH_BUILD_OFFLINE_SQLITE=ON,
TSAN_OPTIONS=suppressions=cmake/tsan.supp, i.e. the Linux / clang-tsan leg's
own configuration. 1801/1801 pass. Not a blind run:
check_sanitizer_instrumentation.sh build/clang-tsan tsan reports
6 ctest binaries all carry __tsan_ symbols (0 allowlisted).

And that configuration was shown to be able to see this specific hazard.
Restoring the pre-fix two-step drain (flip running under strand->mtx, then
erase under _mapMtx) makes TSan report:

WARNING: ThreadSanitizer: data race (pid=490650)
  Write of size 4 at 0x72100001407c by thread T4:
    #0 LoadCountModel::execute(...) tests/test_concurrency_invariants.cpp:227:15
    ...
    #11 morph::exec::detail::StrandExecutor::scheduleNext(...)::'lambda'()::operator()() const strand.hpp:225:17
SUMMARY: ThreadSanitizer: data race ... in LoadCountModel::execute(...)

Two strand lambdas for one key running the model concurrently — exactly the
defect the combined lock exists to prevent. The fixed tree passes that same test
60/60.

Mutation-tested for coverage of the new code, because a container with an
untested overflow arm is a container that works by luck:

mutation result
_overflow->push_backpush_front 3 cases fail, incl. the 50-task FIFO assertion (49 == 1)
refill-from-overflow arm disabled 8 of 12 [strand] cases fail

Full gcc-release suite: 1561 cases, 22971 assertions, all pass
(1 "failed as expected"). clang 22.1.8 -Weverything -Werror builds clean.
clang-tidy over a TU including strand.hpp, using the project's own Clang flag
set from compiler_options.cmake (not a bare invocation — findings end
[check,-warnings-as-errors]): zero findings in strand.hpp; the six it
does report are pre-existing lines in executor.hpp/logger.hpp that
clang-tidy-diff does not touch.

docs/spec/core/executor.md gains PendingQueue and the measured allocation
figures, replacing the paragraph that said only "the cost is allocation churn".

Not verified for #660

  • The time cost. Nothing here claims a latency change; 608 bytes of
    short-lived churn is a malloc/free pair set, not necessarily visible.
  • libc++, MSVC, WASM. The byte figures are libstdc++'s, and the eager
    std::deque allocation they turn on is an implementation choice. The saving
    may be smaller or absent elsewhere. Nothing regresses — the container is
    strictly smaller everywhere — but the number is one toolchain's.
  • Kanban / ThreadSanitizer. Needs Qt + the ladder; not run locally.
    Linux / clang-tsan was, and it is the leg that covers strand.hpp's own
    tests.
  • Coverage gates. check_branch_coverage.py needs a profile this lane did
    not produce; the allowlist repoint was verified against that script's own
    resolution rule instead.

#660 — commit 2, the gate repoint

PendingQueue inserts 67 lines above the allowlisted
if (iter != _strands.end() && iter->second == strand) {, so
branch_partial_allowlist.json's ST1 entry drifted from line 192 to 259. The
source text is unchanged and matches exactly one line, which is the "update
the hint, the disposition is fine" case resolve_allowlist_source_line() names.
Separate commit, no behaviour, no argument changed.

Re-ran that resolver's rule over every entry in all three allowlists
(branch_partial_allowlist.json, error_path_allowlist.json,
mutation_survivors.json): one drifted before, zero after. No backend.hpp
entry was touched, so the two deliberately-ambiguous citations did not come into
play.


#662 — commit 3, and why it does not close the issue

The fact, established first

clang++ -std=c++23 -fsyntax-only -Weverything, clang 22.1.8, one construct per
function:

construct diagnostic group
[](int value) {...} shadows a local variable -Wshadow-uncaptured-local
[] { int value = 2; ... } shadows a local variable -Wshadow-uncaptured-local
[value = 7] { ... } shadows a local variable -Wshadow-uncaptured-local
[first = first + 1] { ... } shadows a structured binding -Wshadow-uncaptured-local
{ int value = 2; } (no lambda) shadows a local variable -Wshadow
[value] { int value = inner; ... } shadows a local variable -Wshadow

This refines the ticket's framing. The split is not "structured binding
versus local variable" — rows 3 and 4 differ only in what is shadowed and land
in the same group, rows 5 and 6 are both locals and land in -Wshadow. The flag
routes on whether the shadowing declaration is inside a lambda with no
capture-default that did not capture the shadowed entity. So the suppression
means "any declaration inside an uncapturing lambda that shadows an uncaptured
enclosing local or structured binding"
— four constructs, of which the comment
named one, and including the [x = std::move(x)] idiom used throughout this
codebase.

It also rules out the ticket's suggested remedy: "re-enable -Wshadow
explicitly after -Wno-shadow-uncaptured-local" cannot work, because -Wshadow
is already on via -Weverything and the groups are disjoint. clang has no
finer group
, so narrowing to the named construct is not expressible at all.

Sizing the only real alternative

Suppression removed, -Werror swapped for -ferror-limit=0 so the build
completes rather than stopping at the first TU, clang 22.1.8, clang-debug with
NET/QT/FORMS_QML/OFFLINE_SQLITE/LOAD_TESTS/HMAC_EXAMPLES/LADDER/
BANK_EXAMPLE:

total emissions: 4856
--- by message ---
   4856 declaration shadows a local variable [-Wshadow-uncaptured-local]
--- distinct sites ---
41

41 distinct sites, 16 first-party files, 35 of them in headers (which is why
41 sites emit 4856 diagnostics). Eight each in core/backend.hpp and
core/remote.hpp, four each in core/bridge.hpp and core/completion.hpp. No
dependency affected. They are almost entirely the deliberate move-into-
continuation idiom, so the cleanup is 41 renames in the framework's most
lifetime-sensitive headers, for zero behaviour change — not a one-line diff.
Filed with the full breakdown as #669.

Note the second line of that output: zero shadows a structured binding.
#661 fixed the only four, so the class the WASM leg exclusively enforces
currently has no instances. The leg is a trap for the next one written, not a
live gate.

So: the comment is corrected, and nothing else

A suppression whose stated reason is narrower than its effect is a defect in
itself, so cmake/compiler_options.cmake:161 now records the six-row table, the
41-site measurement, the fact that the group cannot be narrowed, and the emsdk
divergence cited to the CI log it came from.

#662 is not closed. Its own condition is "a diagnostic in this class fails a
leg that a developer can run locally, demonstrated by mutating the code and
watching that leg go red". Nothing here changes which leg enforces what. It
stays open, blocked on #669, and the measurement is posted on the issue.

Not verified for #662


Issues filed

No triage: label applied to any of them.

Review

Done inline rather than through /code-review or /simplify. The three things
worth a second reader's eye:

  1. PendingQueue::pop()'s precondition. It is !empty(), and the single
    caller checks nothing — but it does not need to: scheduleNext's lambda only
    runs because post() pushed a task and flipped running, or because the
    drain block observed !pending.empty() and re-armed. Both decisions are made
    under strand->mtx, which the pop also holds. This is the same precondition
    std::queue::front() had before.
  2. Occupancy is a flag, not operator bool on the callable. Deliberate: a
    posted empty std::function would otherwise be silently dropped and stall
    the strand. Today's code dispatches it and lets the bad_function_call land
    in the existing catch, which is the pre-existing behaviour, preserved.
  3. _head = nullptr after the move. A moved-from std::function is valid
    but unspecified, so the idle slot is cleared explicitly rather than left
    holding whatever libstdc++ leaves — relevant because captured state
    (including shared_ptrs to models) would otherwise have an unspecified
    lifetime.

CI

Pushed and reported immediately, as instructed; CI was not waited on, polled
or re-run, so no check results are reported here — the picture is incomplete
by construction.
Two flake shapes to discount if they appear: the ledger
scenario corpus failing with SQLite database is locked (#658), and an
ubuntu-toolchain-r PPA 503 killing legs at ~5 minutes during package
installation.

🤖 Generated with Claude Code

https://claude.ai/code/session_01VptDWG2fKr2vBnLSJcgzgW

@Yaraslaut

Copy link
Copy Markdown
Member Author

Runner verification

Checked and confirmed: fixes #660 / refs #662, #662 still OPEN, and the strand.hpp diff contains no lock, mutex, notify or wait code — the only lock-related lines are comments asserting the discipline is unchanged. That is consistent with the safety claim, though it is the weak form of the evidence; the strong form is below and it is the lane's, not mine.

The re-measurement changed the remedy, which is the point of re-measuring

The ticket said "StrandExecutor rebuilds a model's Strand every call" and implied the fix was to stop rebuilding. The measurement says 576 of the 760 bytes were never the strand's lifetime at all — they are libstdc++'s std::deque allocating a node map and a 512-byte buffer in its default constructor, for a queue that holds one task. So the fix is "shrink what is rebuilt" rather than "stop rebuilding", and it lands 2 allocations / 608 bytes, 30% of the round trip's bytes, without touching lifetime or locking at all.

That also means #670's residual (152 bytes, 2 allocations) is correctly separated: removing it needs a StrandExecutor deregistration hook, which is a different change with a different risk profile. Filing it rather than reaching for it is right.

The safety claim was tested, and — more importantly — the detector was tested first

Full ctest under the clang-tsan preset: 1801/1801 pass … and that configuration was proven able to see this hazard: restoring the pre-fix two-step drain makes TSan report a data race whose two stacks both run through scheduleNext's lambda into LoadCountModel::execute.

Proving the instrument can see the hazard before trusting its silence is the whole discipline this repository is built around, and it is the only reason the 1801/1801 means anything. A TSan run that passes because TSan was not instrumented, or because the workload never reaches the window, is the failure AGENTS.md leads with — and check_sanitizer_instrumentation.sh confirming all six binaries carry __tsan_ closes the first half of that.

#668 is the finding that matters most here

tests/test_strand_race.cpp asserts the right property — REQUIRE(maxInFlight.load() == 1), 134 lines, many threads hammering one key. And per the lane it passes 10/10 under TSan against the two-step-drain mutant, while an unrelated Bridge test caught the race intermittently, on the 3rd of 60 repeats.

So the named regression test for this repository's most-cited invariant does not detect the regression it exists for. That is the "control that reports success while measuring nothing" case, sitting on the concurrency contract — and it was found only because #660 required proving TSan could see the hazard at all. I have not reproduced it (no TSan run here), and it should be re-measured by whoever takes #668 before acting; but the shape of the evidence is right and the consequence if true is large.

#662 handled honestly rather than closed

Partially landed with refs, not fixes, because the comment defect is fixed and the enforcement gap is not — and nothing done here meets that issue's own closing condition. Two measured facts contradict the ticket's framing, and both are worth keeping:

  1. Narrowing is not expressible. -Wshadow-uncaptured-local owns any declaration inside an uncapturing lambda shadowing an uncaptured local or structured binding — four constructs, of which the comment named one, including the [x = std::move(x)] idiom used throughout the tree. Clang has no finer group, so the ticket's suggested "re-enable -Wshadow after the suppression" cannot work.
  2. Zero shadows a structured binding remaincore: give Bridge an executor for late registration replies, and re-measure what a local dispatch allocates (fixes #588, refs #572) #661 fixed the only four — so the WASM leg enforces nothing in this class today. It is a trap for the next one written, not a live gate. That reframes -Wno-shadow-uncaptured-local suppresses a different set on each clang, so the WASM leg is the only one enforcing structured-binding shadowing #662 from "a gate with accidental coverage" to "a gate with no coverage and an accidental past catch".

4856 diagnostics over 41 sites in 16 files is why the cleanup is #669's problem and not this PR's.

Not verified by me

The allocation figures, the TSan runs, the 41-site count and #668's reproduction — all need builds I did not run. Also unfinished by the lane, and worth carrying: which clang release reclassified the structured-binding case, which is the remaining half of #662's "establish the fact".

🤖 Generated with Claude Code

https://claude.ai/code/session_01VptDWG2fKr2vBnLSJcgzgW

@codecov

codecov Bot commented Sep 21, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

Yaraslaut and others added 3 commits September 21, 2026 16:28
…ixes #660)

`StrandExecutor` erases a model's `Strand` as soon as it drains, so a
workload that dispatches one action at a time against a model rebuilds
the whole strand on every call. Re-measured on 7a343e6 with
tests/bench/bench_dispatch_allocations.cpp (the instrument #661 landed,
which the original figure predates), x86-64 Linux, GCC 16.2.1 /
libstdc++, -O2, per-line attribution from a `backtrace()` in the
counting hook symbolised through `dladdr` + `addr2line`:

      #7    32 bytes  strand.hpp:104   _strands map node
      #8   152 bytes  strand.hpp:106   make_shared<Strand>
      #9    64 bytes  <stl_deque>      the Strand's std::queue deque map
      #10  512 bytes  <stl_deque>      the Strand's std::queue first buffer

    heap allocations total     : 4159 (20.80 per call)
    bytes allocated total      : 398072 (1990.4 per call)

760 of 1990 bytes, 38%. But 576 of those 760 are not the strand's
lifetime at all: they are libstdc++'s `std::deque` allocating a node map
and a 512-byte first buffer *in its default constructor*, for a queue
that in this workload never holds more than one task.

So this takes the third of the ticket's three directions -- shrink what
is rebuilt -- and leaves the lifetime policy alone. `PendingQueue` holds
the head task inside the `Strand` and constructs a `std::deque` behind
it only when a second task is genuinely queued. Nothing about the
locking changes: the erase still fires when `empty()` becomes true,
still under the `{_mapMtx, strand->mtx}` pair whose atomicity the
comments at :81 and :167 record a previous defect forcing into shape.

    heap allocations total     : 3786 (18.93 per call)
    bytes allocated total      : 279208 (1396.0 per call)

    #7    32 bytes   _strands map node
    #8   120 bytes   make_shared<Strand>

2 allocations and 608 bytes per local dispatch, 30% of the total.

The two remaining strand allocations are inherent to the erase, and
keeping the slot alive to remove them trades this churn for a per-model
entry nothing reclaims -- `StrandExecutor` has no deregistration hook.
That trade is not made here and is not smuggled in.

Verification. Reproduced, not inferred: both the before and after
figures above are runs on this tree, and the four backtraces are real
output, not a reading of the code. The overflow path the change adds is
covered -- `_overflow->push_back` -> `push_front` fails 3 test cases
including the 50-task FIFO assertion, and disabling the refill-from-
overflow arm fails 8. Full gcc-release suite: 1561 cases pass.

The race, not the suite, is the hazard, so: full ctest under the
clang-tsan preset (clang 22.1.8, MORPH_BUILD_NET=ON,
MORPH_BUILD_OFFLINE_SQLITE=ON, cmake/tsan.supp, the `Linux / clang-tsan`
leg's own flags) -- 1801/1801 pass, all six binaries confirmed
instrumented by check_sanitizer_instrumentation.sh. And that
configuration was shown to be able to see this specific hazard:
restoring the pre-fix two-step drain-and-erase makes TSan report a data
race whose two stacks both run through `scheduleNext`'s lambda into
`LoadCountModel::execute` -- two strands for one key, which is exactly
the defect the combined lock exists to prevent.

Not verified: the time cost -- nothing here claims a latency change.
Not verified on libc++, MSVC or the WASM toolchain; the byte figures are
libstdc++'s and the `std::deque` behaviour they turn on is an
implementation choice, so the saving may differ elsewhere. The
`Kanban / ThreadSanitizer` leg (Qt + ladder) was not run locally; the
`Linux / clang-tsan` leg was, and it is the one that covers strand.hpp's
own tests.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VptDWG2fKr2vBnLSJcgzgW
…Queue (refs #660)

Mechanical follow-up to the previous commit, kept separate because it
changes no behaviour and no argument. `PendingQueue` inserts 67 lines
above the allowlisted `if (iter != _strands.end() && iter->second ==
strand) {`, so the ST1 entry's `line: 192` hint drifted to 259.

The `source` text is unchanged and matches exactly one line in the file,
so the disposition itself is untouched -- this is the "text still
matches, update the hint" case check_branch_coverage.py's
resolve_allowlist_source_line() names, not a re-reading of the
invariant.

Verified by re-running that resolver's own rule over every entry in
branch_partial_allowlist.json, error_path_allowlist.json and
mutation_survivors.json against this tree: one drifted entry before,
zero after. Not verified: check_branch_coverage.py itself, which needs a
coverage profile this lane did not produce.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VptDWG2fKr2vBnLSJcgzgW
…#662)

The comment on this suppression said "lambda param shadowing an
uncaptured local". That is one of four constructs the flag owns.
Measured on clang 22.1.8, `clang++ -std=c++23 -fsyntax-only
-Weverything`, one construct per function:

  probe.cpp:9:22:  declaration shadows a local variable      [-Wshadow-uncaptured-local]
  probe.cpp:16:24: declaration shadows a local variable      [-Wshadow-uncaptured-local]
  probe.cpp:23:16: declaration shadows a local variable      [-Wshadow-uncaptured-local]
  probe.cpp:30:16: declaration shadows a structured binding  [-Wshadow-uncaptured-local]
  probe.cpp:37:11: declaration shadows a local variable      [-Wshadow]
  probe.cpp:44:48: declaration shadows a local variable      [-Wshadow]

-- lambda parameter, ordinary local inside a lambda, init-capture over a
local, init-capture over a structured binding; and, still enforced, a
plain nested block and a shadow of a variable the lambda *does* capture.
So the group is "any declaration inside a lambda with no capture-default
that shadows an uncaptured enclosing local or structured binding", which
includes the `[x = std::move(x)]` idiom this codebase uses throughout.
A suppression whose stated reason is narrower than its effect is a
defect on its own, independent of any cleanup, so the comment is
corrected here and nothing else changes.

It is corrected rather than narrowed because narrowing to the one named
construct is not expressible: clang has no finer flag than
-Wshadow-uncaptured-local, so the only alternatives are all-or-nothing
plus per-site suppressions. And all-or-nothing is not a one-line diff.
Measured on this tree, clang 22.1.8, clang-debug with
NET/QT/FORMS_QML/OFFLINE_SQLITE/LOAD_TESTS/HMAC_EXAMPLES/LADDER/
BANK_EXAMPLE configured and -Werror off, with the flag removed:

    4856 diagnostics, 41 distinct sites, 16 first-party files

35 of the 41 are in headers, which is why the emission count is two
orders of magnitude larger. Eight each in core/backend.hpp and
core/remote.hpp, four each in core/bridge.hpp and core/completion.hpp.
No dependency is affected; they arrive via -isystem.

**This does not close #662.** That issue's own condition is "a
diagnostic in this class fails a leg that a developer can run locally,
demonstrated by mutating the code and watching that leg go red", and
nothing here changes which leg enforces what. It stays open, the 41-site
cleanup it needs is filed separately, and folding either into this
commit is what AGENTS.md says not to do.

Two things this measurement says that #662 does not. First, the split is
not "structured binding versus local variable": the flag routes on
whether the shadowing declaration sits in an uncapturing lambda, not on
what kind of entity is shadowed -- rows 3 and 4 above differ only in the
shadowed entity and land in the same group. Second, the tree now has
**zero** structured-binding shadows (morph#661 fixed the only four), so
the WASM leg's exclusive enforcement of that row currently enforces
nothing in practice; it is a trap for the next one written, not a live
gate.

Verified: the six-row table and the 4856/41/16 figures are real output
from this workstation, re-derived from the build log rather than
transcribed. `cmake --preset clang-debug` reconfigures clean afterwards
with the flag still on all 355 compile commands. Not verified: anything
about emsdk -- no emsdk toolchain is available here, so the WASM claim
in the comment is cited to the CI log it came from and labelled as such.
Not verified: which clang release reclassified the structured-binding
case; only clang 22.1.8 was available locally.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VptDWG2fKr2vBnLSJcgzgW
@Yaraslaut
Yaraslaut force-pushed the laneSTRAND-batch-660-662 branch from b320f9a to be0c4a6 Compare September 21, 2026 14:28
@Yaraslaut

Copy link
Copy Markdown
Member Author

Runner: rebased onto 563502abrebase, on the gate limb

#665 merged, and its delta includes a gate this PR has never been judged by:

.github/workflows/drift-guard.yml       <-- new job
scripts/check_qobject_moc_pairing.py    <-- new gate
examples/bank/gui/**  examples/bank/tests/gui/**   (unrelated to this PR)

scripts/check_qobject_moc_pairing.py is the Q_OBJECT-header-pairing gate from #659, and it did not exist when this branch's CI last ran. That is the first limb of the staleness test — its CI was judged by rules that no longer exist — so rebase rather than merge, independently of the failures below.

Rebased cleanly, three commits replayed unchanged, pushed as be0c4a6f. The new gate passes on the rebased tree, along with the rest of the cheap set:

Q_OBJECT moc-pairing lint OK.
self-test OK: 9 fixture(s), including the #652 mutation of the real examples/common/CMakeLists.txt
ok: 15 structured citation(s) in scripts/mutation_survivors.json resolve to the line they name.
NOLINT directive lint OK: 177 NOLINTNEXTLINE directive(s), all annotating code.
bidi-control lint OK: 1246 text file(s) scanned, 12 codepoint(s) searched for, 0 raw occurrence(s).
All 58 rung-filter checks passed.
Prose lint OK: …

On the nine red legs this replaces

They were 28s–1m each, which is the package-installation signature, not a build or test one — the ubuntu-toolchain-r / Launchpad outage filed as #672. I could not read their logs to confirm individually, because that run still had a pending job and gh run view --log refuses while a run is in progress; the counts I could take from the readable ones were zero only because the log was unavailable, not because the signature was absent. Saying that plainly rather than reporting a confirmed cause.

What I did establish is that the outage has cleared: ppa.launchpadcontent.net returns HTTP 200 as of 16:07, and #665's re-run after that point went green and merged. So this fresh run should be judged on its merits.

The legs to watch are the ones that actually test this branch: Linux / clang-tsan for the strand change, and Linux / all optional features (clang) / (gcc) for the PendingQueue container swap.

🤖 Generated with Claude Code

https://claude.ai/code/session_01VptDWG2fKr2vBnLSJcgzgW

@Yaraslaut

Copy link
Copy Markdown
Member Author

Runner: 52 pass / 2 fail — both infrastructure, re-running

Linux / gcc-debug and Linux / gcc-release, both in the Install sccache step:

gzip: stdin: not in gzip format
tar: Child returned status 1
tar: Error is not recoverable: exiting now
##[error]Process completed with exit code 2

Confirmed on both legs (2 gzip/tar errors each). The installer was handed something that is not a tarball — almost certainly an error page from the download — and piped it to tar. No compilation ran, so this says nothing about the branch.

That matters here because the branch is a container change inside Strand, and GCC legs failing while every clang leg passes is exactly the shape a real portability problem would take. It is not one: nothing compiled.

Re-run requested at 17:55. Everything else is green — 52 of 54, including Linux / clang-tsan, which is the leg that actually judges #660's change.

Fourth distinct infrastructure failure today, after the PPA 503s, the Launchpad GPG 500s, and the database is locked flake. Added to #672 as another occurrence; this one is a different upstream from the other two, which strengthens rather than weakens that ticket's argument — the common factor is not one flaky host, it is that a setup-step failure is indistinguishable from a defect in the check list.

Note on my own monitoring, since it bears on this result

The first watcher I armed on this PR reported "settled" while every check was still pending: I started it seconds after a push, before the run's checks existed, so pending == 0 was true for the wrong reason. Re-armed with a total-count floor (total > 30 && pending == 0), and that is where the 52/2 above comes from — total checks: 54. A scan that examined nothing must not read as clean, which is the same rule check_bidi_controls.py and check_nolint_directives.sh already enforce on themselves.

🤖 Generated with Claude Code

https://claude.ai/code/session_01VptDWG2fKr2vBnLSJcgzgW

@Yaraslaut
Yaraslaut merged commit 735cc2e into master Sep 21, 2026
80 of 82 checks passed
Yaraslaut added a commit that referenced this pull request Sep 21, 2026
…ownload that blamed tar, and a sanitizer matrix the bank example was never in (fixes #675, fixes #679, refs #672) (#683)

* ci: let the sanitizer-instrumentation check answer "is this one binary instrumented?", without lowering the floor that makes its sweep mean something (fixes #675)

`scripts/check_sanitizer_instrumentation.sh` conflated two questions behind one
floor. "Did this check examine a representative set?" needs the floor, and that
is the CI invocation. "Is this one binary instrumented?" is a yes/no about a
single file and needs no floor at all -- but the script refused it:

    only examined 1 binaries -- too few for this check to mean anything

so a developer who had built one target under a sanitizer preset answered it by
hand with `nm | grep __tsan_`, re-deriving the per-mode symbol table and the
SIGPIPE trap the script already encodes. The #673 lane did exactly that.

`--binary <file> <mode>` answers the second question against the same symbol
table and skips the floor. The constraint that matters is that it must not be
usable where the floor was meant to apply, and that is enforced structurally
rather than by convention: the mode refuses outright, exit 2, when
GITHUB_ACTIONS is set, so no step of any workflow in this repository can reach
it. The sweep is unchanged -- same floor, same message, same exit codes.

Both halves are pinned by a new self-test, following the repository's
scripts/test_check_*.sh convention and running in drift-guard.yml's
sanitizer-can-fail job. It needs no compiler beyond `cc` and no sanitizer
runtime: the gate's whole measurement is `nm | grep -c __<mode>_`, so a fixture
that merely defines a function of that name exercises the gate rather than
clang, and a hand-written CTestTestfile.cmake is all `ctest --show-only` needs.

Measured, not asserted. The self-test's two load-bearing cases were confirmed
to fail against a mutated checker on this revision:

  - guard replaced by `if false` ->
      error: --binary ran under GITHUB_ACTIONS -- the floor can now be bypassed
      from a workflow step:
      1 self-test check(s) failed

  - floor lowered from 2 to 1 ->
      error: the sweep accepted a one-binary tree -- the floor has been
      lowered:
      1 self-test check(s) failed

and all twelve cases pass on the unmutated script.

Not verified: the narrow mode against a real sanitizer-instrumented binary --
the fixtures carry the symbol name, not a sanitizer runtime. That is deliberate
(it keeps the self-test in a dependency-free job), and the sweep path, which
shares the same `count_symbols` helper, is exercised against real instrumented
binaries by the three CI legs that already run it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VptDWG2fKr2vBnLSJcgzgW

* ci: make a failed download say it failed, instead of letting tar report it as "not in gzip format" (refs #672)

On 2026-09-21 both GCC legs of PR #671 died in the `Install sccache` step with

    gzip: stdin: not in gzip format
    tar: Child returned status 1
    tar: Error is not recoverable: exiting now
    ##[error]Process completed with exit code 2

Nothing compiled, and nothing in the log named the download. The step piped
`curl -sSL <url>` straight into `tar -xz`, and plain curl treats an HTTP 4xx or
5xx as a *successful* transfer of whatever body came back -- so an error page
went down the pipe and the decompressor was the only thing that complained.
`set -o pipefail` would not have helped: GitHub runs `run:` under `bash -e`
without it, so the pipeline's status is tar's regardless.

Reproduced locally against a server that returns 503, the old shape and the new
one, both under `bash -e`:

    =========== OLD SHAPE, 503 (bash -e, no pipefail) ===========

    gzip: stdin: not in gzip format
    tar: Child returned status 1
    tar: Error is not recoverable: exiting now
    exit=2
    =========== NEW SHAPE, 503 ===========
    curl: (22) The requested URL returned error: 503
    exit=22
    =========== NEW SHAPE, 200 ===========
    exit=0
    total 4
    -rwxr-xr-x 1 yaraslau yaraslau 28 Sep 21 18:36 sccache

-- the third block being the success path over a tarball with the real
release's member layout, so `--strip-components=1` still lands the binary in
place.

Applied to all nine copies of the step in ci.yml (the first carries the full
reasoning, the other eight point at it), and to the two other downloads in this
repository with the identical defect and the identical one-flag fix:
docs.yml's Doxygen tarball and mutation.yml's Mull .deb. Those two are the same
finding, not a separate one -- the Mull case is measurably worse, since a 503
puts 64 bytes of HTML in `${asset}` and `dpkg-deb` is left to object to the
archive.

Deliberately NOT in scope, and #672 stays open for them:

  - a retry policy for dependency installation;
  - a CI-wide marker that distinguishes "the environment failed" from "the
    change failed" in the check list.

Both are decisions rather than implementations, and neither is needed for the
one case where the log actively misled. The three apt/PPA outages that make up
the rest of #672 are untouched by this.

Found while doing it, filed rather than folded: `wget -qO- https://apt.llvm.org/
llvm.sh | sudo bash` (nine sites in ci.yml, one in mutation.yml) fails the other
way. Measured against the same 503 server: `wget -qO-` exits 8 and writes zero
bytes, `bash` reads empty input and exits 0, and the pipeline exits 0 -- a
clang-installation step that reports success having installed nothing. Filed
separately because it is a different failure shape needing a different fix.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VptDWG2fKr2vBnLSJcgzgW

* ci/bank: put the bank example under a sanitizer, and instrument the targets that were never going to carry one (fixes #679)

Measured on 5fc5e78: `linux-sanitizers`, `kanban-tsan`, `ladder-sanitizers` and
`valgrind` set zero `MORPH_BUILD_BANK_*` flags, against eight places elsewhere
in ci.yml that set `MORPH_BUILD_BANK_EXAMPLE=ON`. Bank is not a rung, so
`MORPH_BUILD_LADDER=ON` does not reach it either. Nothing was written down
about excluding it; it was an option nobody turned on.

Two halves, and the second was not in the ticket.

**1. bank's targets never called `apply_sanitizers()`.** `ladder_bank_server`
was the only one that did. So flipping the CI flag alone would not have
instrumented anything -- it would have built `bank_lib`, `bank_cli` and all
three test binaries blind. Measured, on a `clang-ubsan` configure with bank on
and the blocks absent:

    ::error::check_sanitizer_instrumentation: 3 of 9 ctest binaries are not
    ubsan-instrumented

naming bank_tests, bank_gui_tests and bank_gui_qml_tests. With the blocks in
place the same sweep reports 9 of 9. The blocks are also required *together*:
removing bank_tests' alone, with bank_lib's kept, does not merely leave the
binary unchecked, it fails to link with `undefined reference to
__ubsan_handle_type_mismatch_v1_abort`.

**2. a `bank-sanitizers` job (clang-ubsan, Qt + bank + GUI)**, rather than a
flag on `linux-sanitizers`' clang-ubsan leg, which is what the ticket proposed.
Two measured reasons. That leg builds no Qt and its own comment reserves the
matrix against GUI stacks, while bank's GUI is where the UB was. And, cold and
cacheless on 12 cores with clang 22.1.8:

    leg's current shape (core + net + offline_sqlite, no Qt)
        configure 36s   build 144s   144 ninja edges
    this job's shape (core + Qt + bank + bank GUI)
        configure 64s   build 391s   287 ninja edges

Folding one into the other roughly triples the slowest leg of a three-leg
matrix, whose duration is then the matrix's. Split out, **the three existing
legs' flags are not changed at all, so the wall-clock delta on them is zero**,
and this runs beside them. Same precedent and same argument as kanban-tsan.
Bank's isolated build cost -- the number the ticket asked for -- is the
difference between this job and the same configure without bank
(51s / 151s / 150 edges): **+13s configure, +240s build, +137 edges**.

`ubsan` rather than `asan`: UBSan diagnoses this class, and ASan over a Qt GUI
needs the `detect_leaks=0` and suppression story ladder-sanitizers carries,
which this job would have to acquire before it could be believed. Bank under
ASan is not closed by this.

**The first bill is zero.** Per the morph#646 (84) and morph#656 (97)
precedents, measured before landing: bank_tests (145 assertions in 21 cases),
bank_gui_tests (19 in 5) and bank_gui_qml_tests (32 in 2) all pass clean under
`-fsanitize=undefined -fno-sanitize-recover=undefined`, and so does the whole
1696-test suite of this configure (212s serial, 100% passed, zero `runtime
error` lines). Nothing is suppressed and no allowlist entry was added.

**A finding that cuts against the ticket's framing, stated rather than shipped
around.** The ticket says this gap is why morph#663 survived to be found by
reading code. Half true. Rebuilding Format.hpp as it stood before the fix, in
this job's exact configuration, bank_gui_tests exits 1 with

    examples/bank/gui/controllers/Format.hpp:76:38: runtime error: 9.2e+19 is
    outside the range of representable values of type 'long'

-- but only because morph#663's fix also added the test that calls parseMinor
with such a value. With the pre-morph#663 header *and* the pre-morph#663 test
set, this leg is green: no pre-existing bank test drove that path. The
sanitizer gap was real and is what this closes; it was not on its own what let
morph#663 through, and this job's reach is bounded by how much of bank the
suites actually drive. That is written into the job's banner, not just here.

Also here, because the job needs it: a `bank` ctest label on the three suites,
so the leg runs bank's 28 tests (6s) rather than re-running the 1696 (212s)
that linux-sanitizers' clang-ubsan leg already runs under identical
instrumentation. A label filter that matched nothing would be this
repository's named failure mode; CMakePresets.json's `base-test` already sets
`noTestsAction: error`, confirmed to exit 8 with `No tests were found!!!`
against a tree built without bank.

Not verified: bank under ASan or TSan; whether the Lightweight ORM is clean
under anything other than UBSan; and the job's real duration on a hosted
runner, which adds an apt install and a Qt install this local measurement does
not model.

Found while measuring, filed rather than folded: bank's 21 ctest cases share
one SQLite file and cannot run concurrently -- `ctest -j 12` fails 21 of them
with `[SQLite]disk I/O error (10)` where the same binaries pass serially. CI
runs ctest serially (no test preset sets a parallel level), so it is latent
rather than active.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VptDWG2fKr2vBnLSJcgzgW

* ci: pin install-qt-action to v4.3.1, so a moving major tag cannot break Qt

`Bank example / UBSan` failed with the Qt install step rejected before any
build ran:

  The template is not valid. jurplel/install-qt-action/v4/action.yml
  (Line: 160, Col: 11): Expected format {org}/{repo}[/path]@ref.
  Actual '$/action'

Upstream's action.yml now contains `uses: $/action` -- GitHub's
self-repository syntax, added in their 2026-07-30 changelog. Some runner
images resolve it and some do not. In the same workflow run, `Linux / Qt6
WebSockets` started 17:13:34Z with a byte-identical invocation and passed;
this job started 17:23:29Z and did not. Ten minutes and a different runner
apart, with no change on our side.

`@v4` is a major alias that upstream moves on every release, so this repository
has no say in when that syntax arrives. `v4.3.1` is the last release whose
action.yml does not use it (v4.4.0 and v4.4.1 both do), and it declares every
input used here -- arch, cache, dir, host, modules, target, version. All 11
call sites across ci.yml, wasm-demo.yml and wasm-ladder.yml are pinned
together, because a partial pin leaves the same lottery running on whichever
job was missed.

Verified: no floating `@v4` remains; all seven workflows parse; banner-lint,
option-coverage and catch2-pin pass.

This does not fix the class -- a pinned tag is still a tag, and the durable
answer is a commit SHA. That trade (immutability against a version nobody can
read) is recorded in morph#672 rather than decided here.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VptDWG2fKr2vBnLSJcgzgW

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant