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:31:46 +0000
committerbitflash-maintainer <noreply@bitflash.invalid>2026-08-23 11:31:46 +0000
commitd273a04ff89233f0f01431bd6e3c18d5261818b0 (patch)
tree934bf3a125082673a2e308d0a124cae1dccc34b5 /src
parent6c0ed67a2f6b39b630c5748ecf214f86e9d26a5c (diff)
payout: build pool_status.json under the locks, write it after releasing them
RefreshStats runs on the event loop -- the one thread that reads and writes every miner's socket -- and took gStatsMutex and then gPayoutLedgerMutex at function scope, then called WritePoolStatusJsonLocked() with both still held. Since 86b39b9 that write ends in fsync() on the file and fsync() on its directory. So every ten seconds the event loop performed two synchronous disk flushes holding the mutex the payout thread must take to record that a miner has been paid, and the payout thread waited behind them. Measured here on ext4/LVM/SSD: 3.78 ms mean per publish. On the pool's shared virtual disk it is higher and it has no upper bound under load. That is worse than the code the merge replaced, which wrote the same file under the same locks but buffered and without fsync. It also made 33f2e8a's headline claim -- "no thread waits on I/O holding gPayoutLedgerMutex, gPayoutExecMutex or gPoolRoundsMutex" -- false on the commit that asserted it. Split the same way as the three ledgers: SerializePoolStatusLocked() returns the image, PublishPoolStatus() writes it, and RefreshStats calls the second one after leaving the scope that holds the first. The ledger lock is also scoped to the loop that actually needs it, which is the gPayoutViews rebuild; the serialiser never needed it, since those rows were already copied. No version guard is added and none is needed: RefreshStats is still the only caller of either half, it still runs only on the event loop, and it now calls them in sequence on that one thread, so pool_status.json still has a single writer by construction. The comment saying so has been rechecked against the new shape rather than carried over. The claim is now tested rather than asserted. The self-test points strPoolStatusFile at a path whose ".tmp" is a FIFO, so the publish parks in open(2) until the test chooses to read, and every mutex the write must not be holding is required to be acquirable while it is parked. Against the previous code that test cannot pass: the writer sits inside fopen() holding gStatsMutex and gPayoutLedgerMutex, and nothing releases them until the write is allowed to finish. Verified by reintroducing the defect -- two assertions fail, deterministically. Property: pool_status.json is serialised under the locks and written with all four released, so no payout mutex is ever held across I/O.
Diffstat (limited to 'src')
-rw-r--r--src/rpc.cpp290
1 files changed, 237 insertions, 53 deletions
diff --git a/src/rpc.cpp b/src/rpc.cpp
index be265db..403cb14 100644
--- a/src/rpc.cpp
+++ b/src/rpc.cpp
@@ -451,9 +451,20 @@ static std::string PoolRoundsFilePath()
451// number; publishing happens with the data mutex released and refuses any 451// number; publishing happens with the data mutex released and refuses any
452// blob whose sequence number is not newer than what is already on disk. So 452// blob whose sequence number is not newer than what is already on disk. So
453// writers can queue in any order and finish in any order -- the file only 453// writers can queue in any order and finish in any order -- the file only
454// ever moves forwards. Crucially, no thread ever waits for I/O while holding 454// ever moves forwards.
455// gPayoutLedgerMutex / gPayoutExecMutex / gPoolRoundsMutex, which is the 455//
456// contention the merge was right to remove from the event loop. 456// The other half of the rule is that no thread waits for I/O while holding
457// gPayoutLedgerMutex, gPayoutExecMutex or gPoolRoundsMutex, which is the
458// contention the merge was right to remove from the event loop. That was
459// claimed here before it was true: pool_status.json is written by the same
460// WriteFileAtomic and RefreshStats held gPayoutLedgerMutex at function scope
461// across the call, so once the writes gained fsync() the event loop was doing
462// two of them every ten seconds with the payout thread's mutex held -- worse
463// than the buffered write the merge replaced. The status file now goes through
464// the same serialise-under-the-lock / publish-outside-it split as the ledgers,
465// and the rule holds for all four files. It is checked, not asserted: see the
466// "no payout mutex is held across a publish" section of the payout-ledger
467// self-test.
457 468
458// A serialised file image plus the version of the in-memory state it was 469// A serialised file image plus the version of the in-memory state it was
459// taken from. 470// taken from.
@@ -2100,7 +2111,22 @@ static std::string PoolStatusFilePath()
2100 return GetAppDir() + "/pool_status.json"; 2111 return GetAppDir() + "/pool_status.json";
2101} 2112}
2102 2113
2103static void WritePoolStatusJsonLocked() 2114// Serialise the operator status file. Caller must hold gStatsMutex (this reads
2115// gStats / gWorkerStats / gPayoutViews); gPoolRoundsMutex is taken and
2116// released inside.
2117//
2118// Split from the write for the same reason as the three ledgers, and for a
2119// worse instance of the same problem. This used to be called from RefreshStats
2120// with gPayoutLedgerMutex held at function scope, and once the ledger writes
2121// gained fsync() that call became two fsyncs -- one on the file, one on its
2122// directory -- performed by the event loop, the thread that owns every miner's
2123// socket, with the mutex the payout thread needs in its hand. Measured on the
2124// build host (ext4 on LVM on SSD): 3.78 ms per publish, every 10 seconds, and
2125// unbounded on a shared virtual disk under load.
2126//
2127// Nothing here needs the ledger mutex at all: the pending-payout rows were
2128// already copied into gPayoutViews under it, further up RefreshStats.
2129static std::string SerializePoolStatusLocked()
2104{ 2130{
2105 nlohmann::json j; 2131 nlohmann::json j;
2106 j["schema"] = "bitflash-pool-status-1"; 2132 j["schema"] = "bitflash-pool-status-1";
@@ -2184,11 +2210,20 @@ static void WritePoolStatusJsonLocked()
2184 } 2210 }
2185 } 2211 }
2186 2212
2187 // No version guard and no write mutex here, deliberately: RefreshStats is 2213 return j.dump(2);
2188 // the only caller and it only ever runs on the event loop, so there is a 2214}
2189 // single writer of pool_status.json by construction. It shares 2215
2190 // WriteFileAtomic so the durability rules stay in one place. 2216// Publish the operator status file. Call with every payout mutex released.
2191 WriteFileAtomic(PoolStatusFilePath(), j.dump(2), "pool status"); 2217//
2218// Still no version guard and no write mutex, deliberately, and the split does
2219// not change the argument: RefreshStats remains the only caller, it still runs
2220// only on the event loop, and it now calls this after leaving the scope that
2221// serialised -- so the serialise and the write stay strictly ordered on one
2222// thread and pool_status.json still has a single writer by construction. It
2223// shares WriteFileAtomic so the durability rules stay in one place.
2224static void PublishPoolStatus(const std::string& blob)
2225{
2226 WriteFileAtomic(PoolStatusFilePath(), blob, "pool status");
2192} 2227}
2193 2228
2194// --------------------------------------------------------------------------- 2229// ---------------------------------------------------------------------------
@@ -2907,6 +2942,10 @@ static std::string PayoutTestAddWalletTx(const std::string& comment,
2907 return hash.GetHex(); 2942 return hash.GetHex();
2908} 2943}
2909 2944
2945// Defined below with the event loop; the status-file publication test drives
2946// it directly.
2947static void RefreshStats(int blocksFoundThisSession, uint64 roundShareTotal);
2948
2910int RunPayoutLedgerSelfTest() 2949int RunPayoutLedgerSelfTest()
2911{ 2950{
2912 int nFail = 0; 2951 int nFail = 0;
@@ -3455,6 +3494,136 @@ int RunPayoutLedgerSelfTest()
3455 strPoolRoundsFile = savedRoundsFile; 3494 strPoolRoundsFile = savedRoundsFile;
3456 } 3495 }
3457 3496
3497 // -- 11. No payout mutex is held across the status-file write ------------
3498 //
3499 // RefreshStats runs on the event loop -- the thread that owns every
3500 // miner's socket -- and used to hold gStatsMutex and gPayoutLedgerMutex at
3501 // function scope across WriteFileAtomic. Once that write gained fsync() on
3502 // the file and on its directory, publishing pool_status.json parked the
3503 // payout thread behind two disk flushes every ten seconds.
3504 //
3505 // Timing assertions would be flaky, so this does not measure anything. It
3506 // makes the write block for as long as the test likes: strPoolStatusFile
3507 // points at a path whose ".tmp" is a FIFO, and open(2) for writing on a
3508 // FIFO does not return until a reader arrives. While RefreshStats is
3509 // parked in there, every mutex it must NOT be holding has to be
3510 // acquirable. Against the previous code this test does not merely fail, it
3511 // cannot pass: the writer is inside fopen() with both mutexes held and
3512 // nothing will release them until the test lets the write finish.
3513#ifndef _WIN32
3514 {
3515 PayoutTestResetState();
3516 const std::string statusPath = dir + "/pool_status_selftest.json";
3517 const std::string statusTmp = statusPath + ".tmp";
3518 const std::string savedStatusFile = strPoolStatusFile;
3519 const int64 savedLastWrite = gLastPoolStatusWrite;
3520 remove(statusPath.c_str());
3521 remove(statusTmp.c_str());
3522 strPoolStatusFile = statusPath;
3523 gLastPoolStatusWrite = 0; // due a publish on the next refresh
3524
3525 // Give the status file something to report, so the ledger mutex is
3526 // genuinely taken on the way through rather than skipped over.
3527 {
3528 std::lock_guard<std::mutex> lk(gPayoutLedgerMutex);
3529 PendingPayout pp;
3530 pp.blockHeight = 909; pp.blockHash = 0; pp.matureAtHeight = 909;
3531 pp.amounts[addrA] = 3 * COIN;
3532 gPendingPayouts.push_back(pp);
3533 }
3534
3535 bool fifoOk = (mkfifo(statusTmp.c_str(), 0600) == 0);
3536 check(fifoOk, "a FIFO stands in for the status file's temp file, so the"
3537 " write can be held open");
3538 if (fifoOk) {
3539 std::atomic<bool> done(false);
3540 std::thread writer([&done]() {
3541 RefreshStats(0, 0);
3542 done = true;
3543 });
3544
3545 // Settle: the work before the write is a handful of vector
3546 // operations, so anything past this point is inside fopen(). The
3547 // !done check below is what actually proves it -- with no reader
3548 // there is no other way for this thread to still be running.
3549 Sleep(500);
3550 bool blocked = !done;
3551 bool gotLedger = false, gotStats = false, gotExec = false,
3552 gotRounds = false;
3553 for (int i = 0; i < 1000 && blocked; i++) {
3554 if (!gotLedger && gPayoutLedgerMutex.try_lock()) {
3555 gotLedger = true; gPayoutLedgerMutex.unlock();
3556 }
3557 if (!gotStats && gStatsMutex.try_lock()) {
3558 gotStats = true; gStatsMutex.unlock();
3559 }
3560 if (!gotExec && gPayoutExecMutex.try_lock()) {
3561 gotExec = true; gPayoutExecMutex.unlock();
3562 }
3563 if (!gotRounds && gPoolRoundsMutex.try_lock()) {
3564 gotRounds = true; gPoolRoundsMutex.unlock();
3565 }
3566 if (gotLedger && gotStats && gotExec && gotRounds)
3567 break;
3568 Sleep(2);
3569 }
3570
3571 check(blocked,
3572 "the status-file write is held open for the duration of this"
3573 " test");
3574 check(!blocked || gotLedger,
3575 "gPayoutLedgerMutex is free while pool_status.json is being"
3576 " written");
3577 check(!blocked || (gotExec && gotRounds),
3578 "gPayoutExecMutex and gPoolRoundsMutex are free while"
3579 " pool_status.json is being written");
3580 check(!blocked || gotStats,
3581 "gStatsMutex is free while pool_status.json is being written");
3582
3583 // Let the write finish. Drain to EOF rather than closing early:
3584 // the writer is still filling the pipe and a closed read end would
3585 // deliver it SIGPIPE.
3586 std::thread reader([&statusTmp]() {
3587 FILE* rf = fopen(statusTmp.c_str(), "r");
3588 if (!rf) return;
3589 char buf[4096];
3590 while (fread(buf, 1, sizeof(buf), rf) > 0) { }
3591 fclose(rf);
3592 });
3593 writer.join();
3594 reader.join();
3595 }
3596 remove(statusTmp.c_str());
3597
3598 // And the ordinary path still produces the file it always did.
3599 gLastPoolStatusWrite = 0;
3600 RefreshStats(0, 0);
3601 std::string onDisk;
3602 bool statusOk = false;
3603 if (PayoutTestReadFile(statusPath, onDisk)) {
3604 try {
3605 nlohmann::json j = nlohmann::json::parse(onDisk);
3606 statusOk = j.value("schema", "") == "bitflash-pool-status-1" &&
3607 j["pendingPayouts"].is_array() &&
3608 j["pendingPayouts"].size() == 1 &&
3609 j["pendingPayouts"][0].value("matureAtHeight", 0) == 909 &&
3610 j["pendingPayouts"][0].value("totalAmountSatoshis",
3611 (int64)0) == 3 * COIN;
3612 } catch (...) {
3613 statusOk = false;
3614 }
3615 }
3616 check(statusOk,
3617 "splitting the serialise from the write leaves pool_status.json"
3618 " saying the same thing");
3619
3620 remove(statusPath.c_str());
3621 remove((statusPath + ".tmp").c_str());
3622 strPoolStatusFile = savedStatusFile;
3623 gLastPoolStatusWrite = savedLastWrite;
3624 }
3625#endif
3626
3458 // -- clean up ------------------------------------------------------------ 3627 // -- clean up ------------------------------------------------------------
3459 PayoutTestResetState(); 3628 PayoutTestResetState();
3460 CRITICAL_BLOCK(cs_mapWallet) 3629 CRITICAL_BLOCK(cs_mapWallet)
@@ -3615,56 +3784,71 @@ int RunPoolStratumSelfTest()
3615// --------------------------------------------------------------------------- 3784// ---------------------------------------------------------------------------
3616static void RefreshStats(int blocksFoundThisSession, uint64 roundShareTotal) 3785static void RefreshStats(int blocksFoundThisSession, uint64 roundShareTotal)
3617{ 3786{
3618 std::lock_guard<std::mutex> lk(gStatsMutex); 3787 std::string statusBlob;
3788 bool fPublish = false;
3789 {
3790 std::lock_guard<std::mutex> lk(gStatsMutex);
3619 3791
3620 gStats.authorizedMiners = 0; 3792 gStats.authorizedMiners = 0;
3621 gStats.blocksFound = blocksFoundThisSession; 3793 gStats.blocksFound = blocksFoundThisSession;
3622 gStats.roundShares = roundShareTotal; 3794 gStats.roundShares = roundShareTotal;
3623 gStats.totalHashRate = 0.0; 3795 gStats.totalHashRate = 0.0;
3624 for (Miner* m : gMiners) 3796 for (Miner* m : gMiners)
3625 if (m->authorised) gStats.authorizedMiners++; 3797 if (m->authorised) gStats.authorizedMiners++;
3626 3798
3627 gWorkerStats.clear(); 3799 gWorkerStats.clear();
3628 for (Miner* m : gMiners) { 3800 for (Miner* m : gMiners) {
3629 if (!m->authorised || m->lastSeen <= 0) continue; 3801 if (!m->authorised || m->lastSeen <= 0) continue;
3630 PoolWorkerStatView v; 3802 PoolWorkerStatView v;
3631 v.address = m->address; 3803 v.address = m->address;
3632 v.worker = m->worker; 3804 v.worker = m->worker;
3633 v.roundShares = m->roundShares; 3805 v.roundShares = m->roundShares;
3634 v.totalShares = m->totalShares; 3806 v.totalShares = m->totalShares;
3635 v.lastSeen = m->lastSeen; 3807 v.lastSeen = m->lastSeen;
3636 v.hashRate = m->hashRate; 3808 v.hashRate = m->hashRate;
3637 gWorkerStats.push_back(v); 3809 gWorkerStats.push_back(v);
3638 gStats.totalHashRate += m->hashRate; 3810 gStats.totalHashRate += m->hashRate;
3639 } 3811 }
3640 std::sort(gWorkerStats.begin(), gWorkerStats.end(), 3812 std::sort(gWorkerStats.begin(), gWorkerStats.end(),
3641 [](const PoolWorkerStatView& a, const PoolWorkerStatView& b) { 3813 [](const PoolWorkerStatView& a, const PoolWorkerStatView& b) {
3642 return a.roundShares != b.roundShares ? a.roundShares > b.roundShares 3814 return a.roundShares != b.roundShares ? a.roundShares > b.roundShares
3643 : a.lastSeen > b.lastSeen; 3815 : a.lastSeen > b.lastSeen;
3644 }); 3816 });
3645 3817
3646 gPayoutViews.clear(); 3818 // Scoped, not held to the end of the function. At function scope this
3647 std::lock_guard<std::mutex> ledger(gPayoutLedgerMutex); 3819 // lock covered the status-file write below, so the event loop did two
3648 for (const PendingPayout& pp : gPendingPayouts) { 3820 // fsyncs holding the mutex the payout thread takes to record that a
3649 PendingPayoutView pv; 3821 // miner has been paid.
3650 pv.matureAtHeight = pp.matureAtHeight; 3822 gPayoutViews.clear();
3651 pv.recipients = 0; 3823 {
3652 pv.totalAmount = 0; 3824 std::lock_guard<std::mutex> ledger(gPayoutLedgerMutex);
3653 for (auto& kv : pp.amounts) { 3825 for (const PendingPayout& pp : gPendingPayouts) {
3654 if (pp.paidTxids.count(kv.first)) 3826 PendingPayoutView pv;
3655 continue; 3827 pv.matureAtHeight = pp.matureAtHeight;
3656 pv.recipients++; 3828 pv.recipients = 0;
3657 pv.totalAmount += kv.second; 3829 pv.totalAmount = 0;
3830 for (auto& kv : pp.amounts) {
3831 if (pp.paidTxids.count(kv.first))
3832 continue;
3833 pv.recipients++;
3834 pv.totalAmount += kv.second;
3835 }
3836 if (pv.recipients > 0)
3837 gPayoutViews.push_back(pv);
3838 }
3658 } 3839 }
3659 if (pv.recipients > 0)
3660 gPayoutViews.push_back(pv);
3661 }
3662 3840
3663 int64 now = GetTime(); 3841 int64 now = GetTime();
3664 if (now - gLastPoolStatusWrite >= 10) { 3842 if (now - gLastPoolStatusWrite >= 10) {
3665 WritePoolStatusJsonLocked(); 3843 statusBlob = SerializePoolStatusLocked();
3666 gLastPoolStatusWrite = now; 3844 gLastPoolStatusWrite = now;
3845 fPublish = true;
3846 }
3667 } 3847 }
3848 // Every payout mutex is released here, and gStatsMutex with them. This is
3849 // where the two fsyncs happen, and no other thread is waiting behind them.
3850 if (fPublish)
3851 PublishPoolStatus(statusBlob);
3668} 3852}
3669 3853
3670// --------------------------------------------------------------------------- 3854// ---------------------------------------------------------------------------