Bug 59

Mining IPC test times out on slow CI because its waits ignore the timeout factor

Status
Fixed
Symptom
Timeout; worst effect seen: test failure only
Where seen
Bitcoin Core functional tests
Trigger
Normal use; no special event is needed
First reported
2026-03-12 (first report)
Fixed
2026-03-24
Fix
bitcoin#34727

Summary

interface_ipc_mining.py failed intermittently on slow CI runners because its waitNext and waitTipChanged timeouts were hard-coded millisecond values that ignored the test framework's --timeout-factor, so a 1-second wait for a new block template could expire before the test's fee-bumping transaction arrived. The defect was in test code and did not affect users. Fixed by commit ad75b147b5c3 ("test: scale IPC mining wait timeouts by timeout_factor") in bitcoin#34727.

What happened

@Sjors's new libmultiprocess CI job in mp#253 ("ci: run some Bitcoin Core CI jobs"), which runs Bitcoin Core's IPC functional tests under sanitizers and on macOS with high parallelism, failed once on macOS (2026-03-12) in interface_ipc_mining.py at assert template6 is not None in run_block_template_test. That step waits for waitNext to return a better template after a fee-bumping transaction is sent, and waitNext returned nothing. Sjors put it down to "brittleness in the test itself under high CPU pressure"; @maflcko asked whether the timeout scaled with --timeout-factor, and it did not. The failure was seen once and not reproduced.

Root cause

test/functional/interface_ipc_mining.py set timeout = 1000.0 (milliseconds) and a one-minute long_timeout as literals, while the rest of the functional test framework scales waits by self.options.timeout_factor, which CI raises for slow sanitizer builds. BlockTemplate.waitNext returns null when its timeout expires without a better template, and WaitAndCreateNewBlock in src/node/miner.cpp only builds a new template to compare fees once per 1000 ms tick, so a 1000 ms timeout allows a single fee check, at the deadline. The test submits the fee-bumping transaction after the wait has started; on a loaded runner it was most likely not yet in the mempool when that one check ran. This is a reading of the code rather than a reproduced failure, but the one-check window is plainly too tight.

The test had only run in Bitcoin Core's CI before mp#253 ran it under TSan/ASan with many parallel jobs.

The code

Quoted from ad75b147b5c3^. run_block_template_test in test/functional/interface_ipc_mining.py gives waitNext a fixed one-second timeout, and sends the fee-bumping transaction only after the wait has started:

timeout = 1000.0 # 1000 milliseconds
# ...
        waitoptions.timeout = timeout
        waitoptions.feeThreshold = 1
# ...
        template6 = await wait_and_do(
            mining_wait_next_template(template5, stack, ctx, waitoptions),
            lambda: self.miniwallet.send_self_transfer(fee_rate=10, from_node=self.nodes[0]))
        assert template6 is not None

On the node, WaitAndCreateNewBlock in src/node/miner.cpp checks fees at most once per one-second tick, so with a one-second timeout the only check is at the deadline, and it gives up after that:

const auto deadline = now + options.timeout;
const MillisecondsDouble tick{1000};
// ...
        kernel_notifications.m_tip_block_cv.wait_until(lock, std::min(now + tick, deadline), [&]() EXCLUSIVE_LOCKS_REQUIRED(kernel_notifications.m_tip_block_mutex) {
// ...
    now = NodeClock::now();
} while (now < deadline);

return nullptr;
  1. Test: starts the waitNext call, sleeps 0.1 s in wait_and_do, then sends the transaction over RPC.
  2. Node: at the one-second deadline builds one new template and compares fees.
  3. On a loaded runner the transaction is not in the mempool yet at step 2, waitNext returns null, and assert template6 is not None fails.

The CI jobs raise --timeout-factor for slow builds, but these literals ignore it. The fix scales them (ad75b147b5c3; the same change in run_mining_interface_test and the long waits is omitted):

@@ -172,7 +172,7 @@ class IPCMiningTest(BitcoinTestFramework):
         """Test BlockTemplate interface methods."""
         self.log.info("Running BlockTemplate interface test")
         block_header_size = 80
-        timeout = 1000.0 # 1000 milliseconds
+        timeout = 1000.0 * self.options.timeout_factor

With a timeout factor above 1 the wait covers several ticks, so the node checks fees again after the transaction arrives. At the default factor of 1 the window is unchanged.

Fix

Commit ad75b147b5c3 (@enirox001) multiplies both tests' base timeout by self.options.timeout_factor, and makes the one-minute waits max(timeout, 60000.0) so they are never shorter than the scaled base timeout (+4/-4). It was added to bitcoin#34727 ("test: Add IPC wake-up test and reuse mining context"), which was already open with other test changes.

Timeline

Lessons

What would have prevented this bug, found it sooner, or made it easier to debug. The patterns page groups these lessons across all bugs.

Pattern Test timing, and tests that cannot fail: A test that assumes timing

  • Prevented by a review checklist item, a different API shape
  • Earliest detector code review (existed then)
  • Diagnosis easy
  • Fix design easy
  • Debugging aids CI artifacts
  • Rust no

Would Rust have prevented it? No. Unscaled literal timeouts in a Python functional test have nothing the Rust compiler could check. More on Rust

Could it have been prevented?

Yes. The functional test framework already has the rule: every wait scales by --timeout-factor, because CI raises it for sanitizer and slow-platform builds. The IPC mining test passed its own literals (1000.0 milliseconds, 60000.0) straight to waitNext and waitTipChanged as Cap'n Proto arguments, outside the framework's wait_until helpers where the scaling lives, so the convention was bypassed without anyone noticing. A one-second timeout was also tight on its own terms: the node's WaitAndCreateNewBlock checks fees once per one-second tick, so the test allowed exactly one check, at the deadline, with the fee-bumping transaction submitted after the wait began. A review item "any literal duration in a test is multiplied by the timeout factor" would have caught it; a framework helper that returns a scaled millisecond value for IPC calls would make the wrong form harder to write than the right one.

Could it have been found sooner?

By review, since the pattern is visible in the source. The only runtime check is a slow enough CI, and Bitcoin Core's own jobs never lost the race; it took the libmultiprocess repository's mp#253 job, running the same tests on macOS under high parallelism with a timeout factor of 4, to lose it once. The framework could lint for unscaled numeric timeouts, but no such lint exists and this is the first record that would have needed it.

What made it hard to debug?

Nothing about the cause; maflcko asked whether the timeout scaled the day it failed, and the fix is four changed lines. The failure was seen once and never reproduced, so the mechanism (one fee check per tick, transaction not yet in the mempool) is a reading of the code rather than an observed trace, which is the usual state of a load-dependent test failure and the reason to fix by inspection rather than wait for a second one.

Lesson

A test framework's timeout scaling only covers waits that go through the framework; when a test passes a duration to the system under test directly, scale it the same way, and size it in units of the system's own polling interval rather than in wall-clock guesses.

Classification

The survey's classification of this bug. The explorer charts these fields across all bugs; the method page explains them.

Field Value
Symptom timeout
Worst effect observed test-failure-only
Seen in functional-test
Scope test-code
Trigger Normal use (ordinary-path)
Phase steady-state
Side n/a
Component test-harness
Root cause kind assumption-violated
Fix kind test-only
Detection ci-intermittent
Needs a peer that is not libmultiprocess C++ no
Needed a sanitizer to see no
Confidence high