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:
cargo doc --no-deps --workspace --openEach 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 to | Where to look in rustdoc |
|---|---|
| Put the engine in my process, send commands from several threads | embed::Embedded, Embedded::command, Embedded::with |
| Run it from my host's event loop, with no thread | embed::Polled, Polled::fd, Polled::timeout_ms, Polled::process |
| Wait for text to appear without polling | engine::Engine::wait_start, wait_check, wait_due_ms, wait_end |
| Let the CLI and socket clients reach my sessions | Embedded::listen, Polled::listen, the server module |
| Move running sessions to another process | engine::handover::pull, push, Engine::export_session |
| Write a loop around the engine alone, wait on my own fds in the same queue | engine::Engine, Engine::try_poll, Engine::watch, host::HostLoop |
| Attach my terminal to a session | Engine::attach, viewer_events, zeropty::tty |
| Write a socket client myself | the protocol module (Request, reply_line, ClientFrame, ServerFrame) |
| Test the engine against a fake OS | engine::Backend, Poller, Clock, Programs, Engine::with_backend |
| Write a plugin, attach it to a host | the plugin module (plugin::api), Embedded::add_plugin, Polled::command_start |
| Use only the terminal emulator | term::Term, Term::freeze, Term::export |
| Use it from C or Node | the 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
import { zeropty, open } from './client/embedded.mjs' // instead of './client/zeropty.mjs'
await zeropty(['new-session', '-d', '-s', 'w', 'exec cat'])| Function | What 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.
#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);| Function | What 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_process | Run 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>".
# 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.
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.
open({ polled: false }) // to use an engine thread in Node (when calling from worker threads). Call before the first commandzeropty *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).
import { zeropty, listen } from './client/embedded.mjs'
listen(`${process.env.HOME}/.zeropty/myapp.sock`) // throws if another server already answers on this socketZEROPTY_SOCK=~/.zeropty/myapp.sock zeropty capture-pane -p -t '=w:' # from another terminal
ZEROPTY_SOCK=~/.zeropty/myapp.sock zeropty attach -t wif (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,
listenfails (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-serverfrom 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'sEmbedded/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-sessionsthem 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
listentakes it over. mstatsis 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.
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 CLIPolled::add_pluginworks the same way. The polled engine keeps running even while waiting for a plugin command. To avoid holding the host thread, usecommand_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(anactivitycommand that counts the bytes each session has output). Pass a socket path as an argument and it stays up, so you can call it withZEROPTY_SOCK=path zeropty activity.A host with its own loop (a program that drives
Enginedirectly) attaches the registry tohost::HostLoopas a service. It takes three lines, andsrc/cli/serve.rsdoes exactly this.rustlet 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 wayPlugins::listand thecommand/command_startofEmbeddedandPolleddo.When registration fails,
PluginErrortells you why. For example, onErr(PluginError::CommandTaken { owner, .. })retry with a different name, and onErr(PluginError::Api { supported, .. })lower the version with.api(supported). Handle the rest with a_arm, such asErr(e) => eprintln!("{e}").