Rust API
Rust 크레이트예요. 내 프로세스 안의 엔진, 서버 플러그인으로 쓰는 법, Rust 로 쓴 플러그인이 있어요. 생성된 rustdoc 은 아래처럼 cargo doc 으로 봐요.
Rust API 문서
zeropty, zeropty_c, zeropty_node 세 crate의 rustdoc이에요. crates.io에는 올리지 않아요(./build.sh로 빌드해요). 웹에 올린 주소는 없고, 저장소에서 만들어 열어요.
cargo doc --no-deps --workspace --opencrate 첫 페이지에 구조와 실행되는 예제가 있고, 공개 함수·타입마다 예제가 붙어 있어요(옛 경로로 남겨 둔 #[deprecated] 항목은 빼고). Rust 예제 200여 개는 doctest라서 cargo test --release --doc이 모두 실제로 돌려 봐요. 공개 항목은 모두 문서가 있고(세 crate 모두 #![warn(missing_docs)]), RUSTDOCFLAGS="-D warnings" cargo doc --no-deps --workspace가 경고 없이 끝나요. scripts/check.sh가 둘 다 확인해요(rustdoc은 --quick이 아닐 때).
어디서부터 읽을지:
| 하고 싶은 일 | rustdoc에서 볼 곳 |
|---|---|
| 내 프로세스에 엔진 넣기, 여러 스레드에서 명령 | embed::Embedded, Embedded::command, Embedded::with |
| 스레드 없이 호스트 이벤트 루프로 돌리기 | embed::Polled, Polled::fd, Polled::timeout_ms, Polled::process |
| 글자가 나올 때까지 폴링 없이 기다리기 | engine::Engine::wait_start, wait_check, wait_due_ms, wait_end |
| CLI와 소켓 클라이언트가 내 세션에 닿게 하기 | Embedded::listen, Polled::listen, server 모듈 |
| 실행 중인 세션을 다른 프로세스로 옮기기 | engine::handover::pull, push, Engine::export_session |
| 엔진만으로 루프 짜기, 내 fd도 같은 큐에서 기다리기 | engine::Engine, Engine::try_poll, Engine::watch, host::HostLoop |
| 내 터미널을 세션에 붙이기 | Engine::attach, viewer_events, zeropty::tty |
| 소켓 클라이언트를 직접 짜기 | protocol 모듈(Request, reply_line, ClientFrame, ServerFrame) |
| 엔진을 가짜 OS로 테스트하기 | engine::Backend, Poller, Clock, Programs, Engine::with_backend |
| 플러그인 만들기, 호스트에 붙이기 | plugin 모듈(plugin::api), Embedded::add_plugin, Polled::command_start |
| 터미널 에뮬레이터만 쓰기 | term::Term, Term::freeze, Term::export |
| C, Node에서 쓰기 | zeropty_c, zeropty_node crate 첫 페이지 |
내 프로세스 안에: 임베디드
서버 없이 엔진을 프로세스 안에 넣어요. 명령 형식과 결과는 소켓과 같아요. wait-pane만 예외로 함수 호출로는 안 돼요(위 참고). 세션은 이 프로세스가 살아 있는 동안만 살아요(프로세스가 끝나면 프로그램들은 EOF를 받아요). 남기고 싶으면 끝나기 전에 서버로 넘겨요(아래 push-sessions).
Node: import만 바꾸면 돼요. 예제: examples/node/embedded.mjs
import { zeropty, open } from './client/embedded.mjs' // './client/zeropty.mjs' 대신
await zeropty(['new-session', '-d', '-s', 'w', 'exec cat'])| 함수 | 하는 일 |
|---|---|
zeropty(argv, { cwd }), zeroptySync(argv, { cwd }) | 서버 클라이언트와 같은 호출, 같은 결과. send-keys p50 0.06~0.07ms, capture-pane 0.06~0.07ms(바쁜 세션 0.10~0.17ms) (측정) |
open({ history, sizeDir, polled }) | 첫 명령 전에 옵션을 주고 엔진을 시작해요(안 부르면 첫 명령이 기본값으로 시작해요) |
listen(소켓) | 서버 플러그인 |
C (또는 FFI가 되는 어떤 언어든): 헤더는 bindings/c/include/zeropty.h, 예제는 examples/c/hello.c예요.
#include "zeropty.h" // cc app.c -Idist -Ldist -lzeropty_c
zeropty *a = zeropty_open(NULL); // 또는 {"history": 2000, "size_dir": "/tmp"}
char *r = zeropty_command(a, "{\"argv\":[\"new-session\",\"-d\",\"-s\",\"x\",\"exec cat\"],\"cwd\":\"/\"}");
zeropty_free(r); // r은 {"code":0,"out":"","err":""}
zeropty_close(a);| 함수 | 하는 일 |
|---|---|
zeropty_open(options_json) | 엔진 스레드가 있는 엔진. 옵션은 NULL이나 {"history": 줄 수, "size_dir": "폴더"} |
zeropty_command(a, request_json) | 명령 하나. 답은 JSON 문자열이고 zeropty_free로 풀어요 |
zeropty_listen(a, path) | 소켓도 열기. 0, 실패하면 -1 |
zeropty_close(a) | 엔진을 멈춰요. 세션은 프로그램이 끝날 때까지 돌아요(먼저 kill-server를 보내면 끝나요) |
zeropty_open_polled, zeropty_fd, zeropty_timeout_ms, zeropty_process | 엔진 스레드 없이 호스트 루프로 돌리기(아래) |
zeropty_open의 명령은 어느 스레드에서 불러도 돼요. 화면 읽기, 키 보내기, 조회는 부른 스레드에서 바로 처리하고, 나머지는 엔진 스레드에 넘겨요.
Rust: crates.io에는 아직 없어서 git 의존성으로 넣어요. 같은 커밋에 머물고 싶으면 rev = "<커밋>"을 더해요.
# Cargo.toml
[dependencies]
zeropty = { git = "https://github.com/CreatiCoding/zeropty" }그리고 zeropty::embed::Embedded(엔진 스레드)나 zeropty::embed::Polled(호스트 루프)를 써요. API 문서는 이 저장소에서 cargo doc --no-deps --workspace --open, 예제는 cargo run --release --example embed, 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"]), "/");
}빌드에는 macOS와 Xcode Command Line Tools가 필요해요(zstd-sys가 C 코드를 컴파일해요). 터미널이 필요한 프로그램을 띄우는 shim은 crate에 들어 있지 않아요. 그럴 때는 이 저장소의 ./build.sh로 만든 dist/zeropty-tty를 써요.
polled: 엔진 스레드 없이 (Node 기본값, 대기 400개에서 0.74MB → 0.45~0.46MB). 호스트의 이벤트 루프가 엔진을 돌려요. 모든 호출은 그 루프 스레드에서 해요. Node에서는 libuv가 엔진 fd를 지켜봐서 따로 할 일이 없어요.
open({ polled: false }) // Node에서 엔진 스레드를 쓰려면 (worker 스레드에서 부를 때). 첫 명령 전에 불러요zeropty *a = zeropty_open_polled(NULL);
for (;;) { // 호스트 루프: 엔진 fd가 읽을 수 있거나 타임아웃이 지나면 zeropty_process
struct pollfd p = { zeropty_fd(a), POLLIN, 0 };
poll(&p, 1, (int)zeropty_timeout_ms(a));
zeropty_process(a);
}플러그인이 더한 명령은 답을 기다리는 동안 부른 스레드를 잡을 수 있어요(마감 기본 10초). 호스트 루프를 잡지 않으려면 zeropty_command_start로 시작하고 zeropty_process 뒤에 zeropty_call_poll로 답을 가져가요(Rust는 Polled::command_start, Node는 await zeropty(...)). 엔진 명령은 이 방식으로 불러도 바로 답이 들어 있어요. 자세한 건 PLUGINS.md에 있어요.
서버 플러그인: 내 프로그램이 소켓도 열기
listen(소켓)을 부르면 zeropty serve와 같은 서버가 내 프로그램 안에서 돌아요. 그러면 zeropty CLI, zeropty attach, zsh 함수, bash builtin, client/zeropty.mjs가 별도 서버 프로세스 없이 이 프로그램의 세션을 다뤄요. 연결은 엔진과 같은 루프에서 처리해요(polled면 호스트 루프, 엔진 스레드 방식이면 엔진 스레드).
import { zeropty, listen } from './client/embedded.mjs'
listen(`${process.env.HOME}/.zeropty/myapp.sock`) // 이미 다른 서버가 답하는 소켓이면 예외ZEROPTY_SOCK=~/.zeropty/myapp.sock zeropty capture-pane -p -t '=w:' # 다른 터미널에서
ZEROPTY_SOCK=~/.zeropty/myapp.sock zeropty attach -t wif (zeropty_listen(a, "/tmp/myapp.sock") != 0) { /* 못 열었어요 */ }- 소켓 경로는 103바이트 이하예요. 소켓은 0600, 새로 만든 폴더는 0700 권한이에요.
- 그 경로에서 이미 서버가 답하면
listen은 실패해요(Node는 예외, C는 -1). 아무도 답하지 않는 소켓 파일(끝난 서버가 남긴 것)은 넘겨받고, 소켓이 아닌 파일이 있으면 지우지 않고 실패해요. - 소켓에서 온
kill-server는 세션만 끝내요. 내 프로그램은 계속 돌고 소켓도 계속 열려 있어요(독립 서버는 프로세스가 끝나요). - 엔진을 닫으면(
zeropty_close, Rust의Embedded·Polled해제) 소켓 파일도 지워요. 그사이 다른 서버가 같은 경로를 차지했으면 그 소켓은 건드리지 않아요. - 세션은 여전히 내 프로그램과 같이 끝나요. 프로그램보다 오래 두려면 끝나기 전에 독립 서버로
push-sessions해요. - 엔진을 멈추거나 프로세스가 끝나도 소켓 파일은 남아요. 아무도 답하지 않는 소켓이라 다음
listen이 넘겨받아요. mstats는 독립 서버에만 있어요.
Rust 로 쓴 플러그인
플러그인은 zeropty::plugin만 써요(Reply, ok, fail도 여기서 다시 내보내요). 엔진 모듈 구조가 바뀌어도 플러그인 코드는 그대로예요.
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"], "/"); // 엔진 명령, 엔진 스레드에서 실행돼요
ok(format!("{} sessions\n", r.out.lines().count()))
}
}
let e = Embedded::start(Options::default());
e.add_plugin(Bell)?; // 이 줄이 돌아온 순간부터 이벤트를 받아요
e.command(&["bell-test".into()], "/"); // 소켓(listen)과 CLI에서도 똑같이 불려요Polled::add_plugin도 같아요. 플러그인 명령을 기다리는 동안에도 polled 엔진은 돌아가요. 호스트 스레드를 잡지 않으려면command_start를 써요(호스트에서 플러그인 명령 부르기).훅에서 zeropty 밖의 무언가(다른 프로세스, 네트워크)를 기다린다면
zeropty::plugin::reply_wait(host.deadline())까지만 기다리세요. 그러면 늦을 때 그 호출만 실패하고 플러그인은 감시에 잘리지 않아요(소켓 플러그인의 서버 쪽이 이렇게 해요).실행해 보기:
cargo run --release --example plugin_activity(세션마다 출력한 바이트 수를 세는activity명령). 인자로 소켓 경로를 주면 계속 떠 있어서ZEROPTY_SOCK=경로 zeropty activity로 불러 볼 수 있어요.자기 루프를 가진 호스트(
Engine을 직접 돌리는 프로그램)는host::HostLoop에 등록부를 서비스로 붙여요. 세 줄이고,src/cli/serve.rs가 이렇게 해요.rustlet plugins = Plugins::new(); plugins.attach(&engine)?; // 플러그인이 루프를 깨울 수 있게 (엔진 큐에 사용자 이벤트 하나) server.set_plugins(plugins.clone()); // 소켓: list-plugins, 플러그인 명령, plugin-attach let mut host = HostLoop::new(Some(server), Waiting::WithEngine); host.set_plugins(plugins.clone()); // 매 바퀴 서버 다음에 pump, 잠들 시간에 감시 상한 loop { host.turn(&mut engine, Source::Wait(None), Yield::Never, |_, _, _| {})?; }소켓 없이 쓰는 호스트는
Plugins::add로 붙이고, 플러그인 명령은Plugins::list와Embedded·Polled의command/command_start처럼 부르면 돼요.등록이 실패하면
PluginError로 이유를 구분해요. 예:Err(PluginError::CommandTaken { owner, .. })면 다른 이름으로 다시 시도하고,Err(PluginError::Api { supported, .. })면.api(supported)로 낮춰요. 나머지는Err(e) => eprintln!("{e}")처럼_갈래로 받아요.