offline+net+docs: seven tickets, five of which turned on a premise measurement contradicted (fixes #695, fixes #696, fixes #640, refs #658, refs #566, refs #725, refs #534) - #741
Merged
Conversation
…out to be stale (fixes #695) Three plan documents each recorded the same Lightweight limits, because each rung rediscovered them and wrote them into its own plan, where the next rung could not find them. This collects them into one page under docs/, where a rung author looks, and gives every entry what a plan entry does not have: the pinned revision it was verified at, the file and line in Lightweight that decides it, the workaround actually used in this tree, and the condition that retires it. Checking them at the pin (bbb972a7, g++ 16.2.1, -fsyntax-only, non-reflection) is what the page is for, and it immediately earned itself. Of the three constraints the issue names, only one survives: - Where() cannot bind a binary value: CONFIRMED, and now as a compile diagnostic rather than by reading the variant. SqlVariant::InnerType has zero binary alternatives; the identical call with a std::string compiles, so the refusal is about the value type. Filed upstream as LASTRADA-Software/Lightweight#618 so the entry has a retirement condition somebody tracks. - "Fluent Query/Update refuse HasMany-bearing records": FALSE in both halves. DataMapper::Update on a HasMany-bearing record compiles; so does Query<T>().Where(...).All(). What is true is narrower and is not about HasMany at all -- Query<Record>() has no Update() for ANY record, which the control probe on a HasMany-free record shows by failing identically. - "HasMany resolves a child FK by ordinal member index": RETIRED upstream. Record.hpp:242-255 now says in its own words that resolution is by relationship type, never by member position, and InverseBelongsToResolver carries three static_asserts turning every failure mode into a named compile error. Verified with the child's BelongsTo declared last, which positional resolution would get wrong. A fourth entry, found while checking the others and already worked around in-tree: the migration DSL has no predicate on any CreateIndex overload, so a partial unique index has no spelling. The page says outright that an entry is only as good as its last verification, because entries 2 and 3 are what that rule was written from. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VptDWG2fKr2vBnLSJcgzgW
…tempts does not compile (fixes #696) setAttempts(uint64_t itemId, uint32_t attempts) was two adjacent, mutually convertible integers. bugprone-easily-swappable-parameters said so at every implementation, which meant every implementor hand-wrote a suppression for a hazard they did not choose -- the framework exporting its lint bill to its consumers -- and the suppression left the hazard exactly where it was. The hazard is silent in both directions, which is what makes it worth a type rather than a NOLINT: setAttempts on an unknown id is a documented no-op, so a transposed call writes an id into a count, matches nothing, returns normally and throws nothing. The real item's count never advances, and the defect surfaces much later as a retry budget that never exhausts. morph::offline::Attempts is implicitly constructible from a narrow integer and deliberately NOT from a 64-bit one, which is what a QueueItem::id is. That is the whole mechanism: setAttempts(attempts, itemId) stops compiling, while every existing call site -- setAttempts(id, 3), setAttempts(id, counter) -- reads exactly as before. No test call site in the tree changed. A genuinely 64-bit count stays expressible with the narrowing visible at the call site. Proven rather than asserted, both directions: $ g++ -fsyntax-only ... -DTRANSPOSED=1 error: cannot convert 'const uint64_t' to 'morph::offline::Attempts' 8 | queue.setAttempts(attempts, itemId); $ g++ -fsyntax-only ... -DTRANSPOSED=0 exit=0 and the two static_asserts that hold the property in place were mutation tested -- weakening the constructor's constraint to sizeof(Count) <= sizeof(uint64_t) makes both of them fire, so neither is vacuous. They are written as a pair on purpose: an alias for uint32_t fails the first, an explicit constructor fails the second. QueueItem::attempts stays a plain uint32_t. It is a struct field reached by name with no adjacent same-typed field to transpose it with; the hazard was in the parameter list and that is where the type went. Deletes the hand-written NOLINT in examples/bank, which is the second data point the issue cites and the reason the tax was recurring rather than hypothetical. tests/test_sync_worker.cpp:406 is a recording test double whose override signature has to follow the interface; that one override is the only line touched in that file. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VptDWG2fKr2vBnLSJcgzgW
…ablished (fixes #640) The triage asked for one of two readings -- glibc's ATTRIBUTES table or the POSIX text -- and neither man page is installed on this machine either. So the property was measured directly, which is stronger than reading a table because it tests the hazard rather than the documentation of it. glibc 2.44, gcc -O0: EAI_AGAIN ptr=0x7febd9bb62f2 "Temporary failure in name resolution" EAI_FAIL ptr=0x7febd9bb632e "Non-recoverable failure in name resolution" EAI_AGAIN ptr=0x7febd9bb62f2 (second call, same code) distinct pointers for distinct codes: YES first string intact after second call: YES dladdr -> /usr/lib/libc.so.6, anonymous (a literal in a data section) unknown code 12345 -> "Unknown error" (a constant, not a formatted buffer) 7fa71e99f000-7fa71ea15000 r--p 0019f000 00:1c 15085232 /usr/lib/libc.so.6 The r--p is the load-bearing part. std::strerror's defect, which morph#625 removed, is a shared MUTABLE buffer a second caller overwrites; a read-only mapping cannot be one. Distinct pointers per code rule out a rotating slot, and even the unrecognised-code path returns a constant, so there is no per-call buffer on any path. musl/emscripten, which the WASM build links, is a static const char msgs[] table by inspection. Winsock's documented-unsafe gai_strerrorA is never compiled: TcpSocket is POSIX-only, and CI's Windows leg cannot reach this file. So the call stays, and now for two reasons the issue asked to be kept apart: rc is an EAI_* code and not an errno, so morph#641's errnoMessage() would render a confidently wrong string -- and separately, it does not have the defect that would make replacing it worth the effort. Recorded at the call site and in docs/spec/security.md, whose existing bullet said gai_strerror was "deliberately untouched" only for the first reason. Both records state what was NOT established: no other libc was checked and POSIX's own text is still unread, so this is a measurement on the configurations morph builds, not a portability guarantee. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VptDWG2fKr2vBnLSJcgzgW
Codecov Report✅ All modified and coverable lines are covered by tests. 📢 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.
Seven tickets, one commit per ticket where there was code to write. Three shipped; four were
rescope/unverifiedtickets whose deliverable was the answer, and the answers are on the issues.The through-line: five of the seven turned on a premise that measurement contradicted. Two Lightweight constraints three plan documents still assert are retired upstream. A GCC diagnostic reported at
-O0only reproduces at-O2. A thread-safety hazard measured its way toinvalid. And morph#658's "one server process, sequential corpus, therefore no second client" is false — the ledger server runs a four-thread pool and a 1 Hz background sweeper.Shipped
fixes #695— one page of Lightweight constraints, and two of the three turn out to be staledocs/LIGHTWEIGHT-CONSTRAINTS.md, linked fromexamples/IMPLEMENTATION.mdwhere a rung author looks. Every entry carries the pinned revision it was verified at, the file and line in Lightweight, the in-tree workaround and the condition that retires it.Checking the three seed constraints at the pin (
bbb972a7, g++ 16.2.1,-fsyntax-only, non-reflection) is what the page is for, and it earned itself immediately — only one of the three survives:Where()cannot bind a binary valueQuery/UpdaterefuseHasMany-bearing recordsHasManyresolves a child FK by ordinal member indexDataMapper::Updateon aHasMany-bearing record compiles. So doesQuery<T>().Where(...).All(). What is true is narrower and not aboutHasManyat all —Query<Record>()has noUpdate()for any record, which the control probe on aHasMany-free record shows by failing identically.Record.hpp:242-255now states that relations resolve by relationship type, never by member position, with threestatic_asserts turning every failure mode into a named compile error. Verified with the child'sBelongsTodeclared last, which positional resolution would get wrong.A fourth entry, found while checking the others and already worked around in-tree: no
CreateIndexoverload takes a predicate, so a partial unique index has no spelling in the migration DSL.Upstream issue filed for the live one, so the entry has a retirement condition someone tracks: LASTRADA-Software/Lightweight#618.
fixes #696— the attempt count gets its own typeThe triage's recount was right: five definitions, one production call site.
morph::offline::Attemptsis implicitly constructible from a narrow integer and deliberately not from a 64-bit one, which is what aQueueItem::idis.The transposed call, as required:
Two
static_asserts hold the property in place, and they were mutation tested — weakening the constructor's constraint fromsizeof(Count) <to<= sizeof(uint64_t)makes both fire:They are a pair on purpose: an alias for
uint32_tfails the first, anexplicitconstructor fails the second. Neither alone would mean anything.The
NOLINTinexamples/bank/src/offline/lightweight_offline_queue.cppis deleted. No test call site in the tree changed — the implicit-from-narrow constructor is what buys that, which is also why this stayed inside the sizing the triage established.QueueItem::attemptsstays a plainuint32_t: a struct field reached by name has no adjacent same-typed field to transpose with. The hazard was in the parameter list, and that is where the type went.fixes #640—gai_strerrormeasured, not assumedNeither man page is installed here either, so the property was measured directly rather than read off a table, which is stronger: it tests the hazard instead of the documentation of it.
glibc 2.44,
gcc -O0:r--pis the load-bearing line:std::strerror's defect is a shared mutable buffer, and a read-only mapping cannot be one. musl/emscripten is astatic const char msgs[]table by inspection. Winsock's documented-unsafegai_strerrorAis never compiled —TcpSocketis POSIX-only and CI's Windows leg cannot reach the file.So the call stays, for two reasons the issue asked to keep apart:
rcis anEAI_*code and not anerrno, and separately it does not have the defect. Recorded at the call site and indocs/spec/security.md, whose bullet previously gave only the first reason. Both records state what was not established: no other libc, and POSIX's own text still unread.Answered, not shipped —
refs, notfixesrefs #658— the static question is no, and the premise behind it is falseEnumerated every
DataMapperconstruction (eleven inledger_model.cpp, five inbudget_model.cpp, two inrule_model.cpp— all at the top of a publicexecute()) and everySqlTransaction(:880,:1537,:1601). No ledger handler opens a second mapper inside another.SetCategory, the handler that failed, is one mapper and one transaction;setCategoryImpltakesDataMapper&; the rule cascade passes the same mapper deliberately.By the triage's own terms that returns the issue to unexplained. Except the fourth fact in "one server process, sequential corpus ⇒ no second client" is wrong:
A four-thread worker pool and a 1 Hz sweeper that opens a second connection and dispatches a job holding a read transaction across a whole aggregation. Plus: the effective busy timeout is 60000 ms, not 5000 — Lightweight's
PostConnect()issuesPRAGMA busy_timeout = 60000after connect and wins over the connection string'sTimeout=. And SQLite returnsSQLITE_BUSYwithout invoking the busy handler when a read lock inside an open transaction is promoted while another connection holdsRESERVED— which is exactlySetCategory's shape (four reads inside the transaction, thenmapper.Update). Inferred, not measured; the full write-up and its falsification test are on the issue.refs #566— same family, different mechanism; they do not share a fixKanban does nest —
execute(MoveTaskPosition)holds three pooled connections at once (:962,:1199,:946) and opens a second write transaction on the third. But the enclosing transaction is committed before the nesting starts, so it is not #658's self-deadlock and #658's finding does not cover it. Said so on both issues, with a table.New, small, and concrete:
buildStateat:1175runs on connection A whiletransactionis still in scope, so autocommit is off and that post-commit read opens a second transaction held until end of function. That is the statement most likely to have thrown, and scoping the transaction to end at itsCommit()is a one-line reduction of the window. Recommended on the issue, not shipped — the root defect is post-commit work throwing out of a committed call, and per AGENTS.md that is the issue's change, not this batch's.refs #725— false positive, proven-O0/-O1clear it. But optimisation-dependence is only suggestive, so the sizes close it:"Array subscript 14" is byte offset 112 — in bounds of 136, out of bounds only of 112. GCC speculatively devirtualized
IModelHolder::attachActionLogtoModelHolder<OpportunityModel>'s override insideModelFactory::create<AccountModel>()— a dispatch that cannot happen — then applied the AccountModel holder's size to an offset from the OpportunityModel holder's layout.crmis the only rung withSelfJournal, which is exactly the 24-byte difference, answering the issue's third "not verified" item.No suppression added, per the batch's scope. Also corrects the issue's own repro: morph sets no default
CMAKE_BUILD_TYPE, so its unoptimised configure should not have reproduced this, and a-DCMAKE_BUILD_TYPE=Debugbuild ofladder_crm_testssucceeds here on the same GCC.refs #534— one of the three is a bug, one is second-order, one would break the productRescoped before building, as asked:
handshakeTimeout— build it. The only one that is a liveness defect rather than a policy: one byte parks a thread and an fd forever, nothing else in the class bounds it,close()cannot reap it, and no legitimate client behaves that way, so a timeout on it has no false-positive cost.maxConnections— worth building, second. Real, and parity withQtWebSocketServerConfig— but with a handshake timeout in place it defends a DoS posture, not a correctness one, and should be described that way.idleTimeout— reject. A desktop GUI client left open is idle by design.SocketServerhas no ping/pong, so it cannot tell "user at lunch" from "peer died", and the timeout would disconnect working clients on a timer. It becomes defensible only after a keepalive exists — at which point it is a different feature with a different name.sendTimeout— belongs with the first. Per the manager note, it is what makes net: reject illegal WebSocket frames, and bound the handshake read #558's already-mergedshutdownBoth()reachable, and it carries this ticket's added regression-test clause.Nothing built — said so plainly rather than half-landing two of four and leaving the third looking decided.
Filed in passing
ledger'sWalSnapshotGuardis not a WAL snapshot: nothing in the rung, the servers or the scenario runner setsjournal_mode, and Lightweight'sPostConnect()explicitly declines to ("We could also enable WAL mode here, but that changes the database file structure"). In rollback-journal mode itsBEGIN DEFERREDis a multi-second write barrier taken on a background worker, which is close to the opposite of what the name promises.DataMappertransaction-free only by declaration order.DataMapperPool::Returndoes no cleanup — verified from source at the pin, both growth strategies:DropAsyncBackendisDisableAsync()and nothing else. A connection returned with a transaction open is inherited by the next unrelated caller, which stalls 60 s then reportsdatabase is locked— attributed to a handler that did nothing wrong.Where()binding, with the reproduction, the control, the cause and three graded fixes.No
triage:labels applied to any of them.Gates
All run after the final edit, exit codes captured directly (no
| tail).check_allowlist_citations.pycheck_mutation_survivors.py--self-testscheck_nolint_directives.shcheck_rung_filters.shcheck_spec_sync.shcheck_spec_citations.shclang-format --dry-run -Werror(22.1.8)clang-tidy-diffover the real diff--target docctest -j8 -L "bank|ladder"ctest -j8(everything)clang-tidy-diffreturning clean on a small diff is a check that could pass by reading nothing, so it was mutated: injectingint q = 0;into a changed line makes it exit 1 withmisc-const-correctnessandreadability-identifier-length. It is reading the diff.check_spec_sync.shinitially failed —include/morph/net/**changed with no matching spec change. Resolved by extending the existinggai_strerrorbullet indocs/spec/security.md, which is where morph#625's sibling decision already lives, rather than by reaching for theno docs updatelabel.Not touched
scripts/mutation_survivors.jsonandscripts/branch_partial_allowlist.json— both still resolve cleanly against this branch, so there is no stale citation to report.include/morph/util/rational.hpp— untouched, per the note about PR #561.🤖 Generated with Claude Code
https://claude.ai/code/session_01VptDWG2fKr2vBnLSJcgzgW