Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion src/support/lockedpool.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -262,7 +262,8 @@ size_t PosixLockedPageAllocator::GetLimit()
#ifdef RLIMIT_MEMLOCK
struct rlimit rlim;
if (getrlimit(RLIMIT_MEMLOCK, &rlim) == 0) {
if (rlim.rlim_cur != RLIM_INFINITY) {
if (rlim.rlim_cur != RLIM_INFINITY &&
std::cmp_less_equal(rlim.rlim_cur, static_cast<rlim_t>(std::numeric_limits<size_t>::max()))) {
return rlim.rlim_cur;
}
}
Expand Down
39 changes: 27 additions & 12 deletions src/util/fs_helpers.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,14 @@

#include <sync.h>
#include <util/byte_units.h> // IWYU pragma: keep
#include <util/check.h>
#include <util/fs.h>
#include <util/log.h>
#include <util/syserror.h>

#include <cerrno>
#include <fstream>
#include <limits>
#include <map>
#include <memory>
#include <optional>
Expand Down Expand Up @@ -152,27 +154,40 @@ bool TruncateFile(FILE* file, unsigned int length)
#endif
}

/**
* this function tries to raise the file descriptor limit to the requested number.
* It returns the actual file descriptor limit (which may be more or less than nMinFD)
*/
int RaiseFileDescriptorLimit(int nMinFD)
int RaiseFileDescriptorLimit(int min_fd)
{
Assert(min_fd >= 0);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

while auditing this line, i began to wonder: is there a practical "floor" we should be enforcing rather than allowing the process to attempt a start with values that guarantee a crash on non-Windows systems?

while manually incrementing limits during local functional tests, i realised that setting min_fd to values between 0 and 10 consistently triggers a failure. specifically, if the environment provides fewer than 5 descriptors, the node cannot even initialise its basic logging or database handles before the OS kernel denies further requests.

i observed that the functional test framework itself requires a slightly higher overhead to capture stdout, but the core issue remains: a limit that is technically "non-negative" can still be physically impossible for a bitcoin node to survive.

through manual isolation, i've identified two distinct failure thresholds:

  • test framework's floor (11 fds): required for the python test runner to successfully pipe stdout and manage the node process.
  • binary floor (≤ 4 fds): below this value, the bitcoind binary fails to start entirely, as it cannot satisfy basic requirements for standard i/o and its primary log file/entropy source.

proof of crash

the test i used.
--- a/test/functional/feature_init.py
+++ b/test/functional/feature_init.py
@@ -348,6 +349,15 @@ class InitTest(BitcoinTestFramework):
+    def init_rlimit_too_low(self):
+        """Test that bitcoind behaves predictably when the limit is too low to function."""
+        if self.RLIM_INFINITY is None:
+            return
+
+        self.log.info("Testing node startup with a very low fd limit")
+        # current behavior: crashes with OSError 24
+        self.restart_node_with_fd_limit(8)
+
     def run_test(self):
         self.init_rlimit_test()
         self.init_rlimit_large_test()
+        self.init_rlimit_too_low()
my logs after the crash.
...
2026-04-16T18:49:47.300353Z TestFramework.node1 (DEBUG): RPC successfully started
2026-04-16T18:49:47.301798Z TestFramework (INFO): Testing node startup with RLIM_INFINITY fd limit
2026-04-16T18:49:47.302125Z TestFramework (DEBUG): Current RLIMIT_NOFILE limits (soft=2048, hard=1048576), trying to set soft limit to -1
2026-04-16T18:49:47.302377Z TestFramework (INFO): Skipping rlimit test: cannot set soft limit (hard=1048576)
2026-04-16T18:49:47.302592Z TestFramework (INFO): Testing node startup with fd limit above INT_MAX
2026-04-16T18:49:47.302776Z TestFramework (DEBUG): Current RLIMIT_NOFILE limits (soft=2048, hard=1048576), trying to set soft limit to 2147483648
2026-04-16T18:49:47.302951Z TestFramework (INFO): Skipping rlimit test: cannot set soft limit (hard=1048576)
2026-04-16T18:49:47.303120Z TestFramework (INFO): Testing node startup with very low fd limit
2026-04-16T18:49:47.303285Z TestFramework (DEBUG): Current RLIMIT_NOFILE limits (soft=2048, hard=1048576), trying to set soft limit to 8
2026-04-16T18:49:47.303530Z TestFramework.node1 (DEBUG): Stopping node
2026-04-16T18:49:47.708177Z TestFramework.node1 (DEBUG): Node stopped
2026-04-16T18:49:47.708435Z TestFramework (DEBUG): Restored previous RLIMIT_NOFILE limits (soft=2048, hard=1048576)
2026-04-16T18:49:47.708521Z TestFramework (ERROR): Unexpected exception:
Traceback (most recent call last):
  File "/home/oldman/Documents/btc/btc-core/review-tests/bitcoin/test/functional/test_framework/test_framework.py", line 144, in main
    self.run_test()
  File "/home/oldman/Documents/btc/btc-core/review-tests/bitcoin/build/test/functional/feature_init.py", line 379, in run_test
    self.init_rlimit_neg()
  File "/home/oldman/Documents/btc/btc-core/review-tests/bitcoin/build/test/functional/feature_init.py", line 360, in init_rlimit_neg
    self.restart_node_with_fd_limit(8)
  File "/home/oldman/Documents/btc/btc-core/review-tests/bitcoin/build/test/functional/feature_init.py", line 337, in restart_node_with_fd_limit
    self.restart_node(1)
  File "/home/oldman/Documents/btc/btc-core/review-tests/bitcoin/test/functional/test_framework/test_framework.py", line 548, in restart_node
    self.start_node(i, extra_args)
  File "/home/oldman/Documents/btc/btc-core/review-tests/bitcoin/test/functional/test_framework/test_framework.py", line 504, in start_node
    node.start(*args, **kwargs)
  File "/home/oldman/Documents/btc/btc-core/review-tests/bitcoin/test/functional/test_framework/test_node.py", line 279, in start
    stdout = tempfile.NamedTemporaryFile(dir=self.stdout_dir, delete=False)
             ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/usr/lib/python3.12/tempfile.py", line 721, in NamedTemporaryFile
    file = _io.open(dir, mode, buffering=buffering,
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/usr/lib/python3.12/tempfile.py", line 718, in opener
    fd, name = _mkstemp_inner(dir, prefix, suffix, flags, output_type)
               ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/usr/lib/python3.12/tempfile.py", line 395, in _mkstemp_inner
    fd = _os.open(file, flags, 0o600)
         ^^^^^^^^^^^^^^^^^^^^^^^^^^^^
OSError: [Errno 24] Too many open files: '/tmp/test_runner_₿_🏃_20260416_214851/feature_init_0/node1/stdout/tmpaekl08vn'
2026-04-16T18:49:47.710279Z TestFramework (DEBUG): Closing down network thread
2026-04-16T18:49:47.760813Z TestFramework (INFO): Not stopping nodes as test failed. The dangling processes will be cleaned up later.
2026-04-16T18:49:47.761167Z TestFramework (WARNING): Not cleaning up dir /tmp/test_runner_₿_🏃_20260416_214851/feature_init_0
2026-04-16T18:49:47.761426Z TestFramework (ERROR): Test failed. Test logging available at /tmp/test_runner_₿_🏃_20260416_214851/feature_init_0/test_framework.log
...

suggestion

rather than allowing the process to hit a OSError: [Errno 24] Too many open files mid-boot, we could perhaps treat this as a validation gate. it might be more robust to notify the user that the provided limit is insufficient for basic operation, providing a "fail-fast" mechanism rather than an ungraceful crash.

given that the node fundamentally requires a small handful of descriptors just to "breathe" (logging, leveldb, entropy), do you think it is worth adjusting the c++ logic to cater for these very low-value edge cases, or is the current assertion sufficient for our purposes?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

allowing the process to attempt a start with values that guarantee a crash on non-Windows systems?

The point of an Assert is that we expect this to never crash. The caller needs to make sure no value < 0 is passed in. Since RaiseFileDescriptorLimit is only called in one place, we know that is the case. If we ever add new call sites, then this assert would need to be re-evaluated.

The number requested by the init code should be sufficient for node operations. It might be possible to get by with even less, but afaik there's no realistic environment where that's the case.

I guess the scenario you're describing is where we don't even get to RaiseFileDescriptorLimit? I don't think that's worth worry about, assuming my assumption above is correct.

@winterrdog winterrdog Apr 23, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The number requested by the init code should be sufficient for node operations. It might be possible to get by with even less, but afaik there's no realistic environment where that's the case.

agreed!

prior to calling RaiseFileDescriptorLimit, the logic in init.cpp's AppInitParameterInteraction function already aggregates user_max_connections, max_private, and min_required_fds. this calculated sum (was 176 when i ran bitcoind through GDB on Linux) ensures the request is well above the physical floor required for basic stability.

I guess the scenario you're describing is where we don't even get to RaiseFileDescriptorLimit? I don't think that's worth worry about, assuming my assumption above is correct.

true!

the failure happens during initialisation before the RaiseFileDescriptorLimit is even reached. I think I was perhaps being a bit too paranoid from an adversarial POV, imagining a scenario where a malicious actor gains enough access to suffocate the environment's ulimit to 8 or less, essentially DOSing the node's ability to even log its own failure. given how unlikely that environment is in practice, i agree it's not worth the extra complexity.

thanks for the clarification!

#if defined(WIN32)
return 2048;
#else
struct rlimit limitFD;
if (getrlimit(RLIMIT_NOFILE, &limitFD) != -1) {
if (limitFD.rlim_cur < (rlim_t)nMinFD) {
limitFD.rlim_cur = nMinFD;
if (limitFD.rlim_cur > limitFD.rlim_max)
// If the current soft limit is already higher, don't raise it
if (limitFD.rlim_cur != RLIM_INFINITY && std::cmp_less(limitFD.rlim_cur, min_fd)) {
const auto current_limit{limitFD.rlim_cur};
static_assert(std::in_range<rlim_t>(std::numeric_limits<int>::max()));
limitFD.rlim_cur = static_cast<rlim_t>(min_fd);
// Don't raise soft limit beyond hard limit
if ((limitFD.rlim_max != RLIM_INFINITY) && (limitFD.rlim_cur > limitFD.rlim_max)) {
limitFD.rlim_cur = limitFD.rlim_max;
setrlimit(RLIMIT_NOFILE, &limitFD);
getrlimit(RLIMIT_NOFILE, &limitFD);
}
if (current_limit != limitFD.rlim_cur) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does this change guard against anything new, or is it just there to not do redundant work?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think the latter, just avoiding an unnecessary write and read call. The original code had the same intention.

setrlimit(RLIMIT_NOFILE, &limitFD);
getrlimit(RLIMIT_NOFILE, &limitFD);
}
}
// Check the (possibly raised) current soft limit against the special
// value of RLIM_INFINITY. Some platforms implement this as the maximum
// uint64, others as int64 (-1). Avoid casting even if the return type
// is changed to uint64_t. We also cap unlikely but possible values
// that would overflow int.
if (limitFD.rlim_cur == RLIM_INFINITY ||
std::cmp_greater_equal(limitFD.rlim_cur, std::numeric_limits<int>::max())) {
return std::numeric_limits<int>::max();
}
return limitFD.rlim_cur;
return static_cast<int>(limitFD.rlim_cur);
}
return nMinFD; // getrlimit failed, assume it's fine
return min_fd; // getrlimit failed, assume it's fine
#endif
}

Expand Down
13 changes: 12 additions & 1 deletion src/util/fs_helpers.h
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,18 @@ bool FileCommit(FILE* file);
void DirectoryCommit(const fs::path& dirname);

bool TruncateFile(FILE* file, unsigned int length);
int RaiseFileDescriptorLimit(int nMinFD);

/**
* Try to raise the file descriptor limit to the requested number.
*
* @param[in] min_fd The requested minimum number of file descriptors.
* @returns The actual file descriptor limit. It may be lower or
* higher than min_fd. Returns std::numeric_limits<int>::max()
* if the OS imposes no limit (RLIM_INFINITY).
*
*/
int RaiseFileDescriptorLimit(int min_fd);

void AllocateFileRange(FILE* file, unsigned int offset, unsigned int length);

/**
Expand Down
36 changes: 36 additions & 0 deletions test/functional/feature_init.py
Original file line number Diff line number Diff line change
Expand Up @@ -323,12 +323,48 @@ def init_empty_test(self):
for option in options:
self.restart_node(1, option)

def restart_node_with_fd_limit(self, limit):
"""Restart node 1 with a given soft RLIMIT_NOFILE. Skips if the limit cannot be set."""
import resource
soft, hard = resource.getrlimit(resource.RLIMIT_NOFILE)
try:
resource.setrlimit(resource.RLIMIT_NOFILE, (limit, hard))
except (ValueError, OSError):
self.log.info(f"Skipping rlimit test: cannot set soft limit (hard={hard})")
return
try:
self.restart_node(1)
self.log.debug(f"Node started successfully with RLIM_INFINITY limit (soft={limit})")
finally:
resource.setrlimit(resource.RLIMIT_NOFILE, (soft, hard))
self.log.debug(f"Restored previous RLIMIT_NOFILE limits (soft={soft}, hard={hard})")

def init_rlimit_test(self):
"""Test that bitcoind starts correctly when the soft RLIMIT_NOFILE limit is RLIM_INFINITY."""
if self.RLIM_INFINITY is None:
self.log.info("Skipping: resource module not available")
return

self.log.info("Testing node startup with RLIM_INFINITY fd limit")
self.restart_node_with_fd_limit(self.RLIM_INFINITY)

def init_rlimit_large_test(self):
"""Test that bitcoind starts correctly when the soft RLIMIT_NOFILE limit is above INT_MAX."""
if self.RLIM_INFINITY is None:
self.log.info("Skipping: resource module not available")
return

self.log.info("Testing node startup with fd limit above INT_MAX")
self.restart_node_with_fd_limit(1 << 31)

def run_test(self):
self.init_pid_test()
self.init_stress_test_interrupt()
self.init_stress_test_removals()
self.break_wait_test()
self.init_empty_test()
self.init_rlimit_test()
self.init_rlimit_large_test()


if __name__ == '__main__':
Expand Down
12 changes: 12 additions & 0 deletions test/functional/test_framework/test_framework.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
from enum import Enum
import argparse
from datetime import datetime, timezone
from importlib.util import find_spec
import logging
import os
from pathlib import Path
Expand Down Expand Up @@ -948,6 +949,17 @@ def skip_if_no_previous_releases(self):
if not self.has_previous_releases():
raise SkipTest("previous releases not available or disabled")

def has_resource_module(self):
"""Checks whether the resource module is available."""
return find_spec('resource') is not None

@property
def RLIM_INFINITY(self):
if not self.has_resource_module():
return None
import resource
return resource.RLIM_INFINITY

def has_previous_releases(self):
"""Checks whether previous releases are present and enabled."""
if not os.path.isdir(self.options.previous_releases_path):
Expand Down
Loading