gui: adopt CallbackScope across the ladder, fixing an unguarded this-capture bug in bookmarks - #330
Merged
Merged
Conversation
…capture in bookmarks Migrates all four remaining hand-rolled liveness-token sites (issue #304 section B2) to morph::async::CallbackScope, which replaces the per-class "token must stay last-declared" convention this framework primitive existed to retire. Correctness fix, not just cleanup: bookmarks::gui::FormsBridge::submitIfValid captured a bare `this` in both of BookmarkFormsController::submitIfValid's reply callbacks, with no guard at all. That path goes through neither Presenter::track()'s QPointer re-check nor Qt's signal/slot auto-disconnect -- a FormsBridge destroyed while a Completion is still in flight (an ordinary GUI case, e.g. process teardown with a submit outstanding) would run `emit` against freed storage. Confirmed by adding "A FormsBridge destroyed with a submit in flight has its reply suppressed, not delivered", mirroring pastebin's identical regression test (pastebin's own FormsBridge was fixed the same way in #315). Fixed by giving FormsBridge a last-declared CallbackScope and wrapping both callbacks in _callbacks.guard(...), exactly as pastebin's FormsBridge already does. polls::gui::PollBridge and kanban::gui::BoardBridge each hand-rolled a `std::shared_ptr<const void> _liveness` member with a `weak_ptr<const void>{_liveness}` + `.expired()` re-derivation at every one of their Completion/EventPoller/QNetworkReply callback sites (polls: 6 sites across 5 methods; kanban: 9 sites across 4 methods, including two probe-thread NetworkMonitor callbacks and a nested post()). Both were already correctly guarded -- this is pure adoption, not a bug fix -- and now use _callbacks.token()/.guard()/.then(scope, fn)/.onError(scope, fn) instead, removing every hand-written expired() check. ledger::gui::ReportJobPoller (not a QObject) held the same hand-rolled token; migrated to CallbackScope the same way, and a new regression test ("A ReportJobPoller destroyed with a dispatch in flight has its reply suppressed, not delivered") pins the destroy-mid-flight behavior post- migration, since it had no such coverage before. Net effect: ~50 lines of re-derived weak_ptr/expired() boilerplate removed across the four rungs, one confirmed use-after-free window closed. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Yaraslaut
force-pushed
the
feat/304-callback-scope-adoption
branch
from
August 27, 2026 12:39
80461dd to
8831fe4
Compare
Codecov Report❌ Patch coverage is 📢 Thoughts on this report? Let us know! |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Addresses part of #304 (§B2: CallbackScope adoption).
Migrates all four remaining hand-rolled liveness-token sites to
morph::async::CallbackScope, which per its own doc comment "replaces the per-class 'token must stay last-declared' convention that used to be re-derived and re-documented in every class that hand-rolled the pattern." The framework itself (bridge.hpp) and pastebin'sFormsBridge(#315) had already adopted it; polls, kanban, ledger and bookmarks had not.Correctness fix, not just cleanup
bookmarks::gui::FormsBridge::submitIfValidcaptured a barethiswith no guard at all, confirming the issue's (unverified) claim. Both reply callbacks passed toBookmarkFormsController::submitIfValidcaptured rawthisand were attached completely unguarded. This path goes through neitherPresenter::track()'sQPointerre-check nor Qt's signal/slot auto-disconnect (the completion is a plainstd::function, not aQObject::connect). AFormsBridgedestroyed while aCompletionis still in flight — which always resolves through the executor, never inline, even inLocalmode — runsemitagainst freed storage.Per TDD discipline, I first wrote a test that reproduces the exact destroy-mid-flight window deterministically (
Mode::LocalSingleThread, so ordering is guaranteed, not timing-dependent), mirroring pastebin's own regression test for the identical bug fixed in #315. Then fixed it by givingFormsBridgea last-declaredCallbackScopeand wrapping both callbacks in_callbacks.guard(...)— the exact shape pastebin's already-fixedFormsBridgeuses.pastebin was independently confirmed already fixed (#315, merged before this branch was cut) — no changes needed there.
Pure adoption (already correctly guarded, migrated for consistency)
polls::gui::PollBridge— replaced its hand-rolledstd::shared_ptr<const void> _liveness+weak_ptr<const void>{_liveness}/.expired()re-derivation (6 sites acrossopenPoll/refresh/submitVotes/updateVotes/submitIfValid/startPolling) withCallbackScope's.then(scope, fn)/.onError(scope, fn)/.guard(fn).kanban::gui::BoardBridge— same migration across 9 sites:startPolling'sDispatch/ApplyEvent/OnFatalError,uploadAttachment/downloadAttachment'sQNetworkReply::finishedhandlers, andenableOfflineQueue's two-layerNetworkMonitorprobe-thread callbacks (probe thread →_executor->post()→ Qt thread, each layer re-gated).ledger::gui::ReportJobPoller(not aQObject) — same hand-rolled token, migrated the same way; composes as a plain data member exactly as it would on aQObject.Net effect: ~50 lines of re-derived
weak_ptr/.expired()boilerplate removed, one confirmed use-after-free window closed.Testing
docs/spec/core/callback_scope.mdandbridge.hpp's own_callbacks/liveness()usage read as the canonical adoption pattern before touching any rung.examples/bookmarks/tests/test_bookmark_qml_bridges.cpp— "A FormsBridge destroyed with a submit in flight has its reply suppressed, not delivered" (mirrors pastebin's identical case).examples/ledger/tests/test_report_job_poller.cpp— "A ReportJobPoller destroyed with a dispatch in flight has its reply suppressed, not delivered" (this class had no destroy-mid-flight coverage before; added as a regression guard for the refactor even though it was already correctly guarded).cl-qt-debugpreset,/W4,MORPH_BUILD_LADDER=ON,MORPH_BUILD_OFFLINE_SQLITE=ONto exercise kanban's offline stack) — zero warnings.ctestresults per rung (SQLitebackend; the defaultODBC_CONNECTION_STRINGin this environment points at an unreachable SQL Server and was unset to use the fast local fallback):-Rargument marshaling of an em-dash in an unrelated test name (test_app.cpp'sRecordMetadata...) — confirmed passing when run directly through the Catch2 binary.test_ledger_qml_surface.cpp, a stalebusyChangedexemption list tracked asmorph#239) is pre-existing on master and untouched by this branch.clang-format --dry-run --Werror) on every changed file.Scope
Stays scoped to
gui_lib/*.{hpp,cpp}in bookmarks, polls, kanban and ledger, plus their two test files, per the issue's PR breakdown (§D.4). Does not touch §A (already fixed), §B1/§B3/§B4/§C, or any QML/doc files other agents may be working on in parallel.🤖 Generated with Claude Code