From 8d2ee88fa2a569410d09e5e1a4f6c92ff9d5e5d1 Mon Sep 17 00:00:00 2001 From: Pieter Wuille Date: Thu, 21 Aug 2025 03:16:07 -0400 Subject: [PATCH] tests: add functional tests for IPC interface Co-Authored-By: ismaelsadeeq Co-Authored-By: ryanofsky Co-Authored-By: TheCharlatan Co-Authored-By: Sjors Provoost --- test/README.md | 16 ++ test/functional/interface_ipc.py | 188 ++++++++++++++++++ .../test_framework/test_framework.py | 7 + test/functional/test_runner.py | 1 + 4 files changed, 212 insertions(+) create mode 100755 test/functional/interface_ipc.py diff --git a/test/README.md b/test/README.md index 37f2c072178..f44e1679d9f 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 00000000000..8410fa24bef --- /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 03c00bec7ac..0b9295cef53 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 f2e514a7a46..31c9805e8eb 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',