Skip to content

Rust API ​

Rust 크레이트예요. 내 프로세스 안의 엔진, 서버 플러그인으로 쓰는 법, Rust 로 쓴 플러그인이 있어요. 생성된 rustdoc 은 아래처럼 cargo doc 으로 봐요.

Rust API 문서 ​

zeropty, zeropty_c, zeropty_node 세 crate의 rustdoc이에요. crates.io에는 올리지 않아요(./build.sh로 빌드해요). 웹에 올린 주소는 없고, 저장소에서 만들어 열어요.

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

crate 첫 페이지에 구조와 실행되는 예제가 있고, 공개 함수·타입마다 예제가 붙어 있어요(옛 경로로 남겨 둔 #[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

js
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예요.

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 = "<커밋>"을 더해요.

toml
# 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예요.

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"]), "/");
}

빌드에는 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를 지켜봐서 따로 할 일이 없어요.

js
open({ polled: false })   // Node에서 엔진 스레드를 쓰려면 (worker 스레드에서 부를 때). 첫 명령 전에 불러요
c
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면 호스트 루프, 엔진 스레드 방식이면 엔진 스레드).

js
import { zeropty, listen } from './client/embedded.mjs'
listen(`${process.env.HOME}/.zeropty/myapp.sock`)   // 이미 다른 서버가 답하는 소켓이면 예외
sh
ZEROPTY_SOCK=~/.zeropty/myapp.sock zeropty capture-pane -p -t '=w:'   # 다른 터미널에서
ZEROPTY_SOCK=~/.zeropty/myapp.sock zeropty attach -t w
c
if (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도 여기서 다시 내보내요). 엔진 모듈 구조가 바뀌어도 플러그인 코드는 그대로예요.

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"], "/");   // 엔진 명령, 엔진 스레드에서 실행돼요
        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가 이렇게 해요.

    rust
    let 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}")처럼 _ 갈래로 받아요.

MIT 또는 Apache-2.0 라이선스로 배포해요.