multiprocess: Add capnp wrapper for Chain interface#29409
Conversation
|
The following sections might be updated with supplementary metadata relevant to reviewers and maintainers. Code Coverage & BenchmarksFor details see: https://corecheck.dev/bitcoin/bitcoin/pulls/29409. ReviewsSee the guideline and AI policy for information on the review process.
If your review is incorrectly listed, please copy-paste ConflictsNo conflicts as of last run. |
|
🚧 At least one of the CI tasks failed. Make sure to run all tests locally, according to the Possibly this is due to a silent merge conflict (the changes in this pull request being Leave a comment here, if you need help tracking down a confusing failure. |
22e0549 to
2fd1042
Compare
cbergqvist
left a comment
There was a problem hiding this comment.
I'm eager for multiprocess to get in, but unable to do in-depth reviews in tolerable time due to lack of domain-knowledge. Did a surface level review of 2fd1042. Hope you don't mind.
2fd1042 to
41831ab
Compare
ryanofsky
left a comment
There was a problem hiding this comment.
Thanks for the review!
Updated 2fd1042 -> 41831ab (pr/ipc-chain.2 -> pr/ipc-chain.3, compare) with suggested changes.
cbergqvist
left a comment
There was a problem hiding this comment.
Thanks for incorporating some of my suggestions. Surface-level ACK of 41831ab. :)
ariard
left a comment
There was a problem hiding this comment.
being able to work with any socket (TCP or UNIX) makes a lot of sense.
you could have bitcoin-node on some secure computing host.
and then one or more bitcoin-walet on over-the-network hosts.
| template<typename S> | ||
| auto Wrap(S& s) | ||
| { | ||
| return ParamsStream{s, TX_WITH_WITNESS, CAddress::V2_NETWORK}; |
There was a problem hiding this comment.
i think Wrap could be parameterized with any CAddress version.
e.g useful if someone wanna test the native crypto noise framework between bitcoin processes.
or in the future experiment with better crypto-primitives for bip324.
There was a problem hiding this comment.
re: #29409 (comment)
i think
Wrapcould be parameterized with anyCAddressversion. e.g useful if someone wanna test the native crypto noise framework between bitcoin processes. or in the future experiment with better crypto-primitives for bip324.
For now the Wrap function is basically a workaround for not having a serialization option that says "serialize all the data in this object to a stream, I don't care about the specific serialization format." So the formats here just chosen to preserve all data, and don't really matter otherwise.
But I could imagine use-cases like you are suggesting where we may want to customize which serialization format is used depending on the IPC connection. I think it would not be that hard to do by adding parameters to Wrap, which is not called many places, or by writing different CustomReadField/BuildField overloads which do not call Wrap. For now though the easiest thing is for the wrapped stream to just hardcode parameters that serialize all the data.
|
I'm not sure what a good approach for reviewing this might be. How can I validate the changes without tests? Should I be running this as part of a parent pull request? The first two commits seem fairly mechanical, but the last one less so. The comments in the code are helpful, but some pointers in the PR description for what to look out for, or a broad description of the approaches taken would be helpful too. E.g. why is so much custom functionality introduced for |
|
re: #29409 (comment) Thanks for the questions.
You could test this as part of #10102, and I should probably add some unit test code to ipc_test.cpp to make sure new runtime code in capnp/chain.cpp and capnp/common-types.h functions has test coverage. Most of the code in the PR is evaluated at compile time though, so for example, if there is a mismatch in any of the interface declarations there will be build errors.
This is helpful feedback, and I will try to add more comments. I know it needs more comments, but it's hard for me to know where to add them without being asked because I'm too familiar with everything here. This PR is a case where "Don't spend more than a few seconds trying to understand it" review really advice applies, so if something doesn't make sense, you should just ask so I can clarify. If something doesn't make sense to you, it probably doesn't make sense to other people either.
Will add a comment, but Similar custom code also exists for the C++ |
ryanofsky
left a comment
There was a problem hiding this comment.
Updated ab6b795 -> eec0d31 (pr/ipc-chain.5 -> pr/ipc-chain.6, compare), rebasing and dropping no longer needed code after libmultiprocess update, adding comments and tests, and splitting up commits. There is still more to do to splitting up commits and adding tests, however.
This PR now shares a little bit of code with #30510, so will get smaller if #30510 is merged. #30510 is also simpler and should be easier to review, so will mark this as a draft, although feedback is still welcome on this PR especially if there are questions or things that need more explanation.
re: #29409 (review)
being able to work with any socket (TCP or UNIX) makes a lot of sense. you could have
bitcoin-nodeon some secure computing host. and then one or morebitcoin-waleton over-the-network hosts
Yes this PR does not actually expose a socket but #30509 does, and #19460 uses it for the wallet (and #19461 uses it for the gui, and Sjors#48 and #30437 use it for the mining interface
| template<typename S> | ||
| auto Wrap(S& s) | ||
| { | ||
| return ParamsStream{s, TX_WITH_WITNESS, CAddress::V2_NETWORK}; |
There was a problem hiding this comment.
re: #29409 (comment)
i think
Wrapcould be parameterized with anyCAddressversion. e.g useful if someone wanna test the native crypto noise framework between bitcoin processes. or in the future experiment with better crypto-primitives for bip324.
For now the Wrap function is basically a workaround for not having a serialization option that says "serialize all the data in this object to a stream, I don't care about the specific serialization format." So the formats here just chosen to preserve all data, and don't really matter otherwise.
But I could imagine use-cases like you are suggesting where we may want to customize which serialization format is used depending on the IPC connection. I think it would not be that hard to do by adding parameters to Wrap, which is not called many places, or by writing different CustomReadField/BuildField overloads which do not call Wrap. For now though the easiest thing is for the wrapped stream to just hardcode parameters that serialize all the data.
|
I'm curious to check if this #29409 (comment) is now fixed once this is rebased. |
|
I think we need to consider if shipping capnp files for this (complete) interface is the right move. But in order to not distract from the code review here, I opened a separate issue - that turned into a bit of an essay: #34981 |
|
Updated darosior's core_bdk_wallet again, and bitcoin-node still crashes on disconnect: Click for backtrace |
Thanks, this is expected and bitcoin-core/libmultiprocess#219 was created earlier to track this. Rust wallet is not disconnecting cleanly and some destructors need to be marked noexcept to prevent this |
| interface ChainClient $Proxy.wrap("interfaces::ChainClient") { | ||
| destroy @0 (context :Proxy.Context) -> (); | ||
| registerRpcs @1 (context :Proxy.Context) -> (); | ||
| verify @2 (context :Proxy.Context) -> (result :Bool); | ||
| load @3 (context :Proxy.Context) -> (result :Bool); | ||
| start @4 (context :Proxy.Context, scheduler :Void) -> (); | ||
| stop @5 (context :Proxy.Context) -> (); | ||
| setMockTime @6 (context :Proxy.Context, time :Int64) -> (); | ||
| schedulerMockForward @7 (context :Proxy.Context, deltaSeconds :Int64) -> (); | ||
| } |
There was a problem hiding this comment.
Out of scope for this PR, just raising this out of curiosity:
I'm working on an IPC extractor for peer-observer, a non-wallet client that consumes bitcoin-node's interfaces for monitoring. While extending it to cover the Chain interface exposed in this PR, I started to think whether such a tool could fit the ChainClient contract. The doc-comment on ChainClient specifically states this:
Interface to let node manage chain clients (wallets, or maybe tools for monitoring and analysis in the future).
Three of those methods feel pretty wallet-shaped: registerRpcs(), verify() and load(). A non-wallet implementer wouldn't necessarily expose RPCs or have saved state to load/verify.
Would it be worth reducing ChainClient to just lifecycle + mock-time hooks and moving the rest to WalletLoader? Genuinely curious whether there's prior context I'm missing.
There was a problem hiding this comment.
re: #29409 (comment)
I'm working on an IPC extractor for peer-observer, a non-wallet client that consumes
bitcoin-node's interfaces for monitoring. While extending it to cover the Chain interface exposed in this PR, I started to think whether such a tool could fit theChainClientcontract. The doc-comment on ChainClient specifically states this:Interface to let node manage chain clients (wallets, or maybe tools for monitoring and analysis in the future).
It's a good question. The doc comment is too ambiguous and should be improved. ChainClient declares methods that bitcoin-node calls and wallets and indexes can implement. It's used in #10102 to let bitcoin-node start a bitcoin-wallet process and control it. In the future the node could also start other processes (indexes, etc) and use this interface to control them. ChainClient probably isn't as likely to be useful for Chain users connecting via IPC as opposed to Chain users being spawned by the node.
There was a problem hiding this comment.
I get it now, thanks you!
|
Here's the (partially implemented) Rust bindings: 2140-dev/bitcoin-capnp-types#22 And Sjors/electrs#1 is a proof-of-concept / demo that modifies Electrs to use IPC instead of RPC and p2p for a bunch of things, notably:
|
willcl-ark
left a comment
There was a problem hiding this comment.
Concept ACK.
Exposing interfaces::Chain over IPC seems very useful, especially for downstream users. I have been using a version of this branch myself on a node "dashboard" as some dog-fooding at: https://tracing.fish.foo
Long term, I think IPC should become the preferred integration boundary for programmatic interaction with Bitcoin Core. I'd like to see us move more users away from linking into node internals, and eventually maybe even from RPC/REST/ZMQ! when IPC can provide a cleaner interface.
The overall approach of reusing the existing interfaces layer makes sense to me. I will continue to test and review a little more. Left a few nits inline.
| isRBFOptIn @15 (context :Proxy.Context, tx :Data) -> (result :Int32); | ||
| isInMempool @16 (context :Proxy.Context, txid :Data) -> (result :Bool); | ||
| hasDescendantsInMempool @17 (context :Proxy.Context, txid :Data) -> (result :Bool); | ||
| broadcastTransaction @18 (context :Proxy.Context, tx: Data, maxTxFee :Int64, broadcastMethod :Int32) -> (error: Text, result :Bool); |
There was a problem hiding this comment.
In 1c9dcf2
Also what happens here is tx is a null pointer (allowed by capnp spec AFAIU)? I think we will immediately try to deference it in BroadcastTransaction and segfault.
There was a problem hiding this comment.
re: #29409 (comment)
broadcastmethodis switched on (e.g. here) with no default case, as is our convention. but we don't check incoming value is mapped to the enum safely.Also what happens here is
txis a null pointer (allowed by capnp spec AFAIU)? I think we will immediately try to deference it inBroadcastTransactionand segfault.
IIUC, the concern in both of these cases is that non-C++ clients calling this interface (like rust or python clients) could call this method but leave the tx argument unset or leave the broadcastMethod argument unset, and then the implementation of this method could crash or fail if it is not checking for invalid values.
This is a legitimate concern, and it's not specific to this method or to these parameters. In C++, the compiler enforces at each call site that explicit values need to be passed for all parameters which don't have defaults. But in capnproto, all parameters are optional because fields without explicit defaults have implicit defaults of 0 or null.
The Chain interface was designed to be called by C++ clients, and with C++ clients this issue does not occur because clients don't need to interact with cap'nproto directly. They can call C++ methods with all normal language restrictions enforced, and the calls are forwarded over capnproto.
But if non-c++ other clients are used, they either need to be careful when they omit parameters, or the interface needs to be changed to allow parameters to be safely omitted. One way to do this is to assign default values in interfaces like c6638fa. Another way is to make method implementations handle invalid values better, for example by throwing exceptions instead of asserting.
| isInMempool @16 (context :Proxy.Context, txid :Data) -> (result :Bool); | ||
| hasDescendantsInMempool @17 (context :Proxy.Context, txid :Data) -> (result :Bool); | ||
| broadcastTransaction @18 (context :Proxy.Context, tx: Data, maxTxFee :Int64, broadcastMethod :Int32) -> (error: Text, result :Bool); | ||
| getTransactionAncestry @19 (context :Proxy.Context, txid :Data) -> (ancestors :UInt64, descendants :UInt64, ancestorsize :UInt64, ancestorfees :Int64); |
There was a problem hiding this comment.
In 1c9dcf2
even though these are positional, I think we can rename descendants -> cluster_count post-cluster mempool (and as this is public facing).
There was a problem hiding this comment.
re: #29409 (comment)
even though these are positional, I think we can rename
descendants->cluster_countpost-cluster mempool (and as this is public facing).
Good catch. It looks like the parameter was renamed in commit 957ae23 and this PR was not updated. Currently there is checking to make sure capnproto parameter types and c++ types are compatible (compilation will fail otherwise) but there it isn't checked that parameters have the same names so sometimes names can get out of sync.
| calculateIndividualBumpFees @20 (context :Proxy.Context, outpoints :List(Data), targetFeerate :Data) -> (result: List(Common.PairInt64(Data))); | ||
| calculateCombinedBumpFee @21 (context :Proxy.Context, outpoints :List(Data), targetFeerate :Data) -> (result :Int64, hasResult :Bool); | ||
| getPackageLimits @22 (context :Proxy.Context) -> (ancestors :UInt32, descendants :UInt32); | ||
| checkChainLimits @23 (context :Proxy.Context, tx :Data) -> (result :Common.ResultVoid); |
There was a problem hiding this comment.
In 1c9dcf2
I wonder if this tx isn't the same as 1c9dcf2 (this PR) ?
CTxMemPool::ChangeSet::TxHandle CTxMemPool::ChangeSet::StageAddition is going to try and dereference.
There was a problem hiding this comment.
re: #29409 (comment)
I wonder if this
txisn't the same as1c9dcf2(this PR) ?
CTxMemPool::ChangeSet::TxHandle CTxMemPool::ChangeSet::StageAdditionis going to try and dereference.
Yes this is the same issue. checkChainLimits calls CheckPolicyLimits which calls StageAddition which does not check whether tx is null before dereferencing.
And In this case, the issue applies to C++ clients as well as rust and python clients because C++ clients can also call checkChainLimits with a null pointer.
Depending on what the goal is, there are various ways this could be addressed. One way would be to change checkChainLimits to throw an exception if a null pointer is passed.
In general it's not a realistic goal for this PR to ensure that it's safe to call all Chain methods with all possible arguments and ensure that the node will not crash, but it should be good to do where feasible.
| return {}; | ||
| } | ||
|
|
||
| void ProxyServerMethodTraits<ipc::capnp::messages::ChainClient::StartParams>::invoke(ChainContext& context) |
There was a problem hiding this comment.
In 1c9dcf2
This assert is dead code only because ChainClient is not exposed by this PR.
The comment says it is overridden by WalletLoader::start, but would this not still abort if ever called?
Why not omit start from the exposed base interface or make this throw a protocol error instead of asserting.
There was a problem hiding this comment.
re: #29409 (comment)
In 1c9dcf2
This assert is dead code only because
ChainClientis not exposed by this PR.
ChainClient is actually exposed by this PR. But depending on what we want the goal of this PR it doesn't need to be. I tried to describe purpose of ChainClient better in a recent comment #29409 (comment), but basically it is not something you would implement if you are an external process connecting to the node, it is only something you would implement if you are implementing a process you would want to be started by the node. In #10102, bitcoin-node spawns a bitcoin-wallet process, and bitcoin-wallet implements the ChainClient interfaces, so the node can manage it and forward RPCs and shut it down.
Original goal of this PR was to add chain.capnp to the codebase so #10102 is easier to review. But if the goal of this PR is now really to expose the Chain interface over IPC, the ChainClient capnproto definition can be dropped from this.
The comment says it is overridden by
WalletLoader::start, but would this not still abort if ever called?
I don't think there is a realistic way this could ever be called. This is here as workaround for the fact the libmultiprocess (and capnproto) don't have separate options for generating client and server classes. So if you declare any interface, client and server classes will be produced and they need to compile. Libmultiprocess can't generate ChainClient::start server method that compiles because it takes a CScheduler& argument and there isn't a way to share schedulers across processes. In #10102, the WalletLoader implementation of start just creates a separate scheduler for the wallet process to use.
More ideally, there might be a way to mark ChainClient as an abstract class that is allowed to be called but never implemented directly as a server object, only inherited from and implement that way. Capnproto doesn't provide this but libmultiprocess could define it's own $Abstract annotation that would do this.
Why not omit start from the exposed base interface or make this throw a protocol error instead of asserting.
This could throw an exception instead of assert. It shouldn't matter very much because I don't think there's a way this could be accidentally called.
Another way to get rid of this might be drop the CScheduler parameter and just have the wallet and node use different schedulers instead of the same one.
ryanofsky
left a comment
There was a problem hiding this comment.
I spent way too long looking into the ChainClient::start thing, but thanks for the questions and putting the interface to work!
| isRBFOptIn @15 (context :Proxy.Context, tx :Data) -> (result :Int32); | ||
| isInMempool @16 (context :Proxy.Context, txid :Data) -> (result :Bool); | ||
| hasDescendantsInMempool @17 (context :Proxy.Context, txid :Data) -> (result :Bool); | ||
| broadcastTransaction @18 (context :Proxy.Context, tx: Data, maxTxFee :Int64, broadcastMethod :Int32) -> (error: Text, result :Bool); |
There was a problem hiding this comment.
re: #29409 (comment)
broadcastmethodis switched on (e.g. here) with no default case, as is our convention. but we don't check incoming value is mapped to the enum safely.Also what happens here is
txis a null pointer (allowed by capnp spec AFAIU)? I think we will immediately try to deference it inBroadcastTransactionand segfault.
IIUC, the concern in both of these cases is that non-C++ clients calling this interface (like rust or python clients) could call this method but leave the tx argument unset or leave the broadcastMethod argument unset, and then the implementation of this method could crash or fail if it is not checking for invalid values.
This is a legitimate concern, and it's not specific to this method or to these parameters. In C++, the compiler enforces at each call site that explicit values need to be passed for all parameters which don't have defaults. But in capnproto, all parameters are optional because fields without explicit defaults have implicit defaults of 0 or null.
The Chain interface was designed to be called by C++ clients, and with C++ clients this issue does not occur because clients don't need to interact with cap'nproto directly. They can call C++ methods with all normal language restrictions enforced, and the calls are forwarded over capnproto.
But if non-c++ other clients are used, they either need to be careful when they omit parameters, or the interface needs to be changed to allow parameters to be safely omitted. One way to do this is to assign default values in interfaces like c6638fa. Another way is to make method implementations handle invalid values better, for example by throwing exceptions instead of asserting.
| isInMempool @16 (context :Proxy.Context, txid :Data) -> (result :Bool); | ||
| hasDescendantsInMempool @17 (context :Proxy.Context, txid :Data) -> (result :Bool); | ||
| broadcastTransaction @18 (context :Proxy.Context, tx: Data, maxTxFee :Int64, broadcastMethod :Int32) -> (error: Text, result :Bool); | ||
| getTransactionAncestry @19 (context :Proxy.Context, txid :Data) -> (ancestors :UInt64, descendants :UInt64, ancestorsize :UInt64, ancestorfees :Int64); |
There was a problem hiding this comment.
re: #29409 (comment)
even though these are positional, I think we can rename
descendants->cluster_countpost-cluster mempool (and as this is public facing).
Good catch. It looks like the parameter was renamed in commit 957ae23 and this PR was not updated. Currently there is checking to make sure capnproto parameter types and c++ types are compatible (compilation will fail otherwise) but there it isn't checked that parameters have the same names so sometimes names can get out of sync.
| calculateIndividualBumpFees @20 (context :Proxy.Context, outpoints :List(Data), targetFeerate :Data) -> (result: List(Common.PairInt64(Data))); | ||
| calculateCombinedBumpFee @21 (context :Proxy.Context, outpoints :List(Data), targetFeerate :Data) -> (result :Int64, hasResult :Bool); | ||
| getPackageLimits @22 (context :Proxy.Context) -> (ancestors :UInt32, descendants :UInt32); | ||
| checkChainLimits @23 (context :Proxy.Context, tx :Data) -> (result :Common.ResultVoid); |
There was a problem hiding this comment.
re: #29409 (comment)
I wonder if this
txisn't the same as1c9dcf2(this PR) ?
CTxMemPool::ChangeSet::TxHandle CTxMemPool::ChangeSet::StageAdditionis going to try and dereference.
Yes this is the same issue. checkChainLimits calls CheckPolicyLimits which calls StageAddition which does not check whether tx is null before dereferencing.
And In this case, the issue applies to C++ clients as well as rust and python clients because C++ clients can also call checkChainLimits with a null pointer.
Depending on what the goal is, there are various ways this could be addressed. One way would be to change checkChainLimits to throw an exception if a null pointer is passed.
In general it's not a realistic goal for this PR to ensure that it's safe to call all Chain methods with all possible arguments and ensure that the node will not crash, but it should be good to do where feasible.
| return {}; | ||
| } | ||
|
|
||
| void ProxyServerMethodTraits<ipc::capnp::messages::ChainClient::StartParams>::invoke(ChainContext& context) |
There was a problem hiding this comment.
re: #29409 (comment)
In 1c9dcf2
This assert is dead code only because
ChainClientis not exposed by this PR.
ChainClient is actually exposed by this PR. But depending on what we want the goal of this PR it doesn't need to be. I tried to describe purpose of ChainClient better in a recent comment #29409 (comment), but basically it is not something you would implement if you are an external process connecting to the node, it is only something you would implement if you are implementing a process you would want to be started by the node. In #10102, bitcoin-node spawns a bitcoin-wallet process, and bitcoin-wallet implements the ChainClient interfaces, so the node can manage it and forward RPCs and shut it down.
Original goal of this PR was to add chain.capnp to the codebase so #10102 is easier to review. But if the goal of this PR is now really to expose the Chain interface over IPC, the ChainClient capnproto definition can be dropped from this.
The comment says it is overridden by
WalletLoader::start, but would this not still abort if ever called?
I don't think there is a realistic way this could ever be called. This is here as workaround for the fact the libmultiprocess (and capnproto) don't have separate options for generating client and server classes. So if you declare any interface, client and server classes will be produced and they need to compile. Libmultiprocess can't generate ChainClient::start server method that compiles because it takes a CScheduler& argument and there isn't a way to share schedulers across processes. In #10102, the WalletLoader implementation of start just creates a separate scheduler for the wallet process to use.
More ideally, there might be a way to mark ChainClient as an abstract class that is allowed to be called but never implemented directly as a server object, only inherited from and implement that way. Capnproto doesn't provide this but libmultiprocess could define it's own $Abstract annotation that would do this.
Why not omit start from the exposed base interface or make this throw a protocol error instead of asserting.
This could throw an exception instead of assert. It shouldn't matter very much because I don't think there's a way this could be accidentally called.
Another way to get rid of this might be drop the CScheduler parameter and just have the wallet and node use different schedulers instead of the same one.
- Add capnp ToBlob, ToArray, Wrap, Serialize, and Unserialize helper functions - Add support for std::chrono::seconds capnp serialization - Add support for util::Result capnp serialization
Expose Chain interface to external processes spawning or connecting to bitcoin-node.
|
🚧 At least one of the CI tasks failed. HintsTry to run the tests locally, according to the documentation. However, a CI failure may still
Leave a comment here, if you need help tracking down a confusing failure. |
Changes making it possible to call
interface::Chainover a socket.Note: The changes in this PR allow a
bitcoin-nodeprocess to be controlled over a socket. The socket can either be an UNIX socket created by specifying the-ipcbindoption, or it can be a socketpair created by a controlling process and passed tobitcoin-nodevia the-ipcfd=<n>with a file descriptor number. This PR is a dependency of #10102 which allows the wallet code to run in a separate process and use theChaininterface.This PR is part of the process separation project.