Skip to content

Rust API ​

The Rust crate: the engine inside your process, the server-plugin variant, and plugins written in Rust. For the generated rustdoc, run cargo doc as below.

Rust API docs ​

The rustdoc of the three crates zeropty, zeropty_c and zeropty_node. They are not published on crates.io (macOS only, built with ./build.sh). There is no hosted copy; build and open it from the repository:

sh
cargo doc --no-deps --workspace --open

Each crate's front page has an overview and runnable examples, and every public function and type has an example (except the #[deprecated] items kept under their old paths). The 200 or so Rust examples are doctests, so cargo test --release --doc really runs them all. Every public item is documented (#![warn(missing_docs)] in all three crates), and RUSTDOCFLAGS="-D warnings" cargo doc --no-deps --workspace finishes without warnings. scripts/check.sh checks both (rustdoc when not run with --quick).

Where to start:

I want toWhere to look in rustdoc
Put the engine in my process, send commands from several threadsembed::Embedded, Embedded::command, Embedded::with
Run it from my host's event loop, with no threadembed::Polled, Polled::fd, Polled::timeout_ms, Polled::process
Wait for text to appear without pollingengine::Engine::wait_start, wait_check, wait_due_ms, wait_end
Let the CLI and socket clients reach my sessionsEmbedded::listen, Polled::listen, the server module
Move running sessions to another processengine::handover::pull, push, Engine::export_session
Write a loop around the engine alone, wait on my own fds in the same queueengine::Engine, Engine::try_poll, Engine::watch, host::HostLoop
Attach my terminal to a sessionEngine::attach, viewer_events, zeropty::tty
Write a socket client myselfthe protocol module (Request, reply_line, ClientFrame, ServerFrame)
Test the engine against a fake OSengine::Backend, Poller, Clock, Programs, Engine::with_backend
Write a plugin, attach it to a hostthe plugin module (plugin::api), Embedded::add_plugin, Polled::command_start
Use only the terminal emulatorterm::Term, Term::freeze, Term::export
Use it from C or Nodethe front pages of the zeropty_c and zeropty_node crates

Inside your process: embedded ​

Put the engine inside your process, with no server. The command format and results are the same as on the socket. The one exception is wait-pane, which is not available as a function call (see above). Sessions live only as long as this process does (when the process exits, the programs get EOF). To keep them, hand them to a server before exiting (push-sessions, below).

Node: just change the import. Example: examples/node/embedded.mjs

js
import { zeropty, open } from './client/embedded.mjs'   // instead of './client/zeropty.mjs'
await zeropty(['new-session', '-d', '-s', 'w', 'exec cat'])
FunctionWhat it does
zeropty(argv, { cwd }), zeroptySync(argv, { cwd })Same calls and same results as the server client. send-keys p50 0.06~0.07ms, capture-pane 0.06~0.07ms (busy session 0.10~0.17ms) (measurements)
open({ history, sizeDir, polled })Starts the engine with options before the first command (if you don't call it, the first command starts the engine with defaults)
listen(socket)Server plugin

C (or any language with an FFI): the header is bindings/c/include/zeropty.h, and the example is examples/c/hello.c.

c
#include "zeropty.h"   // cc app.c -Idist -Ldist -lzeropty_c
zeropty *a = zeropty_open(NULL);   // or {"history": 2000, "size_dir": "/tmp"}
char *r = zeropty_command(a, "{\"argv\":[\"new-session\",\"-d\",\"-s\",\"x\",\"exec cat\"],\"cwd\":\"/\"}");
zeropty_free(r);                // r is {"code":0,"out":"","err":""}
zeropty_close(a);
FunctionWhat it does
zeropty_open(options_json)An engine with its own engine thread. Options are NULL or {"history": lines, "size_dir": "dir"}
zeropty_command(a, request_json)One command. The answer is a JSON string; release it with zeropty_free
zeropty_listen(a, path)Also open a socket. Returns 0, or -1 on failure
zeropty_close(a)Stops the engine. Sessions keep running until their programs exit (send kill-server first to end them)
zeropty_open_polled, zeropty_fd, zeropty_timeout_ms, zeropty_processRun the engine from the host's loop, with no engine thread (below)

Commands on a zeropty_open engine may be called from any thread. Reading the screen, sending keys and queries are handled right on the calling thread; everything else is handed to the engine thread.

Rust: the crate is not on crates.io yet, so add it as a git dependency. To stay on one commit, add rev = "<commit>".

toml
# Cargo.toml
[dependencies]
zeropty = { git = "https://github.com/CreatiCoding/zeropty" }

Then use zeropty::embed::Embedded (engine thread) or zeropty::embed::Polled (host loop). For API docs, run cargo doc --no-deps --workspace --open in this repository; for examples, cargo run --release --example embed and cargo run --release --example polled.

rust
use zeropty::{embed::Embedded, engine::Options};

fn main() {
    let zp = Embedded::start(Options::default());
    let argv = |a: &[&str]| a.iter().map(|s| s.to_string()).collect::<Vec<_>>();
    let r = zp.command(&argv(&["new-session", "-d", "-s", "w", "exec cat"]), "/");
    assert_eq!(r.code, 0, "{}", r.err);
    zp.command(&argv(&["send-keys", "-t", "=w:", "-l", "hello"]), "/");
    print!("{}", zp.command(&argv(&["capture-pane", "-p", "-t", "=w:"]), "/").out);   // hello
    zp.command(&argv(&["kill-server"]), "/");
}

Building requires macOS and the Xcode Command Line Tools (zstd-sys compiles C code). The shim used to launch programs that need a terminal is not part of the crate; for that, use dist/zeropty-tty built with this repository's ./build.sh.

Polled: no engine thread (the Node default; 400 idle sessions go from 0.74MB to 0.45~0.46MB). The host's event loop drives the engine. Make every call from that loop's thread. In Node, libuv watches the engine fd, so there is nothing extra to do.

js
open({ polled: false })   // to use an engine thread in Node (when calling from worker threads). Call before the first command
c
zeropty *a = zeropty_open_polled(NULL);
for (;;) {   // host loop: call zeropty_process when the engine fd is readable or the timeout expires
  struct pollfd p = { zeropty_fd(a), POLLIN, 0 };
  poll(&p, 1, (int)zeropty_timeout_ms(a));
  zeropty_process(a);
}

Commands added by plugins can hold the calling thread while they wait for an answer (10-second deadline by default). To avoid holding the host loop, start them with zeropty_command_start and collect the answer with zeropty_call_poll after zeropty_process (Rust: Polled::command_start; Node: await zeropty(...)). Engine commands called this way already have their answer ready. Details are in PLUGINS.md.

Server plugin: your program opens the socket too ​

Calling listen(socket) runs the same server as zeropty serve inside your program. The zeropty CLI, zeropty attach, the zsh function, the bash builtin and client/zeropty.mjs can then work with this program's sessions without a separate server process. Connections are handled on the same loop as the engine (the host loop when polled, the engine thread otherwise).

js
import { zeropty, listen } from './client/embedded.mjs'
listen(`${process.env.HOME}/.zeropty/myapp.sock`)   // throws if another server already answers on this socket
sh
ZEROPTY_SOCK=~/.zeropty/myapp.sock zeropty capture-pane -p -t '=w:'   # from another terminal
ZEROPTY_SOCK=~/.zeropty/myapp.sock zeropty attach -t w
c
if (zeropty_listen(a, "/tmp/myapp.sock") != 0) { /* could not open it */ }
  • The socket path must be 103 bytes or less. The socket gets 0600 permissions, and newly created directories get 0700.
  • If a server already answers at that path, listen fails (Node throws, C returns -1). A socket file nobody answers on (left behind by a server that exited) is taken over; if a non-socket file is in the way, it fails without deleting it.
  • kill-server from the socket ends only the sessions. Your program keeps running and the socket stays open (a standalone server process exits).
  • Closing the engine (zeropty_close, or dropping Rust's Embedded/Polled) also removes the socket file. If another server has taken the same path in the meantime, its socket is left alone.
  • Sessions still end together with your program. To keep them beyond its lifetime, push-sessions them to a standalone server before exiting.
  • If the engine stops or the process exits, the socket file stays behind. Since nobody answers on it, the next listen takes it over.
  • mstats is only on a standalone server.

Plugins in Rust ​

Plugins use only zeropty::plugin (Reply, ok and fail are re-exported there too). Plugin code stays the same even if the engine's module layout changes.

rust
use zeropty::embed::Embedded;
use zeropty::engine::Options;
use zeropty::plugin::{ok, Access, Event, Events, Host, Manifest, Plugin, Reply};

struct Bell;

impl Plugin for Bell {
    fn manifest(&self) -> Manifest {
        Manifest::new("bell").events(Events::SESSION_EXITED).command("bell-test").access(Access::READ)
    }
    fn event(&mut self, event: &Event, _host: &Host) {
        if let Event::SessionExited { session, reason } = event {
            eprintln!("\x07{session} {}", reason.as_str());
        }
    }
    fn command(&mut self, _argv: &[String], _cwd: &str, host: &Host) -> Reply {
        let r = host.command(&["list-sessions"], "/");   // an engine command, run on the engine thread
        ok(format!("{} sessions\n", r.out.lines().count()))
    }
}

let e = Embedded::start(Options::default());
e.add_plugin(Bell)?;                        // events are delivered from the moment this line returns
e.command(&["bell-test".into()], "/");      // callable the same way from the socket (listen) and the CLI
  • Polled::add_plugin works the same way. The polled engine keeps running even while waiting for a plugin command. To avoid holding the host thread, use command_start (Calling plugin commands from the host).

  • If a hook waits on something outside zeropty (another process, the network), wait no longer than zeropty::plugin::reply_wait(host.deadline()). Then, if you run late, only that call fails and the watchdog does not cut off the plugin (the server side of socket plugins does exactly this).

  • Try it: cargo run --release --example plugin_activity (an activity command that counts the bytes each session has output). Pass a socket path as an argument and it stays up, so you can call it with ZEROPTY_SOCK=path zeropty activity.

  • A host with its own loop (a program that drives Engine directly) attaches the registry to host::HostLoop as a service. It takes three lines, and src/cli/serve.rs does exactly this.

    rust
    let plugins = Plugins::new();
    plugins.attach(&engine)?;                  // lets plugins wake the loop (one user event on the engine queue)
    server.set_plugins(plugins.clone());       // socket: list-plugins, plugin commands, plugin-attach
    let mut host = HostLoop::new(Some(server), Waiting::WithEngine);
    host.set_plugins(plugins.clone());         // pump after the server on every turn; watchdog cap on sleep time
    loop { host.turn(&mut engine, Source::Wait(None), Yield::Never, |_, _, _| {})?; }

    A host without a socket attaches plugins with Plugins::add, and calls plugin commands the way Plugins::list and the command/command_start of Embedded and Polled do.

  • When registration fails, PluginError tells you why. For example, on Err(PluginError::CommandTaken { owner, .. }) retry with a different name, and on Err(PluginError::Api { supported, .. }) lower the version with .api(supported). Handle the rest with a _ arm, such as Err(e) => eprintln!("{e}").

Released under the MIT or Apache-2.0 license.