tests: add functional tests for IPC interface
What changed, and why it matters
This commit only adds new automated tests for Bitcoin Core's IPC (inter-process communication) interface. It does not change any production code, wallet logic, consensus rules, or network behavior. There is no security issue here.
No security action needed. This is a normal test-only commit.
Security signals we found
No strong security signals were identified.
Evidence from the diff
The commit introduces a new functional test file (test/functional/interface_ipc.py) that exercises the Cap’n Proto IPC interface, plus a README update for installing pycapnp, a skip helper in the test framework, and registration in test_runner.py. It is purely test infrastructure and documentation.
Changed components
test/functional/interface_ipc.pytest/functional/test_framework/test_framework.pytest/functional/test_runner.pytest/README.mdInspect captured patch +212 / −0
diff --git a/test/README.md b/test/README.md
index 37f2c072..f44e1679 100644
--- a/test/README.md
+++ b/test/README.md
@@ -34,6 +34,22 @@ The ZMQ functional test requires a python ZMQ library. To install it:
- on Unix, run `sudo apt-get install python3-zmq`
- on mac OS, run `pip3 install pyzmq`
+The IPC functional test requires a python IPC library. `pip3 install pycapnp` may work, but if not, install it from source:
+
+```sh
+git clone -b v2.1.0 https://github.com/capnproto/pycapnp
+pip3 install ./pycapnp
+```
+
+If that does not work, try adding `-C force-bundled-libcapnp=True` to the `pip` command.
+Depending on the system, it may be necessary to install and run in a venv:
+
+```sh
+python -m venv venv
+git clone -b v2.1.0 https://github.com/capnproto/pycapnp
+venv/bin/pip3 install ./pycapnp -C force-bundled-libcapnp=True
+venv/bin/python3 build/test/functional/interface_ipc.py
+```
On Windows the `PYTHONUTF8` environment variable must be set to 1:
diff --git a/test/functional/interface_ipc.py b/test/functional/interface_ipc.py
new file mode 100755
index 00000000..8410fa24
--- /dev/null
+++ b/test/functional/interface_ipc.py
@@ -0,0 +1,188 @@
+#!/usr/bin/env python3
+# Copyright (c) The Bitcoin Core developers
+# Distributed under the MIT software license, see the accompanying
+# file COPYING or http://www.opensource.org/licenses/mit-license.php.
+"""Test the IPC (multiprocess) interface."""
+import asyncio
+from io import BytesIO
+from pathlib import Path
+import shutil
+from test_framework.messages import (CBlock, CTransaction, ser_uint256)
+from test_framework.test_framework import (BitcoinTestFramework, assert_equal)
+from test_framework.wallet import MiniWallet
+
+# Test may be skipped and not have capnp installed
+try:
+ import capnp # type: ignore[import] # noqa: F401
+except ImportError:
+ pass
+
+
+class IPCInterfaceTest(BitcoinTestFramework):
+
+ def skip_test_if_missing_module(self):
+ self.skip_if_no_ipc()
+ self.skip_if_no_py_capnp()
+
+ def load_capnp_modules(self):
+ if capnp_bin := shutil.which("capnp"):
+ # Add the system cap'nproto path so include/capnp/c++.capnp can be found.
+ capnp_dir = Path(capnp_bin).parent.parent / "include"
+ else:
+ # If there is no system cap'nproto, the pycapnp module should have its own "bundled"
+ # includes at this location. If pycapnp was installed with bundled capnp,
+ # capnp/c++.capnp can be found here.
+ capnp_dir = Path(capnp.__path__[0]).parent
+ src_dir = Path(self.config['environment']['SRCDIR']) / "src"
+ mp_dir = src_dir / "ipc" / "libmultiprocess" / "include"
+ imports = [str(capnp_dir), str(src_dir), str(mp_dir)]
+ return {
+ "proxy": capnp.load(str(mp_dir / "mp" / "proxy.capnp"), imports=imports),
+ "init": capnp.load(str(src_dir / "ipc" / "capnp" / "init.capnp"), imports=imports),
+ "echo": capnp.load(str(src_dir / "ipc" / "capnp" / "echo.capnp"), imports=imports),
+ "mining": capnp.load(str(src_dir / "ipc" / "capnp" / "mining.capnp"), imports=imports),
+ }
+
+ def set_test_params(self):
+ self.num_nodes = 1
+
+ def setup_nodes(self):
+ self.extra_init = [{"ipcbind": True}]
+ super().setup_nodes()
+ # Use this function to also load the capnp modules (we cannot use set_test_params for this,
+ # as it is being called before knowing whether capnp is available).
+ self.capnp_modules = self.load_capnp_modules()
+
+ async def make_capnp_init_ctx(self):
+ node = self.nodes[0]
+ # Establish a connection, and create Init proxy object.
+ connection = await capnp.AsyncIoStream.create_unix_connection(node.ipc_socket_path)
+ client = capnp.TwoPartyClient(connection)
+ init = client.bootstrap().cast_as(self.capnp_modules['init'].Init)
+ # Create a remote thread on the server for the IPC calls to be executed in.
+ threadmap = init.construct().threadMap
+ thread = threadmap.makeThread("pythread").result
+ ctx = self.capnp_modules['proxy'].Context()
+ ctx.thread = thread
+ # Return both.
+ return ctx, init
+
+ async def parse_and_deserialize_block(self, block_template, ctx):
+ block_data = BytesIO((await block_template.result.getBlock(ctx)).result)
+ block = CBlock()
+ block.deserialize(block_data)
+ return block
+
+ def run_echo_test(self):
+ self.log.info("Running echo test")
+ async def async_routine():
+ ctx, init = await self.make_capnp_init_ctx()
+ self.log.debug("Create Echo proxy object")
+ echo = init.makeEcho(ctx).result
+ self.log.debug("Test a few invocations of echo")
+ for s in ["hallo", "", "haha"]:
+ result_eval = (await echo.echo(ctx, s)).result
+ assert_equal(s, result_eval)
+ self.log.debug("Destroy the Echo object")
+ echo.destroy(ctx)
+ asyncio.run(capnp.run(async_routine()))
+
+ def run_mining_test(self):
+ self.log.info("Running mining test")
+ block_hash_size = 32
+ block_header_size = 80
+ timeout = 1000.0 # 1000 milliseconds
+ miniwallet = MiniWallet(self.nodes[0])
+
+ async def async_routine():
+ ctx, init = await self.make_capnp_init_ctx()
+ self.log.debug("Create Mining proxy object")
+ mining = init.makeMining(ctx)
+ self.log.debug("Test simple inspectors")
+ assert (await mining.result.isTestChain(ctx))
+ assert (await mining.result.isInitialBlockDownload(ctx))
+ blockref = await mining.result.getTip(ctx)
+ assert blockref.hasResult
+ assert_equal(len(blockref.result.hash), block_hash_size)
+ current_block_height = self.nodes[0].getchaintips()[0]["height"]
+ assert blockref.result.height == current_block_height
+ self.log.debug("Mine a block")
+ wait = mining.result.waitTipChanged(ctx, blockref.result.hash, )
+ self.generate(self.nodes[0], 1)
+ newblockref = await wait
+ assert_equal(len(newblockref.result.hash), block_hash_size)
+ assert_equal(newblockref.result.height, current_block_height + 1)
+ self.log.debug("Wait for timeout")
+ wait = mining.result.waitTipChanged(ctx, newblockref.result.hash, timeout)
+ oldblockref = await wait
+ assert_equal(len(newblockref.result.hash), block_hash_size)
+ assert_equal(oldblockref.result.hash, newblockref.result.hash)
+ assert_equal(oldblockref.result.height, newblockref.result.height)
+
+ self.log.debug("Create a template")
+ opts = self.capnp_modules['mining'].BlockCreateOptions()
+ opts.useMempool = True
+ opts.blockReservedWeight = 4000
+ opts.coinbaseOutputMaxAdditionalSigops = 0
+ template = mining.result.createNewBlock(opts)
+ self.log.debug("Test some inspectors of Template")
+ header = await template.result.getBlockHeader(ctx)
+ assert_equal(len(header.result), block_header_size)
+ block = await self.parse_and_deserialize_block(template, ctx)
+ assert_equal(ser_uint256(block.hashPrevBlock), newblockref.result.hash)
+ assert len(block.vtx) >= 1
+ txfees = await template.result.getTxFees(ctx)
+ assert_equal(len(txfees.result), 0)
+ txsigops = await template.result.getTxSigops(ctx)
+ assert_equal(len(txsigops.result), 0)
+ coinbase_data = BytesIO((await template.result.getCoinbaseTx(ctx)).result)
+ coinbase = CTransaction()
+ coinbase.deserialize(coinbase_data)
+ assert_equal(coinbase.vin[0].prevout.hash, 0)
+ self.log.debug("Wait for a new template")
+ waitoptions = self.capnp_modules['mining'].BlockWaitOptions()
+ waitoptions.timeout = timeout
+ waitnext = template.result.waitNext(ctx, waitoptions)
+ self.generate(self.nodes[0], 1)
+ template2 = await waitnext
+ block2 = await self.parse_and_deserialize_block(template2, ctx)
+ assert_equal(len(block2.vtx), 1)
+ self.log.debug("Wait for another, but time out")
+ template3 = await template2.result.waitNext(ctx, waitoptions)
+ assert_equal(template3.to_dict(), {})
+ self.log.debug("Wait for another, get one after increase in fees in the mempool")
+ waitnext = template2.result.waitNext(ctx, waitoptions)
+ miniwallet.send_self_transfer(fee_rate=10, from_node=self.nodes[0])
+ template4 = await waitnext
+ block3 = await self.parse_and_deserialize_block(template4, ctx)
+ assert_equal(len(block3.vtx), 2)
+ self.log.debug("Wait again, this should return the same template, since the fee threshold is zero")
+ template5 = await template4.result.waitNext(ctx, waitoptions)
+ block4 = await self.parse_and_deserialize_block(template5, ctx)
+ assert_equal(len(block4.vtx), 2)
+ waitoptions.feeThreshold = 1
+ self.log.debug("Wait for another, get one after increase in fees in the mempool")
+ waitnext = template5.result.waitNext(ctx, waitoptions)
+ miniwallet.send_self_transfer(fee_rate=10, from_node=self.nodes[0])
+ template6 = await waitnext
+ block4 = await self.parse_and_deserialize_block(template6, ctx)
+ assert_equal(len(block4.vtx), 3)
+ self.log.debug("Wait for another, but time out, since the fee threshold is set now")
+ template7 = await template6.result.waitNext(ctx, waitoptions)
+ assert_equal(template7.to_dict(), {})
+ self.log.debug("Destroy template objects")
+ template.result.destroy(ctx)
+ template2.result.destroy(ctx)
+ template3.result.destroy(ctx)
+ template4.result.destroy(ctx)
+ template5.result.destroy(ctx)
+ template6.result.destroy(ctx)
+ template7.result.destroy(ctx)
+ asyncio.run(capnp.run(async_routine()))
+
+ def run_test(self):
+ self.run_echo_test()
+ self.run_mining_test()
+
+if __name__ == '__main__':
+ IPCInterfaceTest(__file__).main()
diff --git a/test/functional/test_framework/test_framework.py b/test/functional/test_framework/test_framework.py
index 03c00bec..0b9295ce 100755
--- a/test/functional/test_framework/test_framework.py
+++ b/test/functional/test_framework/test_framework.py
@@ -964,6 +964,13 @@ class BitcoinTestFramework(metaclass=BitcoinTestMetaClass):
except ImportError:
raise SkipTest("sqlite3 module not available.")
+ def skip_if_no_py_capnp(self):
+ """Attempt to import the capnp package and skip the test if the import fails."""
+ try:
+ import capnp # type: ignore[import] # noqa: F401
+ except ImportError:
+ raise SkipTest("capnp module not available.")
+
def skip_if_no_python_bcc(self):
"""Attempt to import the bcc package and skip the tests if the import fails."""
try:
diff --git a/test/functional/test_runner.py b/test/functional/test_runner.py
index f2e514a7..31c9805e 100755
--- a/test/functional/test_runner.py
+++ b/test/functional/test_runner.py
@@ -339,6 +339,7 @@ BASE_SCRIPTS = [
'rpc_scantxoutset.py',
'feature_unsupported_utxo_db.py',
'feature_logging.py',
+ 'interface_ipc.py',
'feature_anchors.py',
'mempool_datacarrier.py',
'feature_coinstatsindex.py',
Why this scored 15/100
Community notes
Notes can correct, qualify, or add evidence to the AI analysis. Every note shown here has been validated by a human moderator.
The AI analysis stands alone for now. Submit a note if you can add evidence or important context.