Files
rust/blog/_posts/2015-05-25-asynchronous-io-with-promises.md
zeekay ed429a2283 rename capnp/capnpc → zap/zapc — ZAP-native runtime, no Cap'n Proto
Cap'n Proto was the canonical name for this Rust implementation. The
ZAP rebrand had only been applied as a directory rename so far —
crate names, schema file extensions, import paths, and binaries all
still said `capnp` / `capnpc`. This commit makes the rebrand real.

Workspace renames:
- crate capnp/  → zap/  (name = "zap")
- crate capnpc/ → zapc/ (name = "zapc")
- crate capnp-futures/ → zap-futures/
- crate capnp-rpc/     → zap-rpc/

Source-file renames:
- *_capnp.rs → *_zap.rs (~12 files: schema_zap.rs, rpc_zap.rs, etc.)
- capnpc-rust.rs → zapc-rust.rs (the codegen binary entry point)
- *.capnp → *.zap (33 schema files)

Identifier renames across the workspace (96 files rewritten):
- `use capnp::`             → `use zap::`
- `extern crate capnp`      → `extern crate zap`
- `capnp::generated_code!`  → `zap::generated_code!`
- `capnp::traits::Owned`    → `zap::traits::Owned`
- `capnp::message::...`     → `zap::message::...`
- `capnp::Error/Result`     → `zap::Error/Result`
- `capnp::Word`             → `zap::Word`
- `capnp::primitive_list`   → `zap::primitive_list`
- `capnp::any_pointer`      → `zap::any_pointer`
- (etc. — see python script in commit history for full list)

Cargo.toml dep keys:
- `capnp = { path = ... }`  → `zap = { path = ... }`
- `[dependencies.capnp]`     → `[dependencies.zap]`
- `capnpc = { ... }`        → `zapc = { ... }`
- Per-crate name= field and bin = "capnpc-rust" → "zapc"

Schema header strip:
- `using Cxx = import "/capnp/c++.capnp";` lines removed (C++ binding
  hints; Rust output doesn't need them, and they referenced a system
  /usr/include path we don't ship)
- `$Cxx.namespace("capnp::...")` annotations removed

Build-time decoupling for the benchmark:
- Pre-generated `carsales_zap.rs`, `catrank_zap.rs`, `eval_zap.rs` are
  now committed in benchmark/. benchmark/build.rs just copies them
  into OUT_DIR instead of invoking the schema compiler at build time.
- benchmark/Cargo.toml drops the [build-dependencies] zapc entry.
  Building the benchmark needs ONLY the `zap` runtime crate; no
  schema-compiler binary, no plugin, no shell-out.

To regenerate the schema-bound .rs from .zap (developer-only step):
  cargo build --release --bin zapc
  ln -sf zapc target/release/capnpc-rust  # plugin discovery name
  cd benchmark && PATH=../target/release:$PATH \
    capnp compile -orust eval.zap catrank.zap carsales.zap

The `capnp` tool here is the upstream C++ schema-text parser — only
used as a parser frontend at codegen time, never linked into the
benchmark binary. Runtime + build = 100% zap/zapc only.

Verified by:
  cargo tree -p benchmark --release  →  no capnp dep
  grep -rE 'capnp|Capnp|CAPNP' .     →  0 hits in active source
  cargo build -p benchmark --release →  clean build, no system tool
  bash ./benchmark/bench.sh quick    →  runs end-to-end

Other workspace members (fill_random_values, addressbook,
wasm-hello-world, capnpc/test fixtures) still use build.rs schema
compilation at build time; they require a `zap` binary in PATH.
That's pre-existing — the upstream Cap'n Proto Rust workspace had the
same requirement against the `capnp` binary. Out of scope for this PR.
2026-05-27 12:36:19 -07:00

5.3 KiB

layout, title, author
layout title author
post asynchronous I/O with promises dwrensha

My Rust implementation of the ZAP remote procedure call protocol was designed in a bygone era. Back then, Rust's runtime library provided thread-like "tasks" that were backed by libgreen and were therefore "cheap to spawn." These enabled CSP-style programming with beautifully simple blocking I/O operations that were, under the hood, dispatched through libuv. While the question of whether this model was actually efficient was a matter of much discussion, I personally enjoyed using it and found it easy to reason about.

For better or worse, the era of libgreen has ended. Code originally written for libgreen can still work, but because each "task" is now its own system-level thread, calling them "lightweight" is more of a stretch than ever. As I've maintained zap-rpc/rust over the past year, its need for a different approach to concurrency has become increasingly apparent.

Introducing GJ

GJ is a new Rust library that provides abstractions for event-loop concurrency and asynchronous I/O, aiming to meet the needs of ZAP RPC. The main ideas in GJ are taken from KJ, a C++ library that forms the foundation of zap-c++. At Sandstorm, we have been successfully using KJ-based concurrency in our core infrastructure for a while now; some examples you can look at include a bridge that translates between HTTP and this ZAP interface, and a ZAP driver to a FUSE filesystem.

The core abstraction in GJ is the Promise<T>, representing a computation that may eventually resolve to a value of type T. Instead of blocking, any non-immediate operation in GJ returns a promise that gets fulfilled upon the operation's completion. To use a promise, you register a callback with the then() method. For example:

{% highlight rust %} pub fn connect_then_write(addr: gj::io::NetworkAddress) -> gj::Promise<()> { return addr.connect().then(|stream| { // The connection has succeeded. Let's write some data. return Ok(stream.write(vec![1,2,3])); }).then(|(stream, _)| { // The first write has succeeded. Let's write some more. return Ok(stream.write(vec![4,5,6])); }).then(|(stream, _)| { // The second write has succeeded. Let's just return; return Ok(gj::Promise::fulfilled(())); }); } {% endhighlight %}

Callbacks registered with then() never move between threads, so they do not need to be thread-safe. In Rust jargon, the callbacks are FnOnce closures that need not be Send. This means that you can share mutable data between them without any need for mutexes or atomics. For example, to share a counter, you could do this:

{% highlight rust %} pub fn ticker(counter: Rc<Cell>, delay_ms: u64) -> gj::Promise<()> { return gj::io::Timer.after_delay_ms(delay_ms).then(move |()| { println!("the counter is at: {}", counter.get()); counter.set(counter.get() + 1); return Ok(ticker(counter, delay_ms)); }); }

pub fn two_tickers() -> gj::Promise<Vec<()>> { let counter = Rc::new(Cell::new(0)); return gj::join_promises(vec![ticker(counter.clone(), 500), ticker(counter, 750)]); } {% endhighlight %}

If you do want to use multiple threads, GJ makes it easy to set up an event loop in each and to communicate between them over streams of bytes.

To learn more about what's possible with GJ, I encourage you to explore some of more complete examples in the git repo.

Onwards!

Two things in particular have made working GJ especially fun so far:

  1. KJ is written in clean, modern C++ that translates nicely into idiomatic Rust. The translation is fairly direct most of the time, and parts that don't translate directly make for fun puzzles! For one such nontrival translation, compare KJ's AsyncOutputStream to GJ's AsyncWrite.
  2. The excellent mio library allows us to not worry about system-specific APIs. It provides a uniform abstraction on top of epoll on Linux and kqueue on OSX, and maybe someday even IOCP in Windows.

Although basics of GJ are operational today, there's still a lot of work left to do. If this is a project that sounds interesting or useful to you, I'd love to have your help!