Protocol request pipelining in LLCPP

Prerequisites

This tutorial builds on the LLCPP getting started tutorials.

Overview

A common aspect of using FIDL on Fuchsia is passing protocol endpoints across protocols. Many FIDL messages include either the client end or the server end of a channel, where the channel is used to communicate over a different FIDL protocol. In this case, the client end allows making requests to the specified protocol, whereas the server end must implement the specified protocol. An alternate set of terms for client end and server end are protocol and protocol request.

This tutorial covers:

  • The usage of these client and server ends, both in FIDL and in the LLCPP FIDL bindings.
  • The request pipelining pattern and its benefits.

The full example code for this tutorial is located at //examples/fidl/llcpp/request_pipelining.

The FIDL protocol

This tutorial implements the EchoLauncher protocol from the fuchsia.examples library:

@discoverable
protocol EchoLauncher {
    GetEcho(struct {
        echo_prefix string:MAX_STRING_LENGTH;
    }) -> (resource struct {
        response client_end:Echo;
    });
    GetEchoPipelined(resource struct {
        echo_prefix string:MAX_STRING_LENGTH;
        request server_end:Echo;
    });
};

This is a protocol that lets clients retrieve an instance of the Echo protocol. Clients can specify a prefix, and the resulting Echo instance adds that prefix to every response.

There are two methods that can be used to accomplish this:

  • GetEcho: Takes the prefix as a request, and responds with the client end of a channel connected to an implementation of the Echo protocol. After receiving the client end in the response, the client can start making requests on the Echo protocol using the client end.
  • GetEchoPipelined: Takes the server end of a channel as one of the request parameters and binds an implementation of Echo to it. The client that made the request is assumed to already hold the client end, and will start making Echo requests on that channel after calling GetEchoPipelined.

As the name suggests, the latter uses a pattern called protocol request pipelining, and is the preferred approach. This tutorial implements both approaches.

Implement the server

Implement the Echo protocol

This implementation of Echo allows specifying a prefix in order to distinguish between the different instances of Echo servers:

// Implementation of the Echo protocol that prepends a prefix to every response.
class EchoImpl final : public fidl::WireServer<fuchsia_examples::Echo> {
 public:
  explicit EchoImpl(std::string prefix) : prefix_(prefix) {}
  // This method is not used in the request pipelining example, so requests are ignored.
  void SendString(SendStringRequestView request, SendStringCompleter::Sync& completer) override {}
  void EchoString(EchoStringRequestView request, EchoStringCompleter::Sync& completer) override {
    std::cout << "Got echo request for prefix " << prefix_ << std::endl;
    auto value_str = std::string(request->value.data(), request->value.size());
    auto response = prefix_ + value_str;
    completer.Reply(fidl::StringView::FromExternal(response));
  }

  const std::string prefix_;
};

The SendString handler is empty as the client just uses EchoString.

Implement the EchoLauncher protocol

This class responds to either method by launching an instance of an Echo server, and then stores the EchoImpl instance in a member variable to ensure that its lifetime matches that of the launcher. The code for running an Echo server given a specific prefix and channel is abstracted into a helper RunEchoServer method:

// Implementation of EchoLauncher. Each method creates an instance of EchoImpl
// with the specified prefix.
class EchoLauncherImpl final : public fidl::WireServer<fuchsia_examples::EchoLauncher> {
 public:
  explicit EchoLauncherImpl(async_dispatcher_t* dispatcher) : dispatcher_(dispatcher) {}

  void GetEcho(GetEchoRequestView request, GetEchoCompleter::Sync& completer) override {
    std::cout << "Got non pipelined request" << std::endl;
    auto endpoints = fidl::CreateEndpoints<fuchsia_examples::Echo>();
    ZX_ASSERT(endpoints.is_ok());
    auto [client_end, server_end] = *std::move(endpoints);
    RunEchoServer(request->echo_prefix, std::move(server_end));
    completer.Reply(std::move(client_end));
  }

  void GetEchoPipelined(GetEchoPipelinedRequestView request,
                        GetEchoPipelinedCompleter::Sync& completer) override {
    std::cout << "Got pipelined request" << std::endl;
    RunEchoServer(request->echo_prefix, std::move(request->request));
  }

  void RunEchoServer(fidl::StringView prefix, fidl::ServerEnd<fuchsia_examples::Echo> server_end) {
    // The binding stays alive as long as the EchoImpl class that is bound is kept in
    // scope, so store them in the class.
    server_instances_.push_back(
        std::make_unique<EchoImpl>(std::string(prefix.data(), prefix.size())));
    fidl::BindServer(dispatcher_, std::move(server_end), server_instances_.back().get());
  }

  // Keep track of all running EchoImpl instances so that they share the same lifetime
  // as this class.
  std::vector<std::unique_ptr<EchoImpl>> server_instances_;
  async_dispatcher_t* dispatcher_;
};

For GetEcho, the code first needs to instantiate both ends of the channel. It then launches an Echo instance using the server end, and then sends a response back with the client end. For GetEchoPipelined, the client has already done the work of creating both ends of the channel. It keeps one end and has passed the other to the server, so all the code needs to do is call RunEchoServer.

Serve the EchoLauncher protocol

The main loop should is the same as in the server tutorial but serves an EchoLauncher instead of Echo.

int main(int argc, char** argv) {
  zx_handle_t directory_request = zx_take_startup_handle(PA_DIRECTORY_REQUEST);
  if (directory_request == ZX_HANDLE_INVALID) {
    std::cerr << "error: directory_request was ZX_HANDLE_INVALID" << std::endl;
    return -1;
  }

  async::Loop loop(&kAsyncLoopConfigAttachToCurrentThread);
  async_dispatcher_t* dispatcher = loop.dispatcher();

  svc_dir_t* dir = nullptr;
  zx_status_t status = svc_dir_create(dispatcher, directory_request, &dir);
  if (status != ZX_OK) {
    std::cerr << "error: svc_dir_create returned: " << status << " ("
              << zx_status_get_string(status) << ")" << std::endl;
    return status;
  }

  ConnectRequestContext context = {.dispatcher = dispatcher,
                                   .server = std::make_unique<EchoLauncherImpl>(dispatcher)};
  status = svc_dir_add_service(dir, "svc", "fuchsia.examples.EchoLauncher", &context, connect);
  if (status != ZX_OK) {
    std::cerr << "error: svc_dir_add_service returned: " << status << " ("
              << zx_status_get_string(status) << ")" << std::endl;
    return status;
  }

  std::cout << "Running echo launcher server" << std::endl;
  loop.Run();
  return 0;
}

Build the server

Optionally, to check that things are correct, try building the server:

  1. Configure your GN build to include the server:

    fx set core.x64 --with //examples/fidl/llcpp/request_pipelining/server:echo-server
    
  2. Build the Fuchsia image:

    fx build
    

Implement the client

After connecting to the EchoLauncher server, the client code connects to one instance of Echo using GetEcho and another using GetEchoPipelined and then makes an EchoString request on each instance.

Non-pipelined client

This is the non-pipelined code:

int main(int argc, const char** argv) {
  async::Loop loop(&kAsyncLoopConfigAttachToCurrentThread);
  async_dispatcher_t* dispatcher = loop.dispatcher();
  int num_responses = 0;

  // Connect to the EchoLauncher protocol
  auto launcher_client_end = service::Connect<fuchsia_examples::EchoLauncher>();
  ZX_ASSERT(launcher_client_end.status_value() == ZX_OK);
  fidl::WireClient launcher(std::move(*launcher_client_end), dispatcher);

  // Make a non-pipelined request to get an instance of Echo
  launcher->GetEcho("non pipelined: ",
                    [&](fidl::WireUnownedResult<fuchsia_examples::EchoLauncher::GetEcho>& result) {
                      ZX_ASSERT(result.ok());
                      // Take the channel to Echo in the response, bind it to the dispatcher, and
                      // make an EchoString request on it.
                      fidl::WireSharedClient echo(std::move(result->response), dispatcher);
                      echo->EchoString(
                          "hello!",
                          // Clone |echo| into the callback so that the client
                          // is only destroyed after we receive the response.
                          [&, echo = echo.Clone()](
                              fidl::WireResponse<fuchsia_examples::Echo::EchoString>* response) {
                            std::string reply(response->response.data(), response->response.size());
                            std::cout << "Got echo response " << reply << std::endl;
                            if (++num_responses == 2) {
                              loop.Quit();
                            }
                          });
                    });

  auto endpoints = fidl::CreateEndpoints<fuchsia_examples::Echo>();
  ZX_ASSERT(endpoints.status_value() == ZX_OK);
  auto [client_end, server_end] = *std::move(endpoints);
  // Make a pipelined request to get an instance of Echo
  ZX_ASSERT(launcher->GetEchoPipelined("pipelined: ", std::move(server_end)).ok());
  // A client can be initialized using the client end without waiting for a response
  fidl::WireClient echo_pipelined(std::move(client_end), dispatcher);
  echo_pipelined->EchoString(
      "hello!", [&](fidl::WireUnownedResult<fuchsia_examples::Echo::EchoString>& result) {
        ZX_ASSERT_MSG(result.ok(), "EchoString failed: %s",
                      result.error().FormatDescription().c_str());
        std::string reply(result->response.data(), result->response.size());
        std::cout << "Got echo response " << reply << std::endl;
        if (++num_responses == 2) {
          loop.Quit();
        }
      });

  loop.Run();
  return num_responses == 2 ? 0 : 1;
}

This code has two layers of callbacks:

  • The outer layer handles the launcher GetEcho response.
  • The inner layer handles the EchoString response.

Inside the GetEcho response callback, the code binds the returned client end to a fidl::WireSharedClient<Echo>, and places a clone into the EchoString callback, so that the client's lifetime is extended until when the echo response is received, which will most likely be after the top level callback returns.

Pipelined client

Despite having to create a pair of endpoints first, the pipelined code is much simpler:

int main(int argc, const char** argv) {
  async::Loop loop(&kAsyncLoopConfigAttachToCurrentThread);
  async_dispatcher_t* dispatcher = loop.dispatcher();
  int num_responses = 0;

  // Connect to the EchoLauncher protocol
  auto launcher_client_end = service::Connect<fuchsia_examples::EchoLauncher>();
  ZX_ASSERT(launcher_client_end.status_value() == ZX_OK);
  fidl::WireClient launcher(std::move(*launcher_client_end), dispatcher);

  // Make a non-pipelined request to get an instance of Echo
  launcher->GetEcho("non pipelined: ",
                    [&](fidl::WireUnownedResult<fuchsia_examples::EchoLauncher::GetEcho>& result) {
                      ZX_ASSERT(result.ok());
                      // Take the channel to Echo in the response, bind it to the dispatcher, and
                      // make an EchoString request on it.
                      fidl::WireSharedClient echo(std::move(result->response), dispatcher);
                      echo->EchoString(
                          "hello!",
                          // Clone |echo| into the callback so that the client
                          // is only destroyed after we receive the response.
                          [&, echo = echo.Clone()](
                              fidl::WireResponse<fuchsia_examples::Echo::EchoString>* response) {
                            std::string reply(response->response.data(), response->response.size());
                            std::cout << "Got echo response " << reply << std::endl;
                            if (++num_responses == 2) {
                              loop.Quit();
                            }
                          });
                    });

  auto endpoints = fidl::CreateEndpoints<fuchsia_examples::Echo>();
  ZX_ASSERT(endpoints.status_value() == ZX_OK);
  auto [client_end, server_end] = *std::move(endpoints);
  // Make a pipelined request to get an instance of Echo
  ZX_ASSERT(launcher->GetEchoPipelined("pipelined: ", std::move(server_end)).ok());
  // A client can be initialized using the client end without waiting for a response
  fidl::WireClient echo_pipelined(std::move(client_end), dispatcher);
  echo_pipelined->EchoString(
      "hello!", [&](fidl::WireUnownedResult<fuchsia_examples::Echo::EchoString>& result) {
        ZX_ASSERT_MSG(result.ok(), "EchoString failed: %s",
                      result.error().FormatDescription().c_str());
        std::string reply(result->response.data(), result->response.size());
        std::cout << "Got echo response " << reply << std::endl;
        if (++num_responses == 2) {
          loop.Quit();
        }
      });

  loop.Run();
  return num_responses == 2 ? 0 : 1;
}

Unlike in the client tutorial, the async loop is run to completion once, which runs both non-pipelined and pipelined code concurrently in order to observe the order of operations. The client keeps track of the number of responses being received, so that it can quit the loop once no more messages from the server are expected.

Build the client

Optionally, to check that things are correct, try building the client:

  1. Configure your GN build to include the server:

    fx set core.x64 --with //examples/fidl/llcpp/request_pipelining/client:echo-client
    
  2. Build the Fuchsia image:

    fx build
    

Run the example code

For this tutorial, a realm component is provided to declare the appropriate capabilities and routes for fuchsia.examples.Echo and fuchsia.examples.EchoLauncher.

  1. Configure your build to include the provided package that includes the echo realm, server, and client:

    fx set core.qemu-x64 --with //examples/fidl/llcpp:echo-launcher-llcpp
    
  2. Build the Fuchsia image:

    fx build
    
  3. Run the echo_realm component. This creates the client and server component instances and routes the capabilities:

    ffx component run fuchsia-pkg://fuchsia.com/echo-launcher-llcpp#meta/echo_realm.cm
    
  4. Start the echo_client instance:

    ffx component bind /core/ffx-laboratory:echo_realm/echo_client
    

The server component starts when the client attempts to connect to the EchoLauncher protocol. You should see output similar to the following in the device logs (ffx log):

none {:.devsite-disable-click-to-copy}` [echo_server][][I] Running echo launcher server [echo_server][][I] echo_server_llcpp: Incoming connection for fuchsia.examples.EchoLauncher [echo_server][][I] Got non pipelined request [echo_server][][I] Got pipelined request [echo_server][][I] Got echo request for prefix pipelined: [echo_client][][I] Got echo response pipelined: hello! [echo_server][][I] Got echo request for prefix non pipelined: [echo_client][][I] Got echo response non pipelined: hello!

Based on the print order, you can see that the pipelined case is faster. The echo response for the pipelined case arrives first, even though the non pipelined request is sent first, since request pipelining saves a roundtrip between the client and server. Request pipelining also simplifies the code.

For further reading about protocol request pipelining, including how to handle protocol requests that may fail, see the FIDL API rubric.

Terminate the realm component to stop execution and clean up the component instances:

ffx component destroy /core/ffx-laboratory:echo_realm