bank: a database per ctest case instead of one shared file, and a rounding that does not charge a whole minor unit for half of one (fixes #682, fixes #678, refs #685) - #686
Conversation
…tops failing 21 of 21 (fixes #682) `catch_discover_tests()` registers one ctest case per `TEST_CASE`, and ctest runs each as its own process. All fourteen `bank_tests` sources named one fixed path -- `temp_directory_path() / "morph_bank_tests.db"` -- and `ensureDatabase()` deleted and re-migrated it on the way in. That is correct under a serial ctest and nothing else. Measured on 7d4ca45, `ctest -j 12 -L bank`: 0% tests passed, 21 tests failed out of 21 HY000 (10) - [SQLite]disk I/O error (10) three runs out of three. CI runs ctest serially, which is the only reason this was latent rather than red. The ladder's remedy for the same hazard is `RESOURCE_LOCK morph_ladder_test_db` (cmake/morph_add_rung.cmake), which serialises the cases. Bank does not have to buy correctness with parallelism: no case here reads state another case wrote -- every process already began by wiping the schema -- so a path per process is behaviour-preserving where the lock is not free. `tests/unique_test_database.hpp` claims a directory under the temp directory with `create_directory`, whose `true` return is an exclusive claim against other processes, and removes it in the static's destructor. After, on the same tree and the same command, five runs out of five: 100% tests passed out of 21 Total Test time (real) = 1.09 sec against 5.7s for the same 21 cases serially -- which is what a `RESOURCE_LOCK` would have pinned every developer run to. `examples/bank/CMakeLists.txt` records that trade next to the three `catch_discover_tests()` calls, so the absent lock reads as a decision rather than the oversight it was. `bank_gui_qml_tests` had the same defect in miniature: two `TEST_CASE`s, one `morph_bank_gui_qml.db`, and a comment asserting that wiping "once per process" made them safe -- which is exactly what does not hold when the process is the case. Both GUI suites now take a private path too. All three suites: `ctest -j 12 -L bank` -> 100% of 28. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VptDWG2fKr2vBnLSJcgzgW
… a minor unit is not charged a whole one (fixes #678) `parseMinor` computed `(major * scale) + 0.5` and truncated. That is not "round to nearest": for the double immediately below one half the sum is not representable and rounds **up** to exactly 1.0, so the truncation returns 1 for a value that is below half a minor unit. The witness is typeable, not constructed: "0.004999999999999999" -> scaled = 0.49999999999999994 -> minor = 1 x = 0.49999999999999994449 x < 0.5 = true x + 0.5 = 1 (int64)(x + 0.5) = 1 <- returned std::llround(x) = 0 <- correct `std::llround` is the fix, and it also removes the construct `bugprone-incorrect-roundings` was pointing at rather than moving it somewhere the check no longer matches. morph#663's guard order survives, which is the one property this change rests on. The bound now applies to the *unrounded* scaled value, which is the stronger check: every `double` strictly below 2^63 is at most 2^63-1024, so `llround` of anything that passes the guard lands inside `std::int64_t` with 1023 to spare, and the accept/reject edge does not move -- doubles near 2^63 are 1024 apart, so the `+ 0.5` never crossed it either. `nan` is still rejected by the negated comparison, and the existing morph#663 cases still pass. The new case uses the witness. `0.005` and `0.004` give the same answer before and after and would have pinned nothing; the pre-existing `0.005` case stays, now labelled as the half-way input that must keep rounding away from zero. Measured on this branch, before the Format.hpp change and after: before: FAILED: CHECK( parseMinor("0.004999999999999999") == 0 ) with expansion: {?} == 0 assertions: 3 | 1 passed | 2 failed after: All tests passed (20 assertions in 5 test cases) Scope, plainly: this is one minor unit on a pathological input. It is not morph#663 -- that was undefined behaviour on ordinary input -- and it is worth fixing because the fix is smaller than the argument, not because it is dangerous. Verified under `clang-ubsan` with bank + bank GUI, the configuration morph#683's bank-sanitizers job uses: `ctest -L bank` -> 100% of 29, and check_sanitizer_instrumentation reports 9 of 9 binaries instrumented. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VptDWG2fKr2vBnLSJcgzgW
Runner verificationThe claim you flagged as the one to check hardest — checked numerically, and it holds exactly: Moving the guard onto the unrounded value is the stronger form, and the accept/reject edge genuinely cannot move: doubles up there are 1024 apart, so #682's numbers are the right shape — the before is a failure, not an absence: Choosing per-case databases over #678's witness test fails first, shown against the old header on the same branch — and relabelling the pre-existing Two things I am deciding, so they do not sit in a hand-backThe #685 is the important one, and its blocker is mine. You could not add a regression guard because The third data point you predicted and found — Declining to cite the contaminated 19.45s serial measurement was right, and saying why is better than quietly omitting it. Not verified by me: the ctest runs, the UBSan job reproduction, and the instrumentation sweep — all need builds I did not run. Windows and macOS remain CI's job, as you say. 🤖 Generated with Claude Code |
Codecov Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
Two bank tickets, one commit each.
parseMinor's+ 0.5rounded a sub-half value up.std::llroundinstead, with morph#663's guard order preserved.#682 — per-case databases, not a
RESOURCE_LOCKcatch_discover_tests()registers one ctest case perTEST_CASEand ctest runs each as its own process. All fourteenbank_testssources named one fixed path,temp_directory_path() / "morph_bank_tests.db", andensureDatabase()deleted and re-migrated it on the way in — correct under a serial ctest and nothing else.Before, on
7d4ca453(clang-release,-DMORPH_BUILD_BANK_EXAMPLE=ON, clang 22.1.8):Three runs of three. Serial was green, which is the only reason CI never saw it.
After, same tree, same command, five runs of five:
Why not the lock
cmake/morph_add_rung.cmake:47-54explains the ladder's choice:RESOURCE_LOCK morph_ladder_test_db, deliberately the same name across every rung, because ctest serialises any two cases sharing a lock name even across binaries. That is the right trade for the ladder, where rungs may point at one database through a sharedODBC_CONNECTION_STRINGoverride.Bank does not have to buy correctness with parallelism. Nothing here reads state another case wrote — every process already began by wiping the schema, and tests isolate themselves by owner principal — so a path per process is behaviour-preserving, which a lock is not free to be:
ctest -j 12 -L bank, 21 casesWhat the lock would have cost, stated plainly: it pins every developer's bank run to the suite's serial wall clock — 5.7s measured here for 21 cases, and it would have grown with each case added. It is also a remedy that has to be remembered at each new
catch_discover_tests()call, which is precisely how #682 happened. The per-process path is remembered by the one header every test already includes.examples/bank/CMakeLists.txtnow records the trade next to the threecatch_discover_tests()calls, so the absentRESOURCE_LOCKreads as a decision rather than the oversight it was.Scope beyond
bank_testsbank_gui_qml_testshad the same defect in miniature — twoTEST_CASEs, onemorph_bank_gui_qml.db— under a comment asserting that wiping "once per process rather than once per case" made them safe, which is exactly what does not hold when the process is the case. Both GUI suites take a private path too. All three:ctest -j 12 -L bank→ 100% of 29.The mechanism
examples/bank/tests/unique_test_database.hppclaims a directory withstd::filesystem::create_directory, whosetruereturn is an exclusive claim against other processes (mkdir/CreateDirectoryWare atomic), retrying on collision — not "generate a name, then check whether it exists", which would race. A function-local static owns it andremove_alls it in its destructor. Best effort on teardown: a process killed outright leaves a stale temp directory, which is not a failed run. Verified:ls -d /tmp/morph_bank_tests-*→ 0 after a full suite run.#678 —
std::llroundparseMinorcomputed(major * scale) + 0.5and truncated, which is not round-to-nearest. For the double immediately below one half, the sum is not representable and rounds up to exactly 1.0.The witness is typeable, not constructed:
The test uses the witness, and it fails first. Built on this branch with the old
Format.hppand the new test case:and after the one-line change,
All tests passed (20 assertions in 5 test cases).0.005and0.004pass both before and after, so the pre-existing0.005case stays but is now labelled as the half-way input that must keep rounding away from zero, rather than as evidence of anything.Scope, plainly: one minor unit on a pathological input. This is not #663 — that was undefined behaviour on ordinary input — and it is worth fixing because the fix is smaller than the argument, not because it is dangerous. It also removes the construct
bugprone-incorrect-roundingswas pointing at, rather than moving it somewhere the check no longer matches.The one claim this branch's safety rests on
That #663's guard still runs before the rounding, and still bounds it.
The guard now applies to the unrounded scaled value, which is the stronger of the two checks. Every
doublestrictly below0x1p63is at most2^63 - 1024, sollroundof anything that passes the guard lands insidestd::int64_twith 1023 to spare. The accept/reject edge does not move either: doubles near 2^63 are spaced 1024 apart, so adding0.5never crossed it — the oldminor < boundand the newscaled < boundreject exactly the same inputs.nanis still caught by the negated comparison (!(nan < bound)is true), and every #663 case still passes.If that reasoning is wrong, the failure is a wrong answer or an abort on a large amount, which is #663 again. It is the thing to check hardest in review.
Review notes, done inline
ctest -j 12that fails 21/21 before and passes 21/21 after; parseMinor's '+ 0.5' rounding: the clang-tidy check that was pointing at it no longer does, and the question is still open #678's test case fails on the unfixed function in the same build tree..github/workflows/is held by another lane. Filed as No gate catches a ctest case sharing an on-disk database, and no CI leg runs ctest in parallel #685 rather than folded in, together with the missing detector for the pattern and the observation that this is the third instance in two days of a documented remedy not reaching a new consumer (No gate catches a Q_OBJECT header split from its TU; the first signal is a link error in six CI legs #659 was the first).clang-tidyreportsreadability-identifier-lengthforbool okatFormat.hpp:106. Pre-existing, on a line this diff does not touch, so CI'sclang-tidy-diffwill not see it. Not filed: it is the designed scope of a diff-based run, not a defect.bank_test_support.hpp's doc comment claimed the file was shared "for the whole binary", which was true and misleading at once — the binary is one case. Corrected in place.Verification
Measured on this branch, Linux, clang 22.1.8 (CI pins
CLANG_VERSION: "22", no skew).ctest -j 12 -L bank, clang-release, all three suitesctest -L bank, clang-releasectest -L bank, clang-ubsan + bank GUI (#683's job)ctest -j 12 -L bank, clang-ubsancheck_sanitizer_instrumentation.sh build/clang-ubsan ubsan__ubsan_symbolscheck_rung_filters.shcheck_tidy_suppression_scope.shcheck_qobject_moc_pairing.py+--self-testcheck_bidi_controls.pycheck_nolint_directives.shcheck_ctest_name_collisions.sh build/clang-releaseclang-format --dry-run -Werrorover the 19 touched filesclang-tidy -p build/clang-releaseover the changed headers/TUsBoth build directories were configured from empty, because this touches build structure.
Not verified:
create_directoryis atomic on both, and nothing here is POSIX-specific, but I ran only Linux.windows-everythingconfigures bank, so CI will say.ctest -j. I ran-j 12only over-L bank. Recorded as the open half of No gate catches a ctest case sharing an on-disk database, and no CI leg runs ctest in parallel #685.-jabove 12, and on a runner with fewer cores than this box.remove()did not clean up; the honest serial number is the post-fix 5.06s, which is the figure used above and in the CMake comment.Closes #682, closes #678. Filed: #685.
🤖 Generated with Claude Code
https://claude.ai/code/session_01VptDWG2fKr2vBnLSJcgzgW