btclib-node 2026.8.27__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,555 @@
1
+ # Copyright (c) The btclib developers
2
+ # Distributed under the MIT software license, see the accompanying
3
+ # LICENSE file or https://opensource.org/license/mit for the full text.
4
+
5
+ """`Node`, the thread that drives everything else in this package.
6
+
7
+ One loop: drain the handshake queue, then a share of the RPC queue and
8
+ a share of the peer-to-peer queue, then step the download manager and
9
+ extend the chain. A message that raises is logged and the loop
10
+ continues; a failure under `update_chain` leaves the loop, because the
11
+ databases the submodules below open have to be closed on the way out.
12
+
13
+ `P2pManager` and `RpcManager` are each a thread of their own, running
14
+ an asyncio loop of their own; this module is what calls into them and
15
+ what they hand work back to.
16
+ """
17
+
18
+ import os
19
+ import signal
20
+ import sys
21
+ import threading
22
+ import time
23
+ from math import log2
24
+ from multiprocessing.pool import Pool, ThreadPool
25
+ from typing import TYPE_CHECKING, override
26
+
27
+ from btclib_node.block_db import BlockDB
28
+ from btclib_node.chainstate import Chainstate
29
+ from btclib_node.config import Config
30
+ from btclib_node.constants import NodeStatus
31
+ from btclib_node.download import DownloadManager
32
+ from btclib_node.exceptions import NodeShutdownTimeoutError
33
+ from btclib_node.interpreter import warm
34
+ from btclib_node.log import Logger
35
+ from btclib_node.main import update_chain
36
+ from btclib_node.mempool import Mempool
37
+ from btclib_node.p2p.address import PeerDB
38
+ from btclib_node.p2p.main import (
39
+ handle_p2p,
40
+ handle_p2p_handshake,
41
+ resume_cfilters,
42
+ resume_getdata,
43
+ )
44
+ from btclib_node.p2p.manager import P2pManager
45
+ from btclib_node.rpc.main import handle_rpc
46
+ from btclib_node.rpc.manager import RpcManager
47
+
48
+ if TYPE_CHECKING:
49
+ from collections import deque
50
+ from types import FrameType
51
+
52
+ from btclib.p2p.inventory import Inventory
53
+
54
+ from btclib_node.p2p.connection import Connection
55
+
56
+ # Everything above this line is imported for `Node` to build on, not to
57
+ # be handed to a caller: `handle_p2p`, `RpcManager` and the rest are
58
+ # named here so this module can wire them together, and nothing outside
59
+ # this repository has ever imported one of them from the package root
60
+ # rather than from its own module. `Node` and `install_signal_handlers`
61
+ # below are the two names a caller reaches for -- `scripts/chains/`
62
+ # reaches for both, and every functional test builds a `Node` without
63
+ # ever calling the other -- so together they are the whole of `__all__`
64
+ # (btclib-org/.github#239).
65
+ __all__ = ["Node", "install_signal_handlers"]
66
+
67
+ # How long `stop` waits for the loop to come back before saying it did
68
+ # not. The flag is read at the top of the loop, so the wait is however
69
+ # long the pass already running takes: `update_chain` validates a fork
70
+ # block by block, and checks this same flag between blocks, so what sets
71
+ # the scale is one block's own `worker_pool.starmap` over its inputs --
72
+ # thousands of signature checks on mainnet -- rather than the whole fork
73
+ # a deep reorg walks. An idle stop costs milliseconds. Well under the
74
+ # per-test limit `pyproject.toml` sets, so that a node which will not
75
+ # stop is reported here rather than by whichever bound expires first;
76
+ # `tests/unit/init_test.py` asserts that ordering rather than leaving it
77
+ # to this comment.
78
+ STOP_TIMEOUT = 30
79
+
80
+ # How long the loop below sleeps once a pass finds nothing waiting in
81
+ # either queue. The figure it replaces, 0.0001, sat below the
82
+ # platform timer's own granularity, so what set the pace of an idle
83
+ # node was the OS rather than the number, and every one of those passes
84
+ # still ran `download_manager.step()` and `update_chain()` in full
85
+ # (btclib-org/btclib-node#440).
86
+ #
87
+ # Core's own message loop takes this shape as a wait on a condition
88
+ # variable that a producer signals, so a node with work is woken at
89
+ # once and one without costs nothing until the wait's own ceiling
90
+ # (`CConnman::ThreadMessageHandler`, `src/net.cpp`, up to 100 ms,
91
+ # at bitcoin/bitcoin@b91d983f66). A plain sleep cannot do that -- nothing
92
+ # wakes it early -- so raising it trades idle CPU for latency on
93
+ # whatever arrives while it is asleep, in a way Core's own wait does
94
+ # not have to. Giving `step()` its own cadence or signalling the loop
95
+ # from the queues would close that gap; neither is done here, so the
96
+ # gap stays and this is a divergence from what is cited above rather
97
+ # than a match to it.
98
+ #
99
+ # 5 ms rather than a larger figure in the 5-20 ms range the issue
100
+ # above named: an idle node's own CPU cost falls to about the same
101
+ # small fraction of its former self at 5, 10 and 20 ms alike, measured
102
+ # by comparing resource.getrusage across an idle run at each -- so
103
+ # nothing past 5 ms buys back more of it. What does move past 5 ms is
104
+ # the very latency the comment above says this sleep trades for that
105
+ # CPU, measured end to end on a real RPC round trip: it grows with the
106
+ # sleep, so the smallest figure that already captures the CPU win is
107
+ # the one that pays least for it, and it still leaves comfortable
108
+ # headroom under `tests/__init__.py`'s own 25 ms poll in `wait_until`
109
+ # and `wait_until_listening`, so nothing in the suite can resolve it.
110
+ IDLE_SLEEP_SECONDS = 0.005
111
+
112
+
113
+ def _default_worker_count() -> int:
114
+ """How many workers `Node.worker_pool` spawns.
115
+
116
+ Eight outside of a test run, unconditionally. Under `pytest-xdist`,
117
+ `PYTEST_XDIST_WORKER_COUNT` is the number of worker processes the
118
+ run was split across (`xdist/remote.py` sets it in the worker's own
119
+ environment before any test module is imported), and every one of
120
+ them builds its own `Node`s and, through them, its own pools: eight
121
+ workers each, on top of `-n auto`'s one xdist worker per core, is
122
+ what starves a `wait_until` on a machine that has the cores for the
123
+ xdist workers alone and not for eight more of `Node.worker_pool`'s
124
+ own per xdist worker on top of that (btclib-org/btclib-node#46).
125
+ Dividing the machine's own core count across the xdist workers
126
+ instead keeps the total the run spawns near that core count,
127
+ whatever `-n` is set to, and leaves a node running outside of pytest
128
+ at the flat eight.
129
+
130
+ The reasoning is the same whether `_pool_factory` below hands
131
+ `worker_pool` a process pool or a thread pool: an xdist worker is
132
+ always an OS process, and what starves it is the machine's core
133
+ count being oversubscribed by whatever `Node.worker_pool` spawns
134
+ under it, a real OS thread competing for a core exactly as a real OS
135
+ process does once the interpreter that spawns it is free-threaded
136
+ (btclib-org/btclib-node#388).
137
+ """
138
+ xdist_workers = os.environ.get("PYTEST_XDIST_WORKER_COUNT")
139
+ if xdist_workers is None:
140
+ return 8
141
+ return max(1, (os.cpu_count() or 8) // int(xdist_workers))
142
+
143
+
144
+ # `Node.worker_pool`'s own size, named so `warm_worker_pool` can compute
145
+ # a warm-up call count from it without a second literal to drift from.
146
+ _WORKER_COUNT = _default_worker_count()
147
+
148
+
149
+ def _pool_factory(*, gil_enabled: bool) -> type[Pool]:
150
+ """Return the pool type `Node.worker_pool` builds, chosen by `gil_enabled`.
151
+
152
+ `Pool` under a GIL build, `ThreadPool` under a free-threaded one --
153
+ `sys._is_gil_enabled()` is `worker_pool`'s own caller for
154
+ `gil_enabled`, kept out of this function so that both arms are
155
+ reachable, and asserted, on a single interpreter (issue #388): a
156
+ `ThreadPool` constructs on a GIL build as readily as a `Pool` does,
157
+ so a parametrized test exercises both without a second interpreter.
158
+
159
+ Two conditions license a `ThreadPool` at all, and both were
160
+ established by reading and by running rather than by trust of a
161
+ process-era comment (issue #388): `btclib.script.engine` never
162
+ writes to the `Tx`, `TxIn` or `PrecomputedTxData` a task is handed
163
+ -- confirmed by a concurrent run over a real, mixed-flavour,
164
+ signed transaction with `Tx.__setattr__`/`TxIn.__setattr__`
165
+ instrumented to catch one, on both interpreters -- and
166
+ `btclib-secp256k1` verifies through one shared, read-only
167
+ libsecp256k1 context that its own "Thread safety" section documents
168
+ as safe for concurrent calls. Under a GIL build the choice does not
169
+ matter for correctness and matters for speed: the libsecp256k1 cffi
170
+ call does not release the GIL, so a `ThreadPool` there is threads
171
+ taking turns behind a process pool's own real parallelism, which is
172
+ why the GIL build keeps `Pool`.
173
+
174
+ Core's own `CCheckQueue` always shares one
175
+ `PrecomputedTransactionData` by pointer across its worker threads
176
+ (`validation.h`'s `CScriptCheck::txdata`,
177
+ at bitcoin/bitcoin@794a753958); a `ThreadPool` is this tree's way of
178
+ doing the same once the interpreter running it makes a real thread
179
+ as parallel as one of Core's.
180
+ """
181
+ return Pool if gil_enabled else ThreadPool
182
+
183
+
184
+ class Node(threading.Thread):
185
+ """A bitcoin full node, and the thread that runs its main loop.
186
+
187
+ `config` (or a default `Config` when none is given) is what says
188
+ which chain, which data directory and which ports; `__init__` opens
189
+ every database under that directory and wires the p2p and RPC
190
+ managers to this node before `start()` ever runs `run`'s loop.
191
+
192
+ Building one touches no process-wide state: `install_signal_handlers`
193
+ below is the separate, explicit call a caller makes for that, and
194
+ this object never makes it on its own behalf (issue #436).
195
+ """
196
+
197
+ def __init__(self, config: Config | None = None) -> None:
198
+ """Open every database `config` names, and wire the two managers up."""
199
+ super().__init__()
200
+
201
+ if config is None:
202
+ config = Config()
203
+
204
+ self.config = config
205
+ self.chain = config.chain
206
+ self.data_dir = config.data_dir
207
+ self.data_dir.mkdir(exist_ok=True, parents=True)
208
+
209
+ self.terminate_flag = threading.Event()
210
+ log_path = self.data_dir / config.log_path if config.log_path else None
211
+ self.logger = Logger(log_path, debug=config.debug)
212
+
213
+ self.chainstate = Chainstate(self.data_dir, self.chain, self.logger)
214
+ self.block_db = BlockDB(self.data_dir, self.logger)
215
+ # the two halves of a filter live in different databases -- the
216
+ # block and its reverse patch in one, the index in the other --
217
+ # so catching up is here, where both are built, and before
218
+ # anything is listening: the version message this node sends
219
+ # says it serves filters for the whole chain
220
+ self.chainstate.filter_index.catch_up(
221
+ self.chainstate.block_index.active_chain, self.block_db
222
+ )
223
+ self.mempool = Mempool(self.logger)
224
+
225
+ # A `getcfilters` answer `p2p.callbacks.get_cfilters` could not
226
+ # finish scheduling under its own pacing bound, keyed by
227
+ # connection id: the connection itself and the heights still
228
+ # owed. `p2p.callbacks.advance_cfilters` and
229
+ # `p2p.main.resume_cfilters` are the only two that read or write
230
+ # this, and both run on this thread -- `run`'s own loop below,
231
+ # under `handle_p2p` or under `resume_cfilters` directly -- so
232
+ # nothing here needs a lock. btclib-org/btclib-node#442
233
+ self.pending_cfilters: dict[int, tuple[Connection, deque[int]]] = {}
234
+
235
+ # The same shape as `pending_cfilters` above, for a `getdata`
236
+ # `p2p.callbacks.getdata` could not finish serving: the
237
+ # connection and the items still owed, read and written only by
238
+ # `p2p.callbacks.advance_getdata` and `p2p.main.resume_getdata`,
239
+ # both on this thread. btclib-org/btclib-node#470
240
+ self.pending_getdata: dict[int, tuple[Connection, deque[Inventory]]] = {}
241
+
242
+ # Built on first use, by the property below: the pool is
243
+ # interpreters under a GIL build (spawned rather than forked
244
+ # wherever that is the platform's default) or threads under a
245
+ # free-threaded one (issue #388), and a node that never
246
+ # validates a script should not pay for either. Which is most
247
+ # of them -- a node serving headers, a node under test, a node
248
+ # that has nothing to connect -- and each one that does pay
249
+ # competes for the cores with the nodes that are actually
250
+ # validating.
251
+ self._worker_pool: Pool | None = None
252
+ self._worker_pool_lock = threading.Lock()
253
+ # Set by `warm_worker_pool` and joined before `run`'s own
254
+ # teardown reads `_worker_pool` below: without that join, a
255
+ # warm-up still building the pool when the loop stops would
256
+ # race the `is not None` check there, and the pool it goes on
257
+ # to build would never be handed to `terminate`.
258
+ self._worker_pool_warmup: threading.Thread | None = None
259
+
260
+ self.status = NodeStatus.Starting
261
+
262
+ self.download_manager = DownloadManager(self, self.logger)
263
+
264
+ self.p2p_port: int | None
265
+ if config.p2p_port:
266
+ self.p2p_port = config.p2p_port
267
+ else:
268
+ self.p2p_port = None
269
+ peer_db = PeerDB(self.chain, self.data_dir)
270
+ self.p2p_manager = P2pManager(self, self.p2p_port, peer_db)
271
+
272
+ self.rpc_port: int | None
273
+ if config.rpc_port:
274
+ self.rpc_port = config.rpc_port
275
+ else:
276
+ self.rpc_port = None
277
+ self.rpc_manager = RpcManager(self, self.rpc_port)
278
+
279
+ @property
280
+ def worker_pool(self) -> Pool:
281
+ """The pool `interpreter.py` validates a script in, built on first use.
282
+
283
+ `_pool_factory` picks the type against `sys._is_gil_enabled()`
284
+ read here, once, rather than inside that function: a `Pool`
285
+ under a GIL build, a `ThreadPool` under a free-threaded one
286
+ (issue #388). Under the lock, so that two callers building it at
287
+ once get one pool between them: the second would otherwise leave
288
+ a pool with nothing holding it and nothing to terminate it.
289
+ """
290
+ with self._worker_pool_lock:
291
+ if self._worker_pool is None:
292
+ # sys._is_gil_enabled is the interpreter's own name for
293
+ # the question; CPython gives it no public spelling
294
+ self._worker_pool = _pool_factory(
295
+ gil_enabled=sys._is_gil_enabled() # noqa: SLF001
296
+ )(processes=_WORKER_COUNT)
297
+ return self._worker_pool
298
+
299
+ def _close_worker_pool(self) -> None:
300
+ # read, not asked for: the property would build the pool this
301
+ # exists to take down. `terminate()` alone left `_worker_pool`
302
+ # referencing a pool whose worker processes and handler threads
303
+ # it had not waited for; `join()` waits for them here, and
304
+ # dropping the reference is what stops whatever collects the
305
+ # `Node` afterwards from finding one still to take down.
306
+ if self._worker_pool is not None:
307
+ self._worker_pool.terminate()
308
+ self._worker_pool.join()
309
+ self._worker_pool = None
310
+
311
+ def __del__(self) -> None:
312
+ """Terminate the worker pool if `run` never got to, as a backstop.
313
+
314
+ For a `Node` built and used without ever being `start()`ed --
315
+ `tests/unit/main_test.py` calls `update_chain` against one
316
+ directly, on the thread that built it, to reach a block's own
317
+ validation without a loop around it, and that builds a real
318
+ `worker_pool` that `run`'s own teardown below never runs to take
319
+ back down. Without this, that pool is still in `Pool.RUN` state
320
+ whenever something finally collects it, which is what made
321
+ `Pool.__del__` warn and reach for a queue of its own on an xdist
322
+ worker's stderr, reported against btclib-org/btclib-node#195.
323
+ `getattr` rather than the attribute itself: `__init__` can raise
324
+ before `_worker_pool` is set, and a constructor's exception
325
+ should not come back paired with a second one out of here.
326
+ """
327
+ if getattr(self, "_worker_pool", None) is not None:
328
+ self._close_worker_pool()
329
+
330
+ def warm_worker_pool(self) -> None:
331
+ """Build the worker pool now, on a thread of its own, and warm it.
332
+
333
+ `check_transactions`' own first call used to be what built and
334
+ warmed `worker_pool`, on whatever thread called it -- `run`'s
335
+ own loop below, the same one that drains
336
+ `p2p_manager.handshake_messages` and promotes a connection once
337
+ its `verack` arrives. Under `_pool_factory`'s process arm, each
338
+ of the pool's own processes pays its own import of
339
+ `btclib_node.interpreter` (and, through it,
340
+ `btclib.script.engine`) the first time it is dispatched a task,
341
+ and while that first dispatch is running, the loop below cannot
342
+ drain that queue: a peer whose `verack` the kernel already
343
+ delivered sits unpromoted until the call returns
344
+ (btclib-org/btclib-node#262). Under the thread arm every worker
345
+ already shares the one import this process paid when `Node`
346
+ itself was imported, so the same dispatch below costs this
347
+ module nothing there -- and is still made, both arms being one
348
+ call site and the warm-up being harmless where it is not needed.
349
+
350
+ `download_manager.block_download` is the only caller, right
351
+ before it sends the first real `GetData` for a block this node
352
+ does not have -- the earliest point a script is actually going
353
+ to be validated, with a peer's round trip ahead of it as extra
354
+ runway, rather than the moment header sync merely completes.
355
+ Reaching `HeaderSynced` is not enough on its own: the comment on
356
+ `_worker_pool` above is that a node which never validates a
357
+ script should not pay for the pool, and a node whose headers are
358
+ synced but which never has a block to fetch -- a header-only
359
+ peer under test, a peer whose counterpart stops serving blocks
360
+ -- is exactly that. The guard below makes a second call a no-op,
361
+ since `block_download` runs on every pass of the loop below and
362
+ would otherwise ask for a second thread once the first has
363
+ already built the pool.
364
+ """
365
+ if self._worker_pool_warmup is not None:
366
+ return
367
+
368
+ def build_and_warm() -> None:
369
+ self.worker_pool.starmap(warm, [()] * (_WORKER_COUNT * 4))
370
+
371
+ self._worker_pool_warmup = threading.Thread(target=build_and_warm, daemon=True)
372
+ self._worker_pool_warmup.start()
373
+
374
+ def _drain_message_queues(self) -> bool:
375
+ """Handle whatever is waiting, and answer whether nothing was.
376
+
377
+ One message must not end the node. `handle_p2p` and
378
+ `handle_p2p_handshake` already answer a bad message by dropping
379
+ the peer, but what reaches here is whatever they did not expect
380
+ -- and leaving `run`'s own loop by exception skips every close
381
+ below it, so the databases would stay open.
382
+
383
+ `resume_cfilters` and `resume_getdata` are last and unconditional,
384
+ not one more queue to size a share from: nothing is queued to
385
+ trigger either, a paused `getcfilters` or `getdata` answer being
386
+ owed regardless of what else this pass finds waiting.
387
+
388
+ A connection paused on `MAX_QUEUED_RECV_BYTES`
389
+ (`p2p/connection.py`) waits here and nowhere of its own: what
390
+ resumes it is `handle_p2p` popping enough of that connection's
391
+ own items off `p2p_manager.messages`, and that queue is shared by
392
+ every connection. The items another peer has ahead of it grow
393
+ with the number of peers currently busy, while the share popped
394
+ per pass grows only with the log of the whole backlog, so the
395
+ wait is a function of how many peers are busy rather than a
396
+ constant -- where Core's own `ThreadMessageHandler`
397
+ (`net.cpp:3216-3238`, at bitcoin/bitcoin@b91d983f66) calls
398
+ `ProcessMessages` once per peer every round and so bounds it by a
399
+ constant. The wait is still bounded rather than open-ended:
400
+ `parse_messages` appends in arrival order and each connection's
401
+ own contribution is capped near that same bound rather than by
402
+ it -- the weighing happens once per parse pass, after it, so a
403
+ connection already at the bound still adds whatever that pass
404
+ completed, up to `MAX_PROTOCOL_MESSAGE_LENGTH` for the single
405
+ message that crossed it -- so what can sit ahead of a paused
406
+ connection is the other connections' caps and whatever
407
+ `ping`/`pong` arrives after it.
408
+ `tests/unit/init_test.py` measures the wait in passes of this
409
+ loop against the number of busy connections.
410
+ btclib-org/btclib-node#490
411
+ """
412
+ wait = True
413
+ try:
414
+ while len(self.p2p_manager.handshake_messages):
415
+ handle_p2p_handshake(self)
416
+ wait = False
417
+ for _ in range(int(log2(len(self.rpc_manager.messages) + 1))):
418
+ handle_rpc(self)
419
+ wait = False
420
+ for _ in range(int(log2(len(self.p2p_manager.messages) + 1))):
421
+ handle_p2p(self)
422
+ wait = False
423
+ if resume_cfilters(self):
424
+ wait = False
425
+ if resume_getdata(self):
426
+ wait = False
427
+ except Exception:
428
+ self.logger.exception("Exception occurred handling a message")
429
+ return wait
430
+
431
+ def _step_chain(self) -> bool:
432
+ """Advance the chain one step, and answer whether `run` should stop."""
433
+ try:
434
+ self.download_manager.step()
435
+ update_chain(self)
436
+ except Exception:
437
+ self.logger.exception("Exception occurred")
438
+ return True
439
+ return False
440
+
441
+ @override
442
+ def run(self) -> None:
443
+ self.logger.info("Starting main loop")
444
+
445
+ # Set before either manager starts, on this thread rather than
446
+ # a manager's own: `listening` is set on a manager's thread, so
447
+ # a caller whose wait ends there learns nothing about whether
448
+ # this assignment has run yet, and a write racing it here can
449
+ # put `status` back below `HeaderSynced` for good (#398).
450
+ self.status = NodeStatus.SyncingHeaders
451
+ if self.p2p_port:
452
+ self.p2p_manager.start()
453
+ if self.rpc_port:
454
+ self.rpc_manager.start()
455
+ while not self.terminate_flag.is_set():
456
+ if self._drain_message_queues():
457
+ time.sleep(IDLE_SLEEP_SECONDS)
458
+ if self._step_chain():
459
+ break
460
+ self.p2p_manager.stop()
461
+ self.rpc_manager.stop()
462
+
463
+ self.p2p_manager.peer_db.close()
464
+ self.chainstate.close()
465
+ self.block_db.close()
466
+
467
+ # joined before the read below, not asked for: the same race
468
+ # the attribute's own comment above names
469
+ if self._worker_pool_warmup is not None:
470
+ self._worker_pool_warmup.join()
471
+ self._close_worker_pool()
472
+
473
+ self.logger.info("Stopping node")
474
+ self.logger.close()
475
+
476
+ def stop(self) -> None:
477
+ """Ask the main loop to stop, and wait up to `STOP_TIMEOUT` for it.
478
+
479
+ Raises if the loop has not come back by then, the node having
480
+ no way to be sure of its chainstate or its databases while a
481
+ thread is still inside them.
482
+
483
+ Signalling alone lets the caller go on while the node is still
484
+ there. A test that returns then is torn down around a thread
485
+ that goes on logging into a harness that has moved on, which is
486
+ where `ValueError: I/O operation on closed file` came from; and
487
+ a loop that cannot come back at all holds the interpreter open
488
+ after the last test, this being a non-daemon thread. No
489
+ per-test limit reaches that second one -- the test it belongs
490
+ to has already passed -- so waiting here is what puts the wait
491
+ inside the test, where a limit can name it
492
+ (btclib-org/btclib-node#98).
493
+
494
+ The bound is the point rather than a precaution:
495
+ `pytest-timeout` arms one timer per test, so a limit already
496
+ spent in the call phase is not there for the teardown, and an
497
+ unbounded wait in a teardown is a run that stops instead of
498
+ failing (btclib-org/btclib-node#115).
499
+
500
+ The node's own thread is the one caller that cannot wait, the
501
+ `stop` RPC being handled inside the loop it stops. It gets the
502
+ signal alone, and reaches the end of `run` by itself. A signal
503
+ handler is the other caller worth naming: this raising there
504
+ makes an operator's interrupt loud, and it does not make the
505
+ process able to exit, the wedged thread being non-daemon.
506
+ """
507
+ self.terminate_flag.set()
508
+ if self.is_alive() and threading.current_thread() is not self:
509
+ self.join(timeout=STOP_TIMEOUT)
510
+ if self.is_alive():
511
+ # named by its data directory, which is what tells one
512
+ # node from another where several are running
513
+ err_msg = f"the node at {self.data_dir} did not stop: its "
514
+ err_msg += "thread is still running after the flag was set. "
515
+ err_msg += "Nothing after this can trust the chainstate or "
516
+ err_msg += "the databases"
517
+ raise NodeShutdownTimeoutError(err_msg)
518
+
519
+
520
+ def install_signal_handlers(node: Node) -> None:
521
+ """Stop `node` on SIGINT, SIGTERM and SIGTSTP, process-wide.
522
+
523
+ `signal.signal` keeps one handler per signal per process, replacing
524
+ whatever was there before, so this is for the one caller in a
525
+ process that wants an operator's interrupt to reach a node at all --
526
+ `scripts/chains/`, and whatever a future CLI ends up being. Calling
527
+ it a second time, for a second node, replaces the first node's
528
+ handler rather than adding to it: that is the same `signal.signal`
529
+ the first call made, not a defect this function introduces.
530
+
531
+ Kept out of `Node.__init__` for two reasons (issue #436). A second
532
+ `Node` built in one process used to silently disown the first,
533
+ every call installing a fresh handler bound to the newer node with
534
+ nothing to say the first one's databases were still open behind it
535
+ -- every functional p2p test builds two nodes, so the first node's
536
+ handlers survived only for the length of the second one's
537
+ constructor call. And `signal.signal` raises outside the main
538
+ thread of the main interpreter, so a `Node` could not be built at
539
+ all from a worker thread, whether or not that caller ever wanted a
540
+ process-wide interrupt.
541
+
542
+ SIG_DFL/SIG_IGN are `signal.signal`'s own other two handler
543
+ arguments, POSIX's only alternatives to a callable one; every
544
+ handler is called with `(signum, frame)`, unread here since the
545
+ three signals below share one handler and `stop` takes neither.
546
+ SIGTSTP is for hibernation and does not exist on Windows
547
+ (btclib-org/btclib-node#429).
548
+ """
549
+
550
+ def stop_handler(_signum: int, _frame: FrameType | None) -> None:
551
+ node.stop()
552
+
553
+ signal.signal(signal.SIGINT, stop_handler)
554
+ signal.signal(signal.SIGTERM, stop_handler)
555
+ signal.signal(signal.SIGTSTP, stop_handler)