upleb.uk

Public git repos — served from a NIP-34 GRASP relay at git.upleb.uk

summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorbitflash-maintainer <noreply@bitflash.invalid>2026-08-23 11:53:20 +0000
committerbitflash-maintainer <noreply@bitflash.invalid>2026-08-23 11:53:20 +0000
commit111fb886c2a38c8ab76fd064c9dbb1f403fde7c1 (patch)
treecaff3d96d9bd13d9096b5d90169ed908f942dd8f /src
parent46864cdf3c90d15d5d21a330bb54f43696aa7d3b (diff)
selftest: make a torn publish structurally invalid, not merely unlucky
Mutation-tested, the concurrency test did not test concurrency. With the per-file write mutex removed from PublishVersioned, `make tests` still went green 2 runs out of 5, and the assertion named "four threads publishing the same file never leave it unparseable" fired 0 times out of 5. It was asserting something true for reasons unrelated to the code it was pointed at. Two causes. The images were 64 arrays of 200 entries, identical in structure and within bytes of each other in length, so an interleaving of any two of them was still a valid JSON array. There was nothing structural for a torn write to break. They now alternate between one entry and five thousand: a write landing on top of another leaves either a truncated array or a small image followed by the tail of a large one, and neither parses. Worse, almost nothing was being written. Each thread walked a permutation of the images and PublishVersioned drops any blob not newer than what is on disk, so only the record highs in each permutation ever reached the disk -- about ten writes in total across four threads, which is not enough contention to produce a race however the images are shaped. Every publish now stamps its sequence number from a shared counter at the moment it is made, so every publish is newer than the last one issued and every publish writes: 400 of them, half of them 600 KB. And the file is now watched while the writers run rather than inspected once after they stop. "The published file is never observed unparseable" is the real invariant -- rename(2) is atomic, so a reader can only ever see a completely written image -- and sampling it only at the end looks at the one moment a torn file is least likely to have survived. Measured on the build host, five runs with the file mutex removed: 5 detections out of 5, every one of them on the unparseable-file assertion, which is the assertion that used to fire never. Five runs with the mutex in place: 5 passes. The whole payout-ledger self-test takes 1.4 s. Property: removing the serialisation this test exists to check fails the test, on every run.
Diffstat (limited to 'src')
-rw-r--r--src/rpc.cpp128
1 files changed, 96 insertions, 32 deletions
diff --git a/src/rpc.cpp b/src/rpc.cpp
index 7ea0597..79947df 100644
--- a/src/rpc.cpp
+++ b/src/rpc.cpp
@@ -3542,54 +3542,118 @@ int RunPayoutLedgerSelfTest()
3542 3542
3543 // -- 6. Concurrent publishers cannot interleave a file ------------------- 3543 // -- 6. Concurrent publishers cannot interleave a file -------------------
3544 // 3544 //
3545 // Two threads renaming the same "<path>.tmp" over the same target was the 3545 // Two threads opening and renaming the same "<path>.tmp" over the same
3546 // original defect. The images are made big so a torn write shows up as 3546 // target was the original defect. This test was mutation-tested and did
3547 // unparseable JSON rather than by luck. 3547 // not catch it: with the file mutex removed the suite still went green 2
3548 // runs in 5, and the "never leave it unparseable" assertion fired 0 times
3549 // in 5. Two reasons, both fixed here.
3550 //
3551 // First, every image had the same structure and near enough the same
3552 // length, so an interleaving of two of them was still valid JSON. The
3553 // images now alternate between ONE entry and FIVE THOUSAND, so a write
3554 // that lands on top of another leaves either a truncated array or a small
3555 // image followed by the tail of a large one -- neither of which parses.
3556 //
3557 // Second, and worse, almost nothing was being written. Each thread walked
3558 // a permutation of the images, and PublishVersioned drops any blob that is
3559 // not newer than what is on disk, so only the record highs in each
3560 // permutation ever reached the disk -- on the order of ten writes in
3561 // total, four threads, no contention to speak of. Every publish here is
3562 // stamped from a shared counter at the moment it is made, so every one of
3563 // them is newer than the last and every one of them writes.
3564 //
3565 // And the file is watched while the writers run, not only inspected after
3566 // they finish. "The published file is never observed unparseable" is the
3567 // actual invariant -- rename(2) is atomic, so a reader can only ever see a
3568 // completely written image -- and checking it once at the end samples the
3569 // one moment when a torn file is least likely to still be there.
3548 { 3570 {
3549 const std::string racePath = dir + "/publish_race_selftest.json"; 3571 const std::string racePath = dir + "/publish_race_selftest.json";
3550 std::mutex raceMutex; 3572 std::mutex raceMutex;
3551 uint64 racePublished = 0; 3573 uint64 racePublished = 0;
3552 3574
3553 std::vector<VersionedBlob> images; 3575 std::string tiny, huge;
3554 for (int i = 0; i < 64; i++) { 3576 {
3555 nlohmann::json arr = nlohmann::json::array(); 3577 nlohmann::json one = nlohmann::json::array();
3556 for (int k = 0; k < 200; k++) { 3578 nlohmann::json o;
3557 nlohmann::json o; 3579 o["image"] = 0;
3558 o["image"] = i; 3580 o["entry"] = 0;
3559 o["entry"] = k; 3581 o["address"] = addrA;
3560 o["address"] = addrA; 3582 o["amount"] = (int64)COIN;
3561 o["amount"] = (int64)(k + 1) * COIN; 3583 one.push_back(o);
3562 arr.push_back(o); 3584 tiny = one.dump(2);
3585
3586 nlohmann::json many = nlohmann::json::array();
3587 for (int k = 0; k < 5000; k++) {
3588 nlohmann::json e;
3589 e["image"] = 1;
3590 e["entry"] = k;
3591 e["address"] = addrA;
3592 e["amount"] = (int64)(k + 1) * COIN;
3593 many.push_back(e);
3563 } 3594 }
3564 VersionedBlob v; 3595 huge = many.dump(2);
3565 v.blob = arr.dump(2);
3566 v.seq = (uint64)i + 1;
3567 images.push_back(v);
3568 } 3596 }
3569 3597
3598 const int kThreads = 4;
3599 const int kPerThread = 100;
3600 const uint64 kTotal = (uint64)kThreads * kPerThread;
3601
3602 std::atomic<uint64> nextSeq(0);
3603 std::atomic<bool> racing(true);
3604 std::atomic<int> tornReads(0);
3605 std::atomic<int> goodReads(0);
3606
3607 std::thread watcher([&]() {
3608 bool seenOnce = false;
3609 while (racing) {
3610 std::string s;
3611 if (!PayoutTestReadFile(racePath, s)) {
3612 // Only meaningful once the file exists at all.
3613 if (seenOnce) tornReads++;
3614 continue;
3615 }
3616 seenOnce = true;
3617 try {
3618 if (nlohmann::json::parse(s).is_array()) goodReads++;
3619 else tornReads++;
3620 } catch (...) {
3621 tornReads++;
3622 }
3623 }
3624 });
3625
3570 std::vector<std::thread> threads; 3626 std::vector<std::thread> threads;
3571 for (int t = 0; t < 4; t++) { 3627 for (int t = 0; t < kThreads; t++) {
3572 threads.push_back(std::thread([&, t]() { 3628 threads.push_back(std::thread([&]() {
3573 for (size_t i = 0; i < images.size(); i++) { 3629 for (int i = 0; i < kPerThread; i++) {
3574 size_t idx = (i * 7 + (size_t)t * 13) % images.size(); 3630 VersionedBlob v;
3575 PublishVersioned(racePath, images[idx], raceMutex, 3631 v.seq = ++nextSeq;
3576 racePublished, "publish race selftest"); 3632 v.blob = (v.seq & 1) ? tiny : huge;
3633 PublishVersioned(racePath, v, raceMutex, racePublished,
3634 "publish race selftest");
3577 } 3635 }
3578 })); 3636 }));
3579 } 3637 }
3580 for (std::thread& th : threads) 3638 for (std::thread& th : threads)
3581 th.join(); 3639 th.join();
3582 3640 racing = false;
3641 watcher.join();
3642
3643 check(goodReads > 0,
3644 "the race test actually observed the file while it was being"
3645 " written");
3646 check(tornReads == 0,
3647 "four threads publishing the same file never leave it"
3648 " unparseable, at any instant while they are doing it");
3649
3650 // The highest sequence number issued is the one the file must settle
3651 // on: PublishVersioned refuses anything older, so whichever order the
3652 // writes complete in, that image is the last one that can land.
3653 const std::string& expected = (kTotal & 1) ? tiny : huge;
3583 std::string onDisk; 3654 std::string onDisk;
3584 bool read = PayoutTestReadFile(racePath, onDisk); 3655 bool read = PayoutTestReadFile(racePath, onDisk);
3585 bool parses = false; 3656 check(read && onDisk == expected,
3586 if (read) {
3587 try { parses = nlohmann::json::parse(onDisk).is_array(); }
3588 catch (...) { parses = false; }
3589 }
3590 check(read && parses,
3591 "four threads publishing the same file never leave it unparseable");
3592 check(read && onDisk == images.back().blob,
3593 "the published file settles on the highest version, whatever the" 3657 "the published file settles on the highest version, whatever the"
3594 " completion order"); 3658 " completion order");
3595 remove(racePath.c_str()); 3659 remove(racePath.c_str());