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
21 changes: 17 additions & 4 deletions cmake/tsan.supp
Original file line number Diff line number Diff line change
Expand Up @@ -89,11 +89,11 @@
# disconnect test is simply the first thing in the tree that exercises the
# path hard enough to trip it.
#
# ── Why these three frames, and why not more ──────────────────────────────
# ── Why these four frames, and why not more ───────────────────────────────
#
# Each names a frame TSan resolves *by symbol* rather than by debug info,
# which is what makes them survive the runner's stripped `libstdc++.so.6`.
# All three are confirmed resolvable there: the first two appear by name in
# All four are confirmed resolvable there: the first two appear by name in
# the CI report this file was written against
# (`std::__exception_ptr::exception_ptr::_M_release() <null>
# (libstdc++.so.6+0xc2f8c)` -- a resolved name with an unresolved file,
Expand All @@ -109,6 +109,17 @@
# `std::runtime_error::~runtime_error` is likewise exported and likewise
# appears resolved by name in CI's own report.
#
# The fourth line is the same shape for the other standard exception base
# whose message lives in a COW string: `std::logic_error`. CI reported it from
# `morph_client_only_facade`, whose `LocalBackend` rejects with a
# `std::logic_error` that a pool thread releases last while the main thread's
# `onError` has just read its `what()` -- a write stack going straight from
# `~logic_error`, resolved by name, to `CompletionState::~CompletionState`.
# A race-free standalone control (one `exception_ptr` copied to a second
# thread, the main thread reading `what()` and dropping its copy, the other
# dropping the last one, nothing else ordering them) reproduces that report on
# Linux with libstdc++.
#
# Deliberately *not* suppressed, though an earlier draft had them:
# `__cxa_free_exception` (in no measured report against this tree) and
# `std::string::_Rep::_M_dispose` (an inlined libstdc++ internal that would
Expand All @@ -118,8 +129,10 @@
# involving that type.
#
# These are narrow on purpose. A genuine race elsewhere in morph does not
# destroy a `std::runtime_error` or touch `exception_ptr`'s refcount, and a
# planted control race was confirmed still reported with this file active.
# destroy a `std::runtime_error` or `std::logic_error`, or touch
# `exception_ptr`'s refcount, and a planted control race was confirmed still
# reported with this file active.
race:std::__exception_ptr::exception_ptr::_M_release
race:std::__exception_ptr::__dest_thunk
race:std::runtime_error::~runtime_error
race:std::logic_error::~logic_error
3 changes: 3 additions & 0 deletions examples/client/main.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -49,8 +49,10 @@ namespace {
namespace examples = morph::examples;
namespace ui = morph::ui;

#if MORPH_CLIENT_HAS_QT_QUICK
/// How long `--grab` lets the window settle after the first mount.
constexpr std::chrono::milliseconds kGrabSettle{1000};
#endif

/// `--grab`: where the window's image goes, and the window, once the Qt Quick frontend made it.
struct Grab {
Expand Down Expand Up @@ -104,6 +106,7 @@ bool saveGrab(Grab const& grab) {
}
return true;
#else
static_cast<void>(grab);
std::cerr << "client: --grab draws the Qt Quick window, and this client is built without it\n";
return false;
#endif
Expand Down
3 changes: 2 additions & 1 deletion include/morph/ui/agent.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -443,7 +443,8 @@ class McpAdapter {
return;
}
Json const* const given = member(params, "arguments");
Json arguments = given == nullptr ? Json{Json::object_t{}} : *given;
// Parentheses: braces would pick the initializer-list constructor and make `[{}]`.
Json arguments = given == nullptr ? Json(Json::object_t{}) : *given;
if (!std::holds_alternative<Json::object_t>(arguments.data)) {
done(std::unexpected(std::string{"the arguments are not an object"}));
return;
Expand Down
6 changes: 4 additions & 2 deletions include/morph/ui/interpreter.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -6534,7 +6534,8 @@ inline Node ScreenCore::lowerForm(DocNode const& node, LowerCtx const& ctx, Elem
if (text.starts_with("s:")) {
text.remove_prefix(2);
}
return forms::detail::engine::Json{std::string{text}};
// Parentheses: braces would pick the initializer-list constructor and make a one-element array.
return forms::detail::engine::Json(std::string{text});
}
if (text.starts_with("i:")) {
text.remove_prefix(2);
Expand All @@ -6547,7 +6548,8 @@ inline Node ScreenCore::lowerForm(DocNode const& node, LowerCtx const& ctx, Elem
((type == "int" || type == "number") && json->is_number()) ||
(type == "exact" && (json->is_number() || json->is_string()));
if (!fits) {
return std::unexpected("it is not " + std::string{type == "int" ? "an " : "a "} + std::string{type});
bool const vowel = type == "int" || type == "exact";
return std::unexpected("it is not " + std::string{vowel ? "an " : "a "} + std::string{type});
}
return *std::move(json);
}
Expand Down
10 changes: 6 additions & 4 deletions include/morph/ui/mount.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -924,8 +924,7 @@ class Mounter {
widget.setOnSelect(inputEvent(spec.onSelect, reassert));
pages.onSelect = inputEvent(spec.onSelect, std::move(reassert));
auto show = [this, &widget, &pageScope, &pages, pageSpecs = spec.tabs, overlay = _overlay](std::size_t index) {
underOverlay(overlay,
[&] { _rt->untracked([&] { showPage(widget, pageScope, pages, pageSpecs, index); }); });
_rt->untracked([&] { showPage(widget, pageScope, pages, pageSpecs, index, overlay); });
};
if (!spec.selected.isBound()) {
show(spec.selected.constant());
Expand All @@ -939,13 +938,16 @@ class Mounter {
// state (focus, scroll position, half-typed text) across tab switches. A new page is complete before anything
// else changes: one that fails to mount leaves the previous page shown and selected, and the bar's highlight,
// which the user's pick may already have moved, goes back to it. The next change of the index mounts again.
//
// Only the mount runs under the tabs' overlay, and that overlay is put back before a failure is reported:
// reporting it may destroy the Mounted, and with it this Mounter.
void showPage(TabsWidget& widget, reactive::Scope& pageScope, Pages& pages, std::vector<Tab> const& pageSpecs,
std::size_t index) {
std::size_t index, ContainerWidget* overlay) {
bool const exists = index < pageSpecs.size();
bool const isNew = exists && pages.pageSlots.at(index) == nullptr;
if (isNew) {
try {
mountPage(widget, pageScope, pages, pageSpecs.at(index).node, index);
underOverlay(overlay, [&] { mountPage(widget, pageScope, pages, pageSpecs.at(index).node, index); });
} catch (...) {
if (pages.shown.has_value()) {
restoreSelection(widget, pages, *pages.shown);
Expand Down
12 changes: 6 additions & 6 deletions scripts/branch_partial_allowlist.json
Original file line number Diff line number Diff line change
Expand Up @@ -85,13 +85,13 @@
},
{
"file": "include/morph/util/rational.hpp",
"line": 1498,
"line": 1504,
"source": "if (ctx.begin() == ctx.end() || *ctx.begin() == '}') {",
"reason": "The ctx.begin() == ctx.end() true arm is unreachable. This is the textbook cppreference-style custom-formatter parse() idiom. Empirically verified on this toolchain (libc++, via a standalone probe compiled with clang++ -std=c++23 -stdlib=libc++): for both std::format(\"{}\", x) and std::format(\"{:}\", x), ctx.end() always points past the terminating '}', so ctx.begin() == ctx.end() is false and the terminator is always reachable via *ctx.begin() == '}' -- matching the observed 0/14 split exactly. std::format's top-level parser (and std::vformat's, which performs the same replacement-field validation before dispatching to a type's parse()) rejects an unterminated '{...' before ever calling into formatter<Rational>::parse, so this function is never invoked with an already-exhausted range. The first disjunct is defensive boilerplate that this standard library's implementation (and the standard's own guarantee about validated replacement fields) makes structurally unreachable."
},
{
"file": "include/morph/util/rational.hpp",
"line": 1407,
"line": 1413,
"source": "if (!detail::addOverflows(whole, step)) {",
"reason": "The false arm (the overflow-would-occur case, declining to step) is unreachable, contrary to Task 2's initial classification of this line as testable -- verified both mathematically and empirically (a standalone probe sweeping denominators/precisions found 476 cases where the scale-up saturated to whole == INT64_MAX, and every one had a fractional remainder of exactly 0, never >= 0.5) before writing this entry. A Rational's magnitude can never exceed INT64_MAX: its value is numerator/denominator with denominator >= 1 and numerator in [-INT64_MAX, INT64_MAX] (canonicalise() clamps INT64_MIN away), so |value| <= |numerator| <= INT64_MAX always. `whole` is trunc(scaled), so whole == INT64_MAX forces scaled == INT64_MAX/1 exactly -- there is no room for scaled to be in (INT64_MAX, INT64_MAX + 1) the way an unbounded rational could land. With scaled == whole exactly, `fraction` (scaled minus whole) is always 0, so `roundAway` (set from comparing fraction against 1/2) is always false when whole == INT64_MAX, and stepping never happens on that side. On the other side, step == -1 would need whole == INT64_MIN to overflow, but whole's range is [-INT64_MAX, INT64_MAX] (INT64_MIN is never a valid Rational magnitude), so that direction cannot overflow either. The guard is defensive: reachable only if a future change let a Rational's magnitude exceed INT64_MAX, which the type's invariants currently forbid everywhere else in this file."
},
Expand Down Expand Up @@ -121,28 +121,28 @@
},
{
"file": "include/morph/ui/document.hpp",
"line": 1715,
"line": 1724,
"source": "default:",
"context": "download(cmd, fields, primary, path);",
"reason": "The default arm is unreachable: the switch names every enumerator of CommandKind, so no value reaches `default`. The project's strict warnings require both at once -- -Wswitch-enum wants every enumerator listed and -Wswitch-default wants a default label -- so the default shares the last enumerator's body and is never the label taken. It is DocumentParser::command's switch over the command kinds."
},
{
"file": "include/morph/ui/document.hpp",
"line": 2085,
"line": 2094,
"source": "default:",
"context": "case Shape::Keys:",
"reason": "The default arm is unreachable: the switch names every enumerator of detail::Shape, so no value reaches `default`. The project's strict warnings require both at once -- -Wswitch-enum wants every enumerator listed and -Wswitch-default wants a default label -- so the default shares the last enumerator's body and is never the label taken. It is DocumentParser::entry's switch over a key's shape."
},
{
"file": "include/morph/ui/document.hpp",
"line": 3320,
"line": 3329,
"source": "default:",
"context": "if (cmd.value) {",
"reason": "The default arm is unreachable: the switch names every enumerator of CommandKind, so no value reaches `default`. The project's strict warnings require both at once -- -Wswitch-enum wants every enumerator listed and -Wswitch-default wants a default label -- so the default shares the last enumerator's body and is never the label taken. It is DocumentValidator::checkCommand's switch."
},
{
"file": "include/morph/ui/document.hpp",
"line": 3655,
"line": 3664,
"source": "default: {",
"reason": "The default arm is unreachable: the switch names every enumerator of NameCategory, so no value reaches `default`. The project's strict warnings require both at once -- -Wswitch-enum wants every enumerator listed and -Wswitch-default wants a default label -- so the default shares the last enumerator's body and is never the label taken. It is ScopeResolver::ref's switch over what a name declares."
},
Expand Down
3 changes: 3 additions & 0 deletions scripts/check_branch_coverage.py
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,9 @@
# new to the denominator rather than new to the code. Kept here as the
# prior data point; the floors below are all from the 2026-09-07 run.
FLOORS = {
# Measured locally over the CI coverage leg's configure (4 branch arms, all taken): only what a source holds
# before its first bundle branches.
"include/morph/client": (97.0, 100.00),
"include/morph/core": (94.0, 97.37),
"include/morph/detail": (97.0, 100.00),
"include/morph/forms": (91.0, 94.78),
Expand Down
21 changes: 9 additions & 12 deletions scripts/test_check_install_export.sh
Original file line number Diff line number Diff line change
Expand Up @@ -171,22 +171,19 @@ expect_caught "morph's install rules not running at all" \
#
# That path names whichever detail/ header the consumer TU reaches *first*, not
# a fixed one, because the compile stops at the first missing include and never
# reports the rest. So it moves whenever a public header gains a detail/ include
# that sorts ahead of the previous first -- it read `morph/detail/fixed_string.hpp`
# until `morph/core/backend.hpp` started including `detail/instance_directory.hpp`,
# and that until `morph/core/completion.hpp` started including
# `detail/completion_awaiter.hpp`, each of which the consumer reaches earlier. A
# failure here saying "caught
# for the WRONG reason" and naming some other detail/ header is that, and the fix
# is to update this needle, not to touch the install rules. The needle stays
# specific rather than becoming a loose `detail/` match so that this case still
# proves the consumer failed on a *missing detail header* and not on something
# unrelated.
# reports the rest: today that is `reactive/detail/graph.hpp`, through
# `client/shell.hpp` -> `ui/view.hpp` -> `reactive/runtime.hpp`. It moves
# whenever a public header gains a detail/ include the consumer reaches earlier,
# and a failure here saying "caught for the WRONG reason" and naming some other
# detail/ header is that: update this needle, not the install rules. The needle
# stays specific rather than becoming a loose `detail/` match so that this case
# still proves the consumer failed on a *missing detail header* and not on
# something unrelated.
expect_caught "the detail/ header set dropped from the install" \
"edit CMakeLists.txt -e '/^# The detail\/ headers that public headers include\./,/^set_target_properties(morph PROPERTIES INTERFACE_HEADER_SETS_TO_VERIFY HEADERS)$/d' -e '/FILE_SET morph_detail_headers DESTINATION/d'" \
fast \
"the consumer project did not compile against the install prefix" \
"detail/completion_awaiter.hpp"
"detail/graph.hpp"

# Bug 2: INTERFACE_HEADER_SETS_TO_VERIFY defaults to *every* interface header
# set, including the detail/ one, which is deliberately not held to compiling
Expand Down
2 changes: 1 addition & 1 deletion src/qt_quick/qml/MorphPanel.qml
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ MorphWidget {
objectName: "divider"
visible: root.headerRowShown
Layout.fillWidth: true
height: 1
implicitHeight: 1
color: root.scheme.border
opacity: 0.6
}
Expand Down
6 changes: 4 additions & 2 deletions src/qt_quick/qml/MorphStack.qml
Original file line number Diff line number Diff line change
Expand Up @@ -41,8 +41,10 @@ MorphWidget {
// The shown children's lengths along the axis, as the layout sizes a child that does not stretch.
readonly property var shownLengths: {
const lengths = []
for (const child of layout.children) {
if (!child.visible)
for (const object of layout.children) {
// `children` is a list of QtObject; the Layout attached type needs an Item.
const child = object as Item
if (!child || !child.visible)
continue
const preferred = root.axis === 0 ? child.Layout.preferredHeight : child.Layout.preferredWidth
const implicit = root.axis === 0 ? child.implicitHeight : child.implicitWidth
Expand Down
5 changes: 3 additions & 2 deletions tests/client/test_sign_in.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -51,11 +51,12 @@ std::optional<client::SignedIn> userAndJwt(ui::Value const& reply) {
.token = jwt != nullptr && jwt->asString() != nullptr ? *jwt->asString() : std::string{}};
}

/// A screen that signs in through `Probe_Login` and attaches a file inline through `Probe_Attach`.
/// A screen that signs in through `Probe_Login` and attaches a file inline through `Probe_Attach`. The path goes in
/// with forward slashes: a Windows path's backslashes would be read as JSON escapes.
std::string loginDocument(std::filesystem::path const& picked) {
return R"({"vocab":"ui/1","id":"probe.login","models":{"probe":{"type":"Probe_Model"}},)"
R"("state":{"picked":{"init":")" +
picked.string() +
picked.generic_string() +
R"("}},)"
R"("mutations":{"login":{"model":"probe","action":"Probe_Login","onSuccess":[{"signIn":{"from":"login"}}]},)"
R"("attach":{"model":"probe","action":"Probe_Attach"}},)"
Expand Down
4 changes: 4 additions & 0 deletions tests/client/test_sources.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -78,8 +78,12 @@ TEST_CASE("LocalSource: opens the linked application's bundle, verified, through
"[client][source]") {
FakeAppContext context;
LocalSource source{probe::app(), LocalSetup{}};
CHECK(source.bundle() == nullptr); // nothing delivered yet
CHECK(source.catalogReply() == nullptr);
Opened const opened = openAndWait(context, source);
REQUIRE(opened.has_value());
CHECK(source.bundle() != nullptr);
CHECK(source.catalogReply() != nullptr);
CHECK(opened->applicationId == "probe");
CHECK(opened->documents.contains("probe.home"));
CHECK(opened->documents.contains("probe.other"));
Expand Down
8 changes: 5 additions & 3 deletions tests/net/test_push_net.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -312,20 +312,22 @@ TEST_CASE("net_connect_handler_fires_on_first_and_reconnect", "[push][channel]")
std::scoped_lock const lock{mtx};
return seen;
};
// Each step waits on a real connect and handshake, which under Valgrind takes far longer than the default budget.
::morph::testing::WaitBudget const budget{10s};
CountingExec reconnectExec;
backend.setConnectHandler([&] { record("connect"); });
backend.setReconnectHandler([&] { record("reconnect"); }, &reconnectExec);

{
::morph::net::SocketServer first{*server, port};
REQUIRE(first.listen());
REQUIRE(::morph::testing::waitUntil([&] { return snapshot().size() == 1; }));
REQUIRE(::morph::testing::waitUntil([&] { return snapshot().size() == 1; }, budget));
}
REQUIRE(::morph::testing::waitUntil([&] { return !backend.waitForConnected(0ms); }));
REQUIRE(::morph::testing::waitUntil([&] { return !backend.waitForConnected(0ms); }, budget));

::morph::net::SocketServer second{*server, port};
REQUIRE(second.listen());
REQUIRE(::morph::testing::waitUntil([&] { return snapshot().size() == 3; }));
REQUIRE(::morph::testing::waitUntil([&] { return snapshot().size() == 3; }, budget));
// The connect handler runs before the reconnect handler is posted.
CHECK(snapshot() == std::vector<std::string>{"connect", "connect", "reconnect"});
}
Expand Down
Loading
Loading