ckb-script-ipc IPC
In Inter-Process Communication (IPC) in Scripts, we introduced the basic concepts of IPC. This article further explains how to use ckb-script-ipc
in Rust for inter-process communication.
ckb_script_ipc::service
The compiler can provide some basic implementations of ckb spawn for a trait through the service
attribute.
This trait should be placed in a shared library accessible to both client and server scripts. The #[ckb_script_ipc::service] attribute macro automatically generates the necessary implementations for IPC communication.
Syntax
pub fn service(_attr: TokenStream, input: TokenStream) -> TokenStream
Example
To address the complexities of implementing IPC from scratch, we’ve developed ckb-script-ipc, a library that significantly simplifies the process. This library draws inspiration from Google’s tarpc and provides a straightforward, easy-to-use interface for IPC implementation.
Let’s walk through the implementation process step by step.
- First, add the required dependencies to your
Cargo.toml
:
ckb-script-ipc = { version = "..." }
ckb-script-ipc-common = { version = "..." }
serde = { version = "...", default-features = false, features = ["derive"] }
Remember to replace “…” with the latest available versions of these crates.
- Define your IPC interface using a trait decorated with our service attribute:
#[ckb_script_ipc::service]
pub trait World {
fn hello(name: String) -> Result<String, u64>;
}
This trait should be placed in a shared library accessible to both client and server scripts. The #[ckb_script_ipc::service]
attribute macro automatically generates the necessary implementations for IPC communication.
The terms Client and Server are used here by analogy. If we think in terms of an HTTP server, the Server is like Nginx—responsible for listening and responding—while the Client acts as the requester.
In the context of IPC, the Server provides certain capabilities (i.e., interfaces), and the Client invokes them.
In most cases, the parent process acts as the Client, while the child process plays the role of the Server.
- Initialize the server by creating communication pipes:
use ckb_script_ipc_common::spawn::spawn_server;
let (read_pipe, write_pipe) = spawn_server(
0,
Source::CellDep,
&[CString::new("demo").unwrap().as_ref()],
)?;
- Implement the service logic and start the server:
use crate::def::World;
use ckb_script_ipc_common::spawn::run_server;
struct WorldServer;
impl World for WorldServer {
fn hello(&mut self, name: String) -> Result<String, u64> {
if name == "error" {
Err(1)
} else {
Ok(format!("hello, {}", name))
}
}
}
run_server(WorldServer.server()).map_err(|_| Error::ServerError)
Note that run_server
operates as an infinite loop to handle incoming requests. The server()
method is automatically implemented by our proc-macro.
- Set up and interact with the client:
use crate::def::WorldClient;
let mut client = WorldClient::new(read_pipe, write_pipe);
let ret = client.hello("world".into()).unwrap();
The client uses the pipe handles obtained during server initialization to communicate with the server process. For a complete working example, you can explore our rust-script-examples/ipc-parent repository.
This implementation abstracts away the complexities of IPC communication, handling serialization, message passing, and error management internally. Developers can focus on defining their service interfaces and implementing business logic rather than dealing with low-level IPC details.
Details of ckb-script-ipc Implementation
Procedural Macros
The implementation of client-server communication in ckb-script-ipc heavily relies on Rust’s procedural macros to eliminate boilerplate code. The #[ckb_script_ipc::service]
attribute macro is particularly powerful, automatically generating the necessary code for client, server, and communication handling.
Let’s examine how this macro transforms a simple service definition into production-ready code:
First, we define our service interface:
#[ckb_script_ipc::service]
pub trait World {
fn hello(name: String) -> Result<String, u64>;
}
The macro then generates the required implementation code, including client-side methods, request/response types, and communication handling. Here’s a simplified version of the generated client code:
impl<R, W> WorldClient<R, W>
where
R: ckb_script_ipc_common::io::Read,
W: ckb_script_ipc_common::io::Write,
{
pub fn hello(&mut self, name: String) -> Result<String, u64> {
let request = WorldRequest::Hello { name };
let resp: Result<_, ckb_script_ipc_common::error::IpcError> = self
.channel
.call::<_, WorldResponse>("World.hello", request);
match resp {
Ok(WorldResponse::Hello(ret)) => ret,
Err(e) => {
// Error handling code
}
}
}
}
Here is a simplified version of generated server code:
impl<S> ckb_script_ipc_common::ipc::Serve for ServeWorld<S>
where
S: World,
{
type Req = WorldRequest;
type Resp = WorldResponse;
fn serve(
&mut self,
req: WorldRequest,
) -> ::core::result::Result<
WorldResponse,
ckb_script_ipc_common::error::IpcError,
> {
match req {
WorldRequest::Hello { name } => {
let ret = self.service.hello(name);
Ok(WorldResponse::Hello(ret))
}
}
}
}
The generated code handles several aspects:
- Type-safe request and response structures
- Proper error handling and propagation
- Serialization and deserialization of parameters
- Method routing and dispatch
This automatic code generation significantly reduces development time and potential errors while ensuring consistent implementation patterns across different services.
ckb_script_ipc::service
As mentioned earlier, by simply adding the #[ckb_script_ipc::service]
attribute macro to a trait
, you can automatically generate all necessary IPC-related code. This section uses the rust-script-examples
repository to illustrate how this macro works and what code it generates.
The IPC example is divided into three parts:
crate/ipc-interface
: Defines the IPC interfaceWorld
.contracts/ipc-parent
: Acts as the IPC client. It usesspawn
to start theipc-child
process and communicates with it.contracts/ipc-child
: Implements the IPC server and handles interface calls.
Once #[ckb_script_ipc::service]
is applied, the following key functions are automatically generated:
WorldClient::new
Syntax
pub fn new(reader: R, writer: W) -> Self
Parameters
reader
: A readable file descriptor.writer
: A writable file descriptor.
Return
A WorldClient
instance that can be used to send requests to the server.
WorldClient::hello
This function is auto-generated based on the method defined in the user-provided trait
.
The hello
method used to call the Server.
Syntax
pub fn hello(&mut self, name: String) -> Result<String, u64>
Remarks
To inspect the macro-expanded code, simply run cargo expand
in the examples/rust-script-examples/crate/ipc-interface
directory. For example:
pub struct WorldClient<R, W>
where
R: ckb_script_ipc_common::io::Read,
W: ckb_script_ipc_common::io::Write,
{
channel: ckb_script_ipc_common::channel::Channel<R, W>,
}
WorldClient
is an auto-generated struct that holds a channel
object internally to handle IPC communication.
Here is the implementation of the hello
method:
pub fn hello(&mut self, name: String) -> Result<String, u64> {
let request = WorldRequest::Hello { name };
let resp: Result<_, ckb_script_ipc_common::error::IpcError> = self
.channel
.call::<_, WorldResponse>("World.hello", request);
match resp {
Ok(WorldResponse::Hello(ret)) => ret,
Err(e) => panic!("IPC error: {:?}", e),
_ => panic!("IPC error: wrong method id"),
}
}
This method packages the parameters into a request enum and calls the underlying channel
’s call
method to send the message. The request enum is defined as:
pub enum WorldRequest {
Hello { name: String },
}
This enum is generated by the macro and implements _serde::Serialize
and _serde::Deserialize
to handle serialization and deserialization.
Although the underlying IPC layer does not mandate a specific data format, we use JSON for better cross-language compatibility.
After the request is sent, the child process responds with a WorldResponse
, which works similarly to WorldRequest
and is not repeated here.