diff options
| author | bitflash-maintainer <noreply@bitflash.invalid> | 2026-08-23 11:31:46 +0000 |
|---|---|---|
| committer | bitflash-maintainer <noreply@bitflash.invalid> | 2026-08-23 11:31:46 +0000 |
| commit | d273a04ff89233f0f01431bd6e3c18d5261818b0 (patch) | |
| tree | 934bf3a125082673a2e308d0a124cae1dccc34b5 /src/rpc.cpp | |
| parent | 6c0ed67a2f6b39b630c5748ecf214f86e9d26a5c (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/rpc.cpp')
| -rw-r--r-- | src/rpc.cpp | 290 |
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 | ||
| 2103 | static 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. | ||
| 2129 | static 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. | ||
| 2224 | static 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. | ||
| 2947 | static void RefreshStats(int blocksFoundThisSession, uint64 roundShareTotal); | ||
| 2948 | |||
| 2910 | int RunPayoutLedgerSelfTest() | 2949 | int 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 | // --------------------------------------------------------------------------- |
| 3616 | static void RefreshStats(int blocksFoundThisSession, uint64 roundShareTotal) | 3785 | static 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 | // --------------------------------------------------------------------------- |