ladder_bookmarks_tests segfaulted once in CI on the control half of the
stopBackgroundJobs() pair. It has not reproduced since, so this is filed with
the evidence that exists rather than a recipe.
What happened
Linux / all optional features (clang), ctest:
1524/2475 Test #1524: App's fetch timer really does fire under a pumping loop (the control for stopBackgroundJobs) ...***Exception: SegFault 0.22 sec
The following tests FAILED:
1524 - App's fetch timer really does fire under a pumping loop (the control for stopBackgroundJobs) (SEGFAULT) ladder-bookmarks
Errors while running CTest
99% tests passed, 1 tests failed out of 1524 — the segfault was the only failure
in the run.
Verification status
Reproduced: once, in CI, not on demand.
- Revision
fb3f4c9a (branch ci/self-hosted-linux-runner), run
33603372999 attempt 1,
self-hosted runner morph-docker-1, started 2026-09-02T07:40:22Z.
- The runner is a Docker container pinned to 2 processors (
--cpuset-cpus) with
a 6 GiB cap, one of five such containers compiling concurrently on a
12-processor host. The machine was heavily loaded, which is the one
environmental difference worth noting against every green run of this test.
Not reproduced, four ways:
- 40/40 passes running that single test in a loop locally, same revision, on a
binary built through the compiler cache.
- The
Application ladder / ASan+UBSan leg of the same CI run executed the
same test and passed: 208/976 Test #1512: ... Passed 0.17 sec, and the leg
finished 100% tests passed, 0 tests failed out of 976. So neither ASan nor
UBSan flagged anything on the path that crashed.
- The rerun of the failed job (attempt 2) was green, 26/26.
- Three earlier full runs on adjacent revisions were green.
Not verified: no core dump or backtrace was captured — ctest reports only
SegFault, and the container is gone. Everything below the next heading is read
from the source, not observed.
Mechanism — inferred from reading, NOT reproduced
examples/bookmarks/tests/test_app.cpp:370 pumps until a timer-driven pass has
dispatched, pumps until fetchInFlight() is false, then lets App destruct at
scope exit.
The parts that are already defensive, and so are probably not it:
App::~App() (examples/bookmarks/src/app/app.cpp:167) calls
stopBackgroundJobs() first, with a comment saying exactly why.
- The in-flight counter is a
shared_ptr<atomic> captured by value into
.then()/.onError(), explicitly so a late completion outliving the App
can still decrement it safely.
The part that does not have that protection: the per-pass handler at
examples/bookmarks/src/app/app.cpp:216 is built over _fetchBridge and
&_fetchExecutor, both App members:
auto handler = std::make_shared<::morph::bridge::BridgeHandler<BookmarkModel>>(_fetchBridge, &_fetchExecutor);
The shared_ptr keeps the handler alive past ~App, but not the executor it
points at. If any queued delivery reaches that handler after ~App has run,
it touches a destroyed member. fetchInFlight() == false says every dispatch
settled its counter; it is not obviously the same statement as "no queued event
referencing _fetchExecutor remains", and the test destroys the App
immediately on that signal.
This is a hypothesis that fits the crash site and the load-sensitivity. It is
not evidence, and it may be wrong.
What would change the verdict
- Close if the mechanism above is disproved (e.g. the executor provably
cannot be reached after ~App) and the crash is traced elsewhere, or if it is
fixed and a stress loop under artificial load stays green.
- Reopen / raise severity if it recurs, especially on a GitHub-hosted runner
or an unloaded machine — that would make it a plain lifetime bug rather than
one that needs contention to surface.
- Worth trying to force before doing either: run this test under heavy CPU
contention in a loop, and/or shorten kFastFetchInterval, to see whether it
becomes reproducible on demand. A version of it that reproduces is worth more
than this report.
The self-hosted runners make this class of failure more likely to be seen,
because a 2-processor container under load stretches exactly the windows a
same-thread event-loop test relies on staying closed.
ladder_bookmarks_testssegfaulted once in CI on the control half of thestopBackgroundJobs()pair. It has not reproduced since, so this is filed withthe evidence that exists rather than a recipe.
What happened
Linux / all optional features (clang), ctest:99% tests passed, 1 tests failed out of 1524— the segfault was the only failurein the run.
Verification status
Reproduced: once, in CI, not on demand.
fb3f4c9a(branchci/self-hosted-linux-runner), run33603372999 attempt 1,
self-hosted runner
morph-docker-1, started 2026-09-02T07:40:22Z.--cpuset-cpus) witha 6 GiB cap, one of five such containers compiling concurrently on a
12-processor host. The machine was heavily loaded, which is the one
environmental difference worth noting against every green run of this test.
Not reproduced, four ways:
binary built through the compiler cache.
Application ladder / ASan+UBSanleg of the same CI run executed thesame test and passed:
208/976 Test #1512: ... Passed 0.17 sec, and the legfinished
100% tests passed, 0 tests failed out of 976. So neither ASan norUBSan flagged anything on the path that crashed.
Not verified: no core dump or backtrace was captured — ctest reports only
SegFault, and the container is gone. Everything below the next heading is readfrom the source, not observed.
Mechanism — inferred from reading, NOT reproduced
examples/bookmarks/tests/test_app.cpp:370pumps until a timer-driven pass hasdispatched, pumps until
fetchInFlight()is false, then letsAppdestruct atscope exit.
The parts that are already defensive, and so are probably not it:
App::~App()(examples/bookmarks/src/app/app.cpp:167) callsstopBackgroundJobs()first, with a comment saying exactly why.shared_ptr<atomic>captured by value into.then()/.onError(), explicitly so a late completion outliving theAppcan still decrement it safely.
The part that does not have that protection: the per-pass handler at
examples/bookmarks/src/app/app.cpp:216is built over_fetchBridgeand&_fetchExecutor, bothAppmembers:auto handler = std::make_shared<::morph::bridge::BridgeHandler<BookmarkModel>>(_fetchBridge, &_fetchExecutor);The
shared_ptrkeeps the handler alive past~App, but not the executor itpoints at. If any queued delivery reaches that handler after
~Apphas run,it touches a destroyed member.
fetchInFlight() == falsesays every dispatchsettled its counter; it is not obviously the same statement as "no queued event
referencing
_fetchExecutorremains", and the test destroys theAppimmediately on that signal.
This is a hypothesis that fits the crash site and the load-sensitivity. It is
not evidence, and it may be wrong.
What would change the verdict
cannot be reached after
~App) and the crash is traced elsewhere, or if it isfixed and a stress loop under artificial load stays green.
or an unloaded machine — that would make it a plain lifetime bug rather than
one that needs contention to surface.
contention in a loop, and/or shorten
kFastFetchInterval, to see whether itbecomes reproducible on demand. A version of it that reproduces is worth more
than this report.
The self-hosted runners make this class of failure more likely to be seen,
because a 2-processor container under load stretches exactly the windows a
same-thread event-loop test relies on staying closed.