Skip to content

플러그인 API ​

권한, 플러그인용 소켓 프로토콜, 이벤트, 명령, zeropty::plugin 의 Rust API 예요.

권한 (access) ​

플러그인이 Host::command(소켓은 id 명령, C는 zeropty_host_command)로 실행할 수 있는 명령은 플러그인이 등록할 때 밝힌 권한까지예요. 기본은 보기(read)뿐이에요.

권한Rust허용하는 명령
read (기본)Access::READcapture-pane, display-message, has-session, list-sessions, grep-panes, show-environment, stats, mem, list-plugins
inputAccess::INPUTsend-keys, paste-buffer, load-buffer
manageAccess::MANAGEnew-session, kill-session, rename-session, resize-*, set-environment, pipe-pane(프로그램을 실행해요), 그 밖에 위 목록에 없는 명령
callAccess::CALL다른 플러그인이 더한 명령
allAccess::ALL위 전부
  • 밝히는 곳: Rust Manifest::new("x").access(Access::INPUT), 소켓 plugin-attach ... -a input,call, C·라이브러리 매니페스트 "access":["input"], Node attachPlugin({ access: ['input'] }).
  • 권한 밖의 명령은 실행하지 않고 send-keys: plugin x lacks access input (...)로 답해요. list-plugins의 access=에 각 플러그인의 권한이 보여요.
  • 어떤 권한으로도 안 되는 것: kill-server, pull-sessions, push-sessions(세션 넘기기는 엔진 스레드를 잡아요), attach, plugin-attach, 자기 자신의 명령.
  • 권한은 플러그인이 무엇을 하려는지 드러내고 실수를 막는 경계예요. 같은 사용자로 도는 프로세스 밖 플러그인은 소켓에 일반 클라이언트로 붙을 수도 있어서, 보안 경계는 소켓(이 사용자만 접근)이에요.

1. 다른 프로세스에서: 소켓 프로토콜 (언어 무관) ​

zeropty serve든, listen을 부른 임베디드 엔진이든 소켓이 열려 있으면 붙을 수 있어요. 연결 하나가 플러그인 하나예요. 메시지는 모두 JSON 한 줄(끝에 \n)이에요.

1단계: 연결하기. ZEROPTY_SOCK(없으면 ~/.zeropty/mux.sock)에 유닉스 소켓으로 연결해요. CLI와 같은 규칙이에요.

2단계: 붙기. 첫 명령으로 plugin-attach를 보내요.

json
{"argv":["plugin-attach","-n","hello","-e","session-created,session-exited","-c","hello"],"cwd":"/"}
옵션뜻
-n 이름플러그인 이름 (필수). 영문자, 숫자, - _ . :, 64자까지
-e 목록받을 이벤트, 쉼표로 구분. sessions(생성·종료·이름 변경), all도 돼요. 없으면 이벤트를 받지 않아요
-c 목록더할 명령 이름, 쉼표로 구분. 내장 명령 이름과 다른 플러그인이 가진 이름은 쓸 수 없어요
-a 목록권한(위 표), 쉼표로 구분. 없으면 read
-v 버전이 플러그인이 쓰인 API 버전 (기본 1). zeropty보다 높으면 거절해요

답은 보통 명령 답과 같은 줄이에요. 성공하면 {"code":0,"out":"zeropty-plugin 1\n","err":""}이고, 이 답이 올 때는 이미 등록이 끝나 있어요. 실패하면 code 1과 이유예요(연결은 클라이언트로 남아서 고쳐서 다시 보내도 돼요).

3단계: 메시지 주고받기. 붙은 뒤로는 이 연결로 아래 메시지가 오가요. 첫 키가 메시지 종류예요.

방향메시지할 일
zeropty → 플러그인{"event":"session-created","session":"w"}이벤트 (아래 표)
zeropty → 플러그인{"call":7,"argv":["hello","you"],"cwd":"/"}더한 명령이 불렸어요. 같은 번호로 답해요
플러그인 → zeropty{"reply":7,"code":0,"out":"hello\n","err":""}call에 대한 답
플러그인 → zeropty{"id":3,"argv":["capture-pane","-p","-t","=w:"],"cwd":"/"}zeropty 명령 실행 (권한 안에서)
zeropty → 플러그인{"id":3,"code":0,"out":"...","err":""}그 명령의 답 (보낸 순서대로)
  • call에 답하는 중에 id 명령을 보내도 돼요(예: hello.py는 list-sessions로 세션 목록을 받아서 답해요). 그 답을 기다리는 동안 이벤트나 다른 call이 먼저 올 수 있으니, 받은 순서대로 쌓아 뒀다가 처리해요.
  • wait-pane은 플러그인에서 쓸 수 없어요. 기다리는 일은 이벤트로 해요.
  • 연결을 닫으면 플러그인이 등록부에서 빠지고, 그 명령은 다시 unknown command가 돼요. 서버가 끝나면 플러그인은 EOF를 받아요.
  • zeropty가 쓰는 소켓 쓰기에는 마감 시간(reply_wait, 마감의 8분의 7)이 있어요. 플러그인이 읽지 않고 버티면 그 플러그인만 실패 처리돼요.
  • call에는 마감의 8분의 7(기본 8.75초) 안에 답해요. 늦으면 그 호출만 plugin timed out으로 실패하고 늦은 reply는 버려져요. 플러그인은 계속 붙어 있어요. 오래 걸리는 일이면 먼저 "시작했다"고 답하고 결과는 다른 명령으로 가져가게 만드세요.

Python, 표준 라이브러리만: examples/plugins/hello.py가 프로토콜 전체예요(68줄).

sh
python3 examples/plugins/hello.py &
zeropty hello you

Node: client/plugin.mjs의 attachPlugin이 연결, 줄 나누기, 번호 맞추기를 해 줘요.

js
import { attachPlugin } from './client/plugin.mjs'
const plugin = await attachPlugin({
  name: 'notes',
  events: ['session-exited'],
  access: [],                       // 보기만 해요. 키 입력이 필요하면 ['input']
  onEvent: (ev) => console.log(`${ev.session} ${ev.reason}`),
  commands: {
    // 문자열을 돌려주면 출력, { code, out, err }도 돼요. 예외는 code 1과 메시지가 돼요.
    'notes-count': async (argv, { plugin }) => {
      const { out } = await plugin.command(['list-sessions', '-F', '#{session_name}'])
      return `${out.split('\n').filter(Boolean).length}\n`
    },
  },
})

examples/plugins/notify.mjs는 세션 생성·종료 기록(zeropty notify-log)과 조용한 세션 찾기(zeropty notify-idle 10)를 더하고, --desktop이면 세션이 끝날 때 macOS 알림을 띄워요.

이벤트 ​

이름 (-e)Rust필드언제
session-createdEvent::SessionCreatedsessionnew-session, 다른 서버에서 넘겨받았을 때
session-exitedEvent::SessionExitedsession, reason: exited · killed · handed-over프로그램이 끝났을 때(kill-server도 exited), kill-session, 다른 서버로 넘겼을 때
session-renamedEvent::SessionRenamedsession(새 이름), fromrename-session
outputEvent::Outputsession, data(소켓·C: base64)프로그램이 출력한 바이트를 읽을 때마다. 화면과 같이 모아 읽어서 조용한 세션은 ~300ms 단위예요. 이벤트 하나는 64KB까지라서 한꺼번에 많이 읽으면 여러 이벤트로 와요. UTF-8 문자가 두 이벤트에 나뉠 수 있어요
activityEvent::Activitysession출력을 읽어 화면이 바뀔 때마다. 바이트를 복사하지 않아서 output보다 싸요
sessionsEvents::SESSIONS위 세 생애 이벤트
allEvents::ALL전부

output을 원하는 플러그인이 있을 때만 엔진이 읽은 바이트를 복사해요. 화면 변화만 알면 되면 activity를 쓰고, 필요할 때 capture-pane으로 읽어요.

  • output은 손실이 있을 수 있어요. 메모리 예산(엔진이 넘김 한 번에 16MB, 플러그인 큐마다 8MB) 때문에, 제때 읽는 플러그인도 한꺼번에 아주 큰 출력(수십 MB 이상)이 오면 일부를 잃어요. 출력 전체가 빠짐없이 필요하면 플러그인 대신 pipe-pane(세션 출력을 파일이나 명령으로 그대로 보내요)을 써요.
  • dropped=는 하나의 합계예요. 큐가 차서(1024개나 출력 8MB) 버린 이벤트, 엔진이 16MB 예산을 넘어 버린 출력, 엔진 쪽 큐가 넘쳐 버린 이벤트를 모두 같은 숫자에 더해요. 이유별로 나눠 세지는 않아요.

명령 ​

  • list-plugins: 플러그인마다 한 줄. 이름: 상태 (api N) events=... commands=... access=... dropped=N. 소켓, CLI, 임베디드 호출 모두 돼요. 상태는 셋이에요.
    • running: 이벤트와 명령을 받고 있어요.
    • stopped: 빠지는 중이에요. 연결이 닫혔거나 remove됐는데 호스트 루프가 아직 목록에서 지우지 않은 잠깐 동안 보여요. 명령은 이미 가지 않아요.
    • failed (이유): 패닉(panicked in 훅: 메시지), 마감 시간을 넘긴 훅(stuck in a hook for more than N ms), 연결 오류(connection: ...). 목록에 남아서 이유를 볼 수 있고, 같은 이름으로 다시 붙으면 자리를 내줘요. 실패한 항목은 최근 16개까지만 남기고 오래된 것부터 지워요(이름을 바꿔 가며 다시 붙는 프로그램이 등록부를 끝없이 늘리지 않도록).
  • plugin-attach: 위 1번. 소켓 연결에서만 의미가 있어요(CLI로 보내면 답을 받고 바로 끊겨서 플러그인도 바로 빠져요).
  • zeropty plugin-run 라이브러리.dylib: 위 2번. 서버가 끝나면 끝나요.

API 레퍼런스 (Rust, zeropty::plugin) ​

안정성 경계는 코드의 모듈·파일 경계와 같아요.

파일경로누가 써요안정성
src/plugin/api.rszeropty::plugin::api (= zeropty::plugin::*)플러그인 작성자안정 API (아래 버전 정책)
src/plugin/registry.rs, src/plugin/hosting.rsPlugins, Call엔진을 넣는 호스트공개 메서드는 안정 API
src/host.rsHostLoop::set_plugins, impl Service for Plugins자기 루프를 가진 호스트호스트 루프 API(zeropty::host)와 같아요
src/plugin/__private/{remote,native,launch}.rs, src/plugin/wire.rszeropty::plugin::__private (#[doc(hidden)], 문서에 나오지 않아요)zeropty 바이너리·C 바인딩·테스트불안정, semver 밖
src/engine/tap.rs(Event, ExitReason만 zeropty::plugin으로 공개)엔진큐와 메서드는 크레이트 안에서만

안정 API. 플러그인 작성자와 엔진을 넣는 호스트가 쓰는 부분이에요. 같은 API_VERSION 안에서는 더하기만 해요(아래 버전 정책).

항목설명
API_VERSION이 인터페이스의 버전(지금 1). Manifest::api와 plugin-attach -v를 이것과 비교해요
trait Plugin: Send + 'staticmanifest()(필수), start, event, command, stop(기본 구현 있음). 훅 넷(start, event, command, stop)은 플러그인 스레드에서 한 번에 하나씩 불려요. manifest()는 등록할 때 한 번, 플러그인의 새 스레드에서 맨 먼저 불리고 add는 그 답을 매니페스트 마감(등록부 마감, 최대 1초)까지만 기다려요. 넘으면 PluginError::ManifestTimeout(ms)로 등록을 거절하고, 플러그인은 manifest()가 끝나는 대로 자기 스레드에서 버려져요. 그래서 멈춘 manifest()도 add를 부른 쪽을 최대 1초만 붙잡고 엔진은 붙잡지 않아요. 상수로 바로 만들고 느린 준비는 마감 시간 안에서 도는 start에서 해요. 패닉은 잡혀서 PluginError::ManifestPanicked가 돼요
Manifest::new(name).events(Events).command(name).access(Access).api(v)이름, 받을 이벤트, 더할 명령, 권한, 버전. events·access는 이미 있는 것에 더해요. 필드 events: Events, access: Access(늘 READ 포함)는 읽을 수 있어요. #[non_exhaustive]
Manifest::from_json(text), attach_argv()C·라이브러리의 JSON 매니페스트 읽기, 같은 매니페스트의 plugin-attach 명령
Event#[non_exhaustive] enum. kind() -> Events(그 이벤트 하나의 비트), name(), session(), to_json()
ExitReasonExited, Killed, HandedOver. as_str(). #[non_exhaustive]라 match에는 _ 갈래가 필요해요
PluginError등록·매니페스트 오류. #[non_exhaustive] enum: BadName, BadCommand, Api { api, supported }, UnknownEvent, UnknownAccess, BuiltinCommand, DuplicateCommand, NameTaken, CommandTaken { owner }, BadManifest, ManifestPanicked, ManifestTimeout(ms), Thread, Unsupported(플러그인을 돌리지 않는 프런트엔드). Display는 사람이 읽는 이유, String::from은 답의 err처럼 줄바꿈을 붙여요. std::error::Error 구현
Events받을 이벤트의 집합(타입이 있는 비트 플래그). 상수 Events::{SESSION_CREATED, SESSION_EXITED, SESSION_RENAMED, OUTPUT, ACTIVITY, SESSIONS, ALL}. Copy, Eq, Hash, Debug(Events(session-created | output)), Default(빈 집합), Display(소켓과 같은 쉼표 목록). 연산 |, |=, &, &=, -(차집합), !(아는 비트 안에서의 여집합). 메서드 empty(), all(), is_empty(), contains(other), intersects(other), union, difference, bits() -> u32, from_bits(u32) -> Option<Events>(모르는 비트가 있으면 None), from_bits_truncate, parse("a,b") -> Result<Events, PluginError>(FromStr도 같아요), names() -> String
Access권한의 집합. 상수 Access::{READ, INPUT, MANAGE, CALL, ALL}. 트레이트와 메서드는 Events와 같아요(parse("read,input"), names())
Host::command(argv, cwd) -> Reply엔진 명령을 권한 안에서 실행하고 답을 기다려요(마감 시간의 절반까지, 기다리는 시간은 훅 시간에 안 쳐요). 다른 플러그인의 명령은 CALL이 있어야 하고, 고리를 만드는 호출은 거절돼요
Host::deadline() -> Duration마감 시간(set_timeout, ZEROPTY_PLUGIN_TIMEOUT_MS). 훅이 자기 시간으로 이보다 오래 돌면 잘려요
reply_wait(deadline) -> Duration훅이 zeropty 밖을 기다려도 되는 시간: 마감에서 8분의 1(최소 20ms, 최대 절반)을 뺀 값. 감시보다 분명히 먼저 포기하게 해요
Host::name(), Host::detach(), Host::fail(why)이름, 스스로 빠지기, 실패로 표시하고 빠지기(그 순간부터 명령이 오지 않아요)
Reply, ok(out), fail(err), Reply::new(code, out, err)명령의 답(code, out, err 필드를 읽어요). 엔진 모듈 것을 다시 내보내요. CLI와 소켓의 답 그대로라 세 필드는 고정이에요(Reply { code, out, err }로 직접 만들어도 돼요)
is_reserved(name) -> bool엔진이나 호스트(서버, CLI)가 직접 답하는 이름이라 플러그인이 가질 수 없는지. 엔진 쪽은 엔진의 명령 표(COMMANDS, 별칭 포함)를 그대로 읽어서 엔진에 명령이 생기면 함께 예약돼요. 그 목록 자체는 API가 아니에요: zeropty가 명령을 더하면 늘어나니 myplugin-...처럼 자기 접두사를 쓰세요
Plugins등록부. add·add_boxed·check(Result<(), PluginError>), remove(없으면 false), names, list, set_timeout, timeout, set_shutdown_wait, shutdown, attach(&engine)(플러그인이 그 엔진의 루프를 깨우게 해요. 엔진을 받아서 큐 타입(지금은 kqueue)이 API에 드러나지 않아요), watchdog_ms()(플러그인이 있을 때 루프가 잠들 수 있는 최대 시간). host::Service를 구현해요. 공개 메서드는 모두 안정 API예요
HostLoop::set_plugins(plugins), HostLoop::plugins()호스트 루프가 등록부를 서비스로 돌리게 해요(zeropty serve, Embedded, Polled가 이걸 써요)
Embedded::add_plugin·add_plugin_boxed, Embedded::plugins(), Polled::add_plugin·add_plugin_boxed, Polled::plugins()임베디드 엔진에 붙이기(Result<(), PluginError>). 바인딩이 쓰는 embed::Frontend에도 add_plugin_boxed, command_start가 있어요. 둘 다 기본 구현이 있어서 플러그인 이전에 Frontend를 구현한 코드도 그대로 컴파일돼요(add_plugin_boxed 기본값은 Err(PluginError::Unsupported), command_start 기본값은 command를 바로 답해요)
Embedded::command_start, Polled::command_start -> Call기다리지 않고 명령 부르기. 엔진 명령은 답이 든 Call로 와요
Call진행 중인 명령. poll() -> Option<Reply>(답을 한 번 줘요), wait() -> Reply, due_ms(), answered(), Call::ready(reply). 답 전에 drop하면 아직 시작 안 한 명령은 실행되지 않아요. Polled::timeout_ms()는 아직 답을 기다리는 Call의 마감만 세요. 답을 받았거나 drop한 Call은 루프를 깨우지 않아요

zeropty::plugin::__private (안정 API 아님). zeropty 자신의 바이너리(plugin-run, 플러그인 디렉터리)·C 바인딩·테스트가 쓰는 부분을 이 모듈 하나에 모았어요. 불안정하고 semver 밖이에요: 버전을 올리지 않고도 언제든 바뀌거나 없어질 수 있어요. 안정 플러그인 API는 api(= zeropty::plugin::*), Plugins, Call뿐이고, 플러그인과 호스트는 __private를 쓰지 않아요. serde의 __private와 같은 관례예요: #[doc(hidden)]이라 API 문서에 나오지 않고, 안정 API의 어떤 항목도 이 모듈의 타입을 받거나 돌려주지 않아요. 이름에 __가 붙은 경로를 직접 쓰는 것이 곧 불안정한 것을 쓰겠다는 표시예요.

cargo 기능으로 숨기지 않은 이유: 바이너리(src/main.rs)와 바인딩은 라이브러리와 다른 크레이트라서 공개된 경로가 필요하고, cargo에는 패키지가 자기 바이너리에만 기능을 켜는 방법이 없어요. 기능 뒤에 두면 바이너리를 다른 패키지로 옮기거나(cargo install --path .가 깨져요) required-features를 걸어야 해서(cargo install이 바이너리를 건너뛰어요), 설치 방법을 그대로 두는 쪽을 골랐어요. 그래서 바이너리와 그 테스트는 예전처럼 루트 패키지 zeropty에 있어요:

sh
cargo install --path .              # zeropty 바이너리 설치
cargo build --release -p zeropty    # target/release/zeropty
항목설명
remote (Remote, parse_attach)소켓 플러그인(plugin-attach)의 서버 쪽
launch::{Autostart, configured_dir, programs}플러그인 디렉터리(ZEROPTY_PLUGIN_DIR)
unsafe native::{load, run}, unsafe native::Library::from_parts, native::{Api, Def, ENTRY}플러그인 라이브러리 불러오기(zeropty_plugin_init), plugin-run. 라이브러리를 불러오면 그 코드가 실행되므로 unsafe예요(# Safety에 조건). 다른 언어에서는 C ABI(zeropty_load_plugin)나 zeropty plugin-run을 써요
call_json, reply_json, reply_from_json, base64프로토콜 구현용
set_engine_events, take_engine_events, has_engine_events등록부 없이 엔진 쪽 이벤트 큐를 보는 테스트용 함수

크레이트 밖에서는 보이지 않는 것: 엔진 쪽 큐(Engine::set_tap, take_tap, has_tap_events), 등록부의 pump·call·owns, 깨우기 이벤트 태그(PLUGIN_TAG)는 pub(crate)예요.

C ABI (bindings/c/include/zeropty.h): zeropty_add_plugin, zeropty_host_command, zeropty_load_plugin, 라이브러리 진입점 zeropty_plugin_init과 zeropty_plugin_api·zeropty_plugin_def, 기다리지 않는 호출 zeropty_command_start·zeropty_call_poll·zeropty_call_due_ms·zeropty_call_free. Node(zeropty.node): Engine.commandStart(argv, cwd)는 늘 Promise<Reply>를 돌려줘요. 엔진 명령이면 이미 풀린 Promise예요. Reply와 Promise가 섞이지 않아서 늘 await하면 돼요.

등록 규칙: 이름과 명령 이름은 영문자·숫자·- _ . :(64자까지, -로 시작 못 해요), 내장 명령(is_reserved가 참인 이름)과 실행 중인 다른 플러그인의 이름·명령은 쓸 수 없어요. 실패하거나 멈춘 플러그인의 이름은 다시 쓸 수 있어요(재연결).

동작 한계값: 플러그인 큐 1024개·출력 8MB(256개와 2MB 아래로 비면 출력 복사 재개), output 이벤트 하나 64KB, 엔진 쪽 큐 이벤트 16384개·출력 16MB(한 번 읽은 출력도 16MB까지 복사), 마감 시간 10초(Host::command는 5초, 소켓 플러그인의 call 답과 소켓 쓰기는 8.75초), 감시 주기 마감 시간의 4분의 1(10ms~1초), 종료 대기 1초, 남기는 실패 항목 16개.

버전 정책 ​

  • 플러그인은 자기가 쓰인 버전을 밝혀요(Manifest::api, -v, 매니페스트의 api). zeropty보다 새 버전이면 등록을 거절해요. 옛 버전은 계속 받아요.
  • 같은 버전 안에서는 더하기만 해요: 새 훅은 기본 구현을 달고 오고, Event, ExitReason, PluginError, Manifest는 #[non_exhaustive]라 새 이벤트·종료 이유·오류 종류·필드가 생겨도 기존 플러그인이 컴파일돼요(그래서 이 enum들을 match할 때는 _ 갈래를 둬요). Reply는 CLI·소켓의 답이자 임베더가 구조체 리터럴로 만드는 타입이라 code·out·err 세 필드로 고정이에요. 더 알려야 할 게 생기면 Reply에 필드를 더하지 않고 옆에 새 타입으로 더해요. 소켓 플러그인은 모르는 키와 모르는 event를 무시하면 돼요. C의 zeropty_plugin_api는 끝에만 필드를 더하고 size로 알려요.
  • 바이트 단위 호환이 필요한 것(메시지의 키 순서 event·call·reply·id가 맨 앞)은 테스트로 고정해 뒀어요.
  • zeropty::plugin::__private는 이 정책 밖이에요. zeropty 자신의 바이너리·바인딩·테스트용이라 semver를 따르지 않고, 버전을 올리지 않고도 바뀌거나 없어질 수 있어요. 쓰면 다음 릴리스에서 깨질 수 있어요. 컴파일러가 막지는 않아서(serde의 __private와 같은 관례), 이 이름을 쓰는 코드는 스스로 그 위험을 지는 거예요.
  • 이벤트와 권한은 날 u32 비트가 아니라 Events·Access 타입이에요. 이 타입은 이 버전이 아는 비트만 담아서(from_bits는 모르는 비트에 None을 돌려줘요) 모르는 비트를 가진 매니페스트를 만들 수 없어요. 그래서 그런 매니페스트를 거절하던 PluginError::UnknownEventBits·UnknownAccessBits는 첫 릴리스 전에 없앴어요(#[non_exhaustive] enum이라 이 변경으로 깨지는 match는 없어요). 소켓·C·JSON의 형식(이름, 쉼표 목록)은 그대로예요.
  • C·dylib의 JSON 매니페스트(Manifest::from_json)는 엄격해요. 다섯 키(name, events, commands, access, api) 밖의 키, 문자열이 아닌 목록 값, 타입이 틀린 값은 등록 오류(bad manifest: ...)예요. "event"처럼 철자를 틀려서 이벤트 0개로 조용히 등록되는 일이 없게 하려는 거예요.

동작 모델 ​

작성자가 알아야 할 것의 규칙을 자세히 적었어요. 각 규칙을 확인하는 테스트는 테스트에 있어요. 등록부·서버·엔진이 어떻게 이어져 있는지는 플러그인 내부에 있어요.

스레드

  • 플러그인 코드는 엔진 스레드에서 돌지 않아요. 플러그인마다 전용 스레드가 하나 있고, 훅(start, event, command, stop)은 그 스레드에서 한 번에 하나씩 불려요. 훅은 블로킹해도 돼요.
  • 플러그인이 엔진에 시키는 명령(Host::command, 소켓은 id 명령, C는 zeropty_host_command)은 엔진 스레드에서 다른 명령과 똑같이 실행돼요.
  • 엔진 스레드는 플러그인 소켓에 블로킹하지 않아요. plugin-attach의 성공 답과 그 연결에 밀린 답은 플러그인 스레드가 보내고, 성공 답이 갈 때는 이미 등록이 끝나 있어요.

이벤트

  • 엔진은 어떤 플러그인도 원하지 않는 종류의 이벤트는 만들지 않아요.
  • 이벤트는 플러그인마다 큐(1024개)로 가요. 호스트 루프가 한 바퀴 돌 때(pump) try_send로 넣고, 큐가 차 있으면 그 이벤트는 버리고 list-plugins의 dropped=에 세요. 느린 플러그인은 이벤트를 잃을 뿐 엔진을 붙잡지 못해요.
  • 큐는 개수만이 아니라 바이트로도 막혀요. output 이벤트는 64KB 이하 조각으로 나뉘고, 플러그인 큐마다 출력 8MB까지만 들어가요(넘는 조각은 버리고 dropped=에 세요). 엔진도 한 번 읽은 출력을 16MB까지만 복사하고 나머지는 이벤트 하나를 버린 것으로 세요. 그래서 읽지 않는 플러그인이 있어도 서버가 더 쓰는 메모리는 이 예산들(합쳐서 수십 MB)을 넘지 않아요. 예전에는 이벤트 하나가 한 번에 읽은 출력 전부라서, 200MB yes와 읽지 않는 output 플러그인 하나로 서버 메모리가 141MB에서 656MB가 됐어요. a_burst_to_a_plugin_that_never_reads_stays_within_the_budgets가 서버의 최대 메모리(ri_lifetime_max_phys_footprint)를 플러그인 없을 때와 비교해 64MB 안으로 고정해요(이 기계에서 101MB 대 131MB). 이 측정은 기계 부하에 흔들릴 수 있어서 ZEROPTY_REQUIRE_ALL=1일 때만 돌고, 예산 자체는 결정적인 단위 테스트(engine::tap의 bursts_come_in_bounded_pieces, 등록부의 바이트 예산 테스트)가 늘 확인해요.
  • output(또는 activity)을 원하는 플러그인이 모두 큐가 가득 찼으면, 엔진은 출력을 복사하지도 이벤트를 만들지도 않고 dropped=만 세요. 그중 하나의 큐가 4분의 1(256개) 아래로 비면 다시 복사해요. 생애 이벤트(session-*)는 이렇게 끄지 않고 큐에서 하나씩 세요.
  • 엔진 쪽 큐에도 상한(이벤트 16384개, 출력 16MB)이 있어요. 호스트가 pump를 드물게 불러서 넘친 이벤트는 그 종류를 원한 플러그인의 dropped=에 더해요.

명령과 마감 시간

  • 플러그인 명령을 부른 쪽은 마감 시간 안에 답을 못 받으면 plugin timed out 오류를 받아요.
  • 소켓 서버는 플러그인 명령의 답을 비동기로 기다려요. 기다리는 건 그 연결뿐이고 다른 클라이언트는 그대로 처리돼요. 같은 연결의 다음 명령은 순서를 지키려고 그 뒤에 처리돼요.
  • 엔진을 넣은 호스트는 두 가지로 불러요. command는 답이나 마감까지 돌아오지 않아요. command_start(C zeropty_command_start, Node commandStart는 늘 Promise<Reply>)는 바로 돌아오고, 답은 나중에 Call::poll로 가져가요. 자세한 건 호스트에서 플러그인 명령 부르기에 있어요.
  • polled 엔진의 Polled::command는 기다리는 동안에도 process가 하는 일(세션 출력 읽기, listen한 소켓의 클라이언트, 플러그인이 시킨 엔진 명령, 감시)을 계속 해요. 그 사이에는 엔진의 kqueue에서 잠들고, 플러그인의 답·세션 출력·클라이언트·엔진 타이머가 깨워요. CPU를 돌리며 기다리지 않아요.
  • 소켓 플러그인과 plugin-run 라이브러리의 call 답은 reply_wait(마감)(마감의 8분의 7, 기본 8.75초)까지 기다려요. 넘으면 그 호출만 plugin timed out (no reply within N ms; a later reply is ignored)로 실패하고, 뒤늦게 온 reply는 버려요. 플러그인은 running으로 남아요. 플러그인에 한 줄(이벤트, 호출, 답)을 쓰는 데도 같은 시간을 통틀어 한 번만 줘요. 소켓 버퍼가 차서 그 안에 다 못 쓰면 그 플러그인은 failed (connection: not read for N ms)가 돼요. 마감보다 먼저 끝나므로 감시가 stuck in a hook으로 자르지 않아요. (write마다 시간 제한을 걸면 macOS는 몇 바이트라도 보낸 write를 성공으로 돌려줘서 write_all이 제한을 여러 번 기다렸고, 감시가 먼저 잘랐어요.)
  • Host::command는 마감 시간의 절반까지 기다려요. 넘으면 명령: no answer within N ms (half the plugin deadline)를 받아요. 남은 절반은 이 플러그인을 부른 쪽에 이유를 담아 답하는 데 써요.
  • 부른 쪽이 포기한 명령은 나중에 실행되지 않아요. 아직 시작하지 않은 명령은 버리고 오류 끝에 not run(플러그인 큐에서 기다리기만 했으면 busy; not run)을 붙여요. 이미 시작한 명령이면 started, may still take effect(플러그인 명령은 plugin timed out)를 붙여요. 시작과 포기는 원자 변수 하나로 한 번만 정해지므로, 실패로 알린 명령이 뒤에 실행되는 일은 없어요. 답을 받기 전에 버린 Call(C zeropty_call_free)도 같아요.
  • 고리를 만드는 호출은 바로 거절해요. A가 B의 명령을 기다리는 동안 B가 A의 명령을 부르면(call 권한) 명령: call cycle (b -> a -> b): plugin a is waiting for b를 받아요. 더 긴 고리도 같아요. 두 플러그인은 running으로 남아요.

실패 격리

  • 훅에서 패닉이 나면 그 플러그인만 failed (panicked in 훅: 메시지)가 되고, 부른 쪽은 오류를 받아요. 실패한 순간부터 명령이 그 플러그인으로 가지 않고, 같은 이름으로 다시 등록할 수 있어요.
  • 훅 하나가 자기 시간으로 마감 시간보다 오래 돌면 failed (stuck in a hook for more than N ms)가 되고 더는 이벤트와 명령을 받지 않아요. Rust 스레드는 밖에서 끝낼 수 없어서, 그 스레드는 훅에서 돌아올 때 끝나요.
  • 영영 돌아오지 않는 훅은 그 스레드를 끝까지 붙잡아요. 이건 피할 수 없어요(Rust에는 스레드를 밖에서 죽이는 방법이 없어요). 그래도 엔진은 영향을 받지 않아요. 감시가 그 플러그인을 failed로 표시하고 큐를 닫으니 이벤트도 명령도 더 가지 않고, 엔진은 원래 플러그인 큐나 스레드를 기다리는 일이 없어요(try_send만 해요). 그 플러그인 명령을 부른 쪽은 마감 시간에 오류를 받고, shutdown은 기다림(기본 1초)이 끝나면 그 스레드를 떼어 놓고 돌아와요. 남는 비용은 멈춘 스레드 하나와 그 플러그인이 쥔 메모리뿐이고, 프로세스가 끝날 때 사라져요. 플러그인을 다시 시작해야 하면 같은 이름으로 새로 등록할 수 있어요. 이게 걱정되는 플러그인은 프로세스 밖(소켓 플러그인, plugin-run)으로 돌리면 멈춘 프로세스를 OS가 끝낼 수 있어요.
  • manifest()도 플러그인 스레드에서 돌아요. add는 플러그인 스레드를 먼저 띄우고 거기서 manifest()를 불러서, 그 답을 매니페스트 마감(등록부 마감과 1초 중 짧은 쪽)까지만 기다려요. 넘으면 ManifestTimeout으로 거절하고 이름도 비워 둬요. 그 스레드는 manifest()가 돌아오면 플러그인을 버리고 끝나요(영영 안 돌아오면 위의 멈춘 훅처럼 스레드 하나가 남아요). 등록부 잠금은 manifest()의 답을 받은 뒤에만 잡으니, 기다리는 동안 다른 플러그인 등록·명령·엔진은 막히지 않아요.
  • 자기 시간만 세요. 훅 안에서 Host::command로 엔진이나 다른 플러그인을 기다리는 시간은 빼요. 느린 쪽은 자기 훅 시간으로 판정돼요.
  • 감시는 호스트 루프의 pump에서 돌아요. 플러그인이 하나라도 있으면 호스트 루프는 마감 시간의 4분의 1(10ms~1초)보다 오래 잠들지 않아요(Plugins::watchdog_ms를 HostLoop::timeout_ms가 셈에 넣어요. polled 엔진은 timeout_ms(), 서버와 Embedded는 루프가 잠들 시간에 들어 있어요). 그래서 할 일이 없는 엔진에서도 멈춘 훅은 마감 시간의 1.25배 안에 잘려요. 플러그인이 없으면 이 상한도 없어요.
  • 프로세스 밖 플러그인이 죽으면 서버는 연결이 끊긴 것만 봐요. 그 플러그인은 등록부에서 빠져요.

종료

  • Plugins::shutdown(그리고 Embedded·Polled의 drop)은 플러그인마다 stop을 보내고 스레드가 끝나기를 set_shutdown_wait(기본 1초, 0이면 기다리지 않음)까지 기다려요. 그때도 훅 안에 멈춰 있는 스레드는 떼어 놓고(detach) 돌아와요. 그 스레드는 훅에서 돌아오면 stop 없이 끝나요.
  • 프로세스 밖 플러그인은 서버가 끝나면 EOF를 받아요.

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