Plugin API
Access rules, the socket protocol for plugins, events, commands and the Rust API of zeropty::plugin.
Access
The commands a plugin can run through Host::command (the id command over the socket, zeropty_host_command in C) are limited to the access it declared at registration. The default is view-only (read).
| Access | Rust | Allowed commands |
|---|---|---|
read (default) | Access::READ | capture-pane, display-message, has-session, list-sessions, grep-panes, show-environment, stats, mem, list-plugins |
input | Access::INPUT | send-keys, paste-buffer, load-buffer |
manage | Access::MANAGE | new-session, kill-session, rename-session, resize-*, set-environment, pipe-pane (runs a program), and any other command not listed above |
call | Access::CALL | Commands added by other plugins |
all | Access::ALL | All of the above |
- Where to declare it: Rust
Manifest::new("x").access(Access::INPUT), socketplugin-attach ... -a input,call, C and library manifests"access":["input"], NodeattachPlugin({ access: ['input'] }). - A command outside the plugin's access is not run and is answered with
send-keys: plugin x lacks access input (...).access=inlist-pluginsshows each plugin's access. - Not allowed under any access level:
kill-server,pull-sessions,push-sessions(handing sessions over holds the engine thread),attach,plugin-attach, and the plugin's own commands. - Access is a boundary that makes a plugin's intent visible and prevents mistakes. An out-of-process plugin running as the same user can also connect to the socket as an ordinary client, so the security boundary is the socket (accessible only to this user).
1. From another process: the socket protocol (any language)
You can attach whenever a socket is open, whether it is zeropty serve or an embedded engine that called listen. One connection is one plugin. Every message is a single line of JSON (terminated by \n).
Step 1: connect. Connect to ZEROPTY_SOCK (or ~/.zeropty/mux.sock if unset) as a Unix socket. These are the same rules as the CLI.
Step 2: attach. Send plugin-attach as the first command.
{"argv":["plugin-attach","-n","hello","-e","session-created,session-exited","-c","hello"],"cwd":"/"}| Option | Meaning |
|---|---|
-n name | Plugin name (required). Letters, digits, - _ . :, up to 64 characters |
-e list | Events to receive, comma-separated. sessions (create, exit, rename) and all also work. If omitted, no events are delivered |
-c list | Command names to add, comma-separated. Built-in command names and names owned by other plugins cannot be used |
-a list | Access levels (table above), comma-separated. Defaults to read |
-v version | The API version this plugin was written for (default 1). Rejected if higher than zeropty's |
The reply is a line like any ordinary command reply. On success it is {"code":0,"out":"zeropty-plugin 1\n","err":""}, and by the time this reply arrives, registration is already complete. On failure it is code 1 with the reason (the connection stays a client, so you can fix the request and send it again).
Step 3: exchange messages. After attaching, the following messages flow over this connection. The first key identifies the message type.
| Direction | Message | What to do |
|---|---|---|
| zeropty → plugin | {"event":"session-created","session":"w"} | An event (table below) |
| zeropty → plugin | {"call":7,"argv":["hello","you"],"cwd":"/"} | An added command was called. Reply with the same number |
| plugin → zeropty | {"reply":7,"code":0,"out":"hello\n","err":""} | Reply to a call |
| plugin → zeropty | {"id":3,"argv":["capture-pane","-p","-t","=w:"],"cwd":"/"} | Run a zeropty command (within access) |
| zeropty → plugin | {"id":3,"code":0,"out":"...","err":""} | Reply to that command (in the order sent) |
- You may send
idcommands while answering acall(for example,hello.pygets the session list withlist-sessionsand puts it in its reply). While you wait for that reply, events or othercalls may arrive first, so queue them in the order received and handle them afterwards. wait-panecannot be used from plugins. Use events for waiting.- Closing the connection removes the plugin from the registry, and its commands become
unknown commandagain. When the server exits, the plugin gets EOF. - Socket writes by zeropty have a deadline (
reply_wait, 7/8 of the deadline). If a plugin stops reading and holds out, only that plugin is marked failed. - Reply to a
callwithin 7/8 of the deadline (8.75 seconds by default). If you are late, only that call fails withplugin timed outand the latereplyis discarded. The plugin stays attached. For long-running work, reply "started" first and let the result be fetched with another command.
Python, standard library only: examples/plugins/hello.py is the whole protocol (68 lines).
python3 examples/plugins/hello.py &
zeropty hello youNode: attachPlugin in client/plugin.mjs handles connecting, line splitting and matching reply numbers.
import { attachPlugin } from './client/plugin.mjs'
const plugin = await attachPlugin({
name: 'notes',
events: ['session-exited'],
access: [], // view only. Use ['input'] if you need to send keys
onEvent: (ev) => console.log(`${ev.session} ${ev.reason}`),
commands: {
// Return a string for output, or { code, out, err }. An exception becomes code 1 with its message.
'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 adds a log of session creation and exit (zeropty notify-log) and a finder for quiet sessions (zeropty notify-idle 10), and with --desktop it shows a macOS notification when a session ends.
Events
Name (-e) | Rust | Fields | When |
|---|---|---|---|
session-created | Event::SessionCreated | session | new-session, or when taken over from another server |
session-exited | Event::SessionExited | session, reason: exited · killed · handed-over | When the program exits (kill-server is also exited), kill-session, or when handed over to another server |
session-renamed | Event::SessionRenamed | session (new name), from | rename-session |
output | Event::Output | session, data (socket and C: base64) | Every time bytes output by the program are read. They are read in batches together with the screen, so for quiet sessions this is in units of ~300ms. A single event holds at most 64KB, so a large read arrives as several events. A UTF-8 character can be split across two events |
activity | Event::Activity | session | Every time reading output changes the screen. It copies no bytes, so it is cheaper than output |
sessions | Events::SESSIONS | The three lifecycle events above | |
all | Events::ALL | Everything |
The engine copies the bytes it reads only when some plugin wants output. If you only need to know that the screen changed, use activity and read with capture-pane when needed.
outputcan be lossy. Because of the memory budgets (16MB per handoff from the engine, 8MB per plugin queue), even a plugin that reads promptly loses part of a very large burst of output (tens of MB or more). If you need every byte of output, usepipe-pane(which sends session output as-is to a file or command) instead of a plugin.dropped=is a single total. Events dropped because the queue was full (1024 events or 8MB of output), output dropped because the engine exceeded its 16MB budget, and events dropped because the engine-side queue overflowed are all added to the same number. They are not counted separately by reason.
Commands
list-plugins: one line per plugin,name: state (api N) events=... commands=... access=... dropped=N. Works over the socket, the CLI and embedded calls. There are three states.running: receiving events and commands.stopped: being removed. Shown briefly after the connection closed or the plugin wasremoved but before the host loop has taken it off the list. Commands are no longer routed to it.failed (reason): a panic (panicked in hook: message), a hook that exceeded the deadline (stuck in a hook for more than N ms), or a connection error (connection: ...). It stays on the list so you can see the reason, and gives up its slot when a plugin reattaches under the same name. Only the 16 most recent failed entries are kept, and the oldest are removed first (so a program that keeps reattaching under new names cannot grow the registry without bound).
plugin-attach: section 1 above. Meaningful only on a socket connection (sent from the CLI, the connection is closed right after the reply, so the plugin is removed immediately).zeropty plugin-run library.dylib: see Write a plugin. Exits when the server exits.
API reference (Rust, zeropty::plugin)
The stability boundaries match the module and file boundaries of the code.
| File | Path | Used by | Stability |
|---|---|---|---|
src/plugin/api.rs | zeropty::plugin::api (= zeropty::plugin::*) | Plugin authors | Stable API (versioning below) |
src/plugin/registry.rs, src/plugin/hosting.rs | Plugins, Call | Hosts that embed the engine | Public methods are stable API |
src/host.rs | HostLoop::set_plugins, impl Service for Plugins | Hosts with their own loop | Same as the host loop API (zeropty::host) |
src/plugin/__private/{remote,native,launch}.rs, src/plugin/wire.rs | zeropty::plugin::__private (#[doc(hidden)], not in the docs) | The zeropty binary, C bindings, tests | Unstable, outside semver |
src/engine/tap.rs | (only Event and ExitReason are public, via zeropty::plugin) | Engine | Queues and methods are crate-internal |
Stable API. The parts used by plugin authors and by hosts that embed the engine. Within the same API_VERSION, things are only added (see versioning below).
| Item | Description |
|---|---|
API_VERSION | The version of this interface (currently 1). Manifest::api and plugin-attach -v are compared against it |
trait Plugin: Send + 'static | manifest() (required), start, event, command, stop (with default implementations). The four hooks (start, event, command, stop) are called one at a time on the plugin thread. manifest() is called once at registration, first thing on the plugin's new thread, and add waits for its answer only until the manifest deadline (the registry deadline, at most 1 second). If it takes longer, registration is rejected with PluginError::ManifestTimeout(ms), and the plugin is dropped on its own thread as soon as manifest() finishes. So even a stuck manifest() holds the caller of add for at most 1 second, and never holds the engine. Build it directly from constants and do slow setup in start, which runs under the deadline. A panic is caught and becomes PluginError::ManifestPanicked |
Manifest::new(name).events(Events).command(name).access(Access).api(v) | Name, events to receive, commands to add, access, version. events and access add to what is already there. The fields events: Events and access: Access (which always includes READ) are readable. #[non_exhaustive] |
Manifest::from_json(text), attach_argv() | Parse the JSON manifest of C plugins and libraries; the plugin-attach command for the same manifest |
Event | #[non_exhaustive] enum. kind() -> Events (the bit for that single event), name(), session(), to_json() |
ExitReason | Exited, Killed, HandedOver. as_str(). #[non_exhaustive], so a match needs a _ arm |
PluginError | Registration and manifest errors. #[non_exhaustive] enum: BadName, BadCommand, Api { api, supported }, UnknownEvent, UnknownAccess, BuiltinCommand, DuplicateCommand, NameTaken, CommandTaken { owner }, BadManifest, ManifestPanicked, ManifestTimeout(ms), Thread, Unsupported (a frontend that does not run plugins). Display gives a human-readable reason; String::from appends a newline, like a reply's err. Implements std::error::Error |
Events | A set of events to receive (typed bit flags). Constants Events::{SESSION_CREATED, SESSION_EXITED, SESSION_RENAMED, OUTPUT, ACTIVITY, SESSIONS, ALL}. Copy, Eq, Hash, Debug (Events(session-created | output)), Default (the empty set), Display (the same comma-separated list as the socket). Operators |, |=, &, &=, - (difference), ! (complement within the known bits). Methods empty(), all(), is_empty(), contains(other), intersects(other), union, difference, bits() -> u32, from_bits(u32) -> Option<Events> (None if there are unknown bits), from_bits_truncate, parse("a,b") -> Result<Events, PluginError> (FromStr is the same), names() -> String |
Access | A set of access levels. Constants Access::{READ, INPUT, MANAGE, CALL, ALL}. Same traits and methods as Events (parse("read,input"), names()) |
Host::command(argv, cwd) -> Reply | Runs an engine command within the plugin's access and waits for the reply (up to half the deadline; time spent waiting does not count toward the hook's time). Commands of other plugins require CALL, and calls that would form a cycle are rejected |
Host::deadline() -> Duration | The deadline (set_timeout, ZEROPTY_PLUGIN_TIMEOUT_MS). A hook that runs longer than this on its own time is cut off |
reply_wait(deadline) -> Duration | How long a hook may wait on something outside zeropty: the deadline minus 1/8 of it (at least 20ms, at most half). Ensures it gives up clearly before the watchdog fires |
Host::name(), Host::detach(), Host::fail(why) | The name; detach itself; mark itself failed and detach (no commands arrive from that moment on) |
Reply, ok(out), fail(err), Reply::new(code, out, err) | A command's reply (read its code, out and err fields). Re-exported from the engine module. It is exactly the reply of the CLI and the socket, so the three fields are fixed (you may also build it directly as Reply { code, out, err }) |
is_reserved(name) -> bool | Whether a name is answered directly by the engine or the host (server, CLI), and so cannot be owned by a plugin. The engine side reads the engine's command table (COMMANDS, including aliases) directly, so new engine commands are reserved automatically. The list itself is not API: it grows as zeropty adds commands, so use your own prefix, such as myplugin-... |
Plugins | The registry. add, add_boxed, check (Result<(), PluginError>), remove (false if absent), names, list, set_timeout, timeout, set_shutdown_wait, shutdown, attach(&engine) (lets plugins wake that engine's loop; it takes the engine so that the queue type, currently kqueue, does not leak into the API), watchdog_ms() (the longest the loop may sleep while plugins exist). Implements host::Service. All public methods are stable API |
HostLoop::set_plugins(plugins), HostLoop::plugins() | Makes the host loop run the registry as a service (zeropty serve, Embedded and Polled use this) |
Embedded::add_plugin, add_plugin_boxed, Embedded::plugins(), Polled::add_plugin, add_plugin_boxed, Polled::plugins() | Attach to an embedded engine (Result<(), PluginError>). The embed::Frontend used by the bindings also has add_plugin_boxed and command_start. Both have default implementations, so code that implemented Frontend before plugins existed still compiles (the default add_plugin_boxed is Err(PluginError::Unsupported), and the default command_start answers immediately with command) |
Embedded::command_start, Polled::command_start -> Call | Call a command without waiting. Engine commands come back as a Call that holds the reply |
Call | A command in progress. poll() -> Option<Reply> (gives the reply once), wait() -> Reply, due_ms(), answered(), Call::ready(reply). Dropping it before the reply means a command not yet started is never run. Polled::timeout_ms() only counts the deadlines of Calls still waiting for a reply. A Call that has been answered or dropped does not wake the loop |
zeropty::plugin::__private (not stable API). The parts used by zeropty's own binary (plugin-run, the plugin directory), the C bindings and the tests are gathered in this one module. It is unstable and outside semver: it can change or disappear at any time without a version bump. The stable plugin API is only api (= zeropty::plugin::*), Plugins and Call, and plugins and hosts do not use __private. This follows the same convention as serde's __private: it is #[doc(hidden)] and does not appear in the API docs, and no item of the stable API takes or returns a type from this module. Using a path with __ in its name is itself the signal that you are using something unstable.
Why it is not hidden behind a cargo feature: the binary (src/main.rs) and the bindings are separate crates from the library, so they need a public path, and cargo has no way for a package to enable a feature only for its own binaries. Putting it behind a feature would mean either moving the binary to another package (which breaks cargo install --path .) or adding required-features (which makes cargo install skip the binary), so we chose to keep the install path unchanged. That is why the binary and its tests stay in the root package zeropty as before:
cargo install --path . # install the zeropty binary
cargo build --release -p zeropty # target/release/zeropty| Item | Description |
|---|---|
remote (Remote, parse_attach) | The server side of socket plugins (plugin-attach) |
launch::{Autostart, configured_dir, programs} | The plugin directory (ZEROPTY_PLUGIN_DIR) |
unsafe native::{load, run}, unsafe native::Library::from_parts, native::{Api, Def, ENTRY} | Loading plugin libraries (zeropty_plugin_init), plugin-run. Loading a library runs its code, hence unsafe (conditions under # Safety). From other languages, use the C ABI (zeropty_load_plugin) or zeropty plugin-run |
call_json, reply_json, reply_from_json, base64 | For implementing the protocol |
set_engine_events, take_engine_events, has_engine_events | Test functions that inspect the engine-side event queue without a registry |
Not visible outside the crate: the engine-side queue (Engine::set_tap, take_tap, has_tap_events), the registry's pump, call and owns, and the wakeup event tag (PLUGIN_TAG) are pub(crate).
C ABI (bindings/c/include/zeropty.h): zeropty_add_plugin, zeropty_host_command, zeropty_load_plugin, the library entry point zeropty_plugin_init with zeropty_plugin_api and zeropty_plugin_def, and the non-blocking calls zeropty_command_start, zeropty_call_poll, zeropty_call_due_ms, zeropty_call_free. Node (zeropty.node): Engine.commandStart(argv, cwd) always returns a Promise<Reply>. For engine commands, the Promise is already resolved. Reply and Promise are never mixed, so you can always just await.
Registration rules: names and command names use letters, digits and - _ . : (up to 64 characters, and may not start with -). Built-in commands (names for which is_reserved is true) and the names and commands of other running plugins cannot be used. Names of failed or stopped plugins can be reused (reconnecting).
Operating limits: plugin queue 1024 events and 8MB of output (output copying resumes once it drains below 256 events and 2MB), 64KB per output event, engine-side queue 16384 events and 16MB of output (a single read is also copied up to 16MB), deadline 10 seconds (Host::command 5 seconds; call replies and socket writes for socket plugins 8.75 seconds), watchdog period 1/4 of the deadline (10ms~1 second), shutdown wait 1 second, 16 failed entries kept.
Versioning
- A plugin declares the version it was written for (
Manifest::api,-v,apiin the manifest). If it is newer than zeropty's, registration is rejected. Older versions keep being accepted. - Within a version, things are only added: new hooks come with default implementations, and
Event,ExitReason,PluginErrorandManifestare#[non_exhaustive], so existing plugins still compile when new events, exit reasons, error kinds or fields appear (which is why amatchon these enums needs a_arm).Replyis both the reply of the CLI and the socket and a type that embedders build as a struct literal, so it is fixed at the three fieldscode,outanderr. If more needs to be reported, a new type is added alongside it rather than adding fields toReply. Socket plugins just need to ignore unknown keys and unknownevents. C'szeropty_plugin_apionly adds fields at the end and announces them throughsize. - Things that need byte-level compatibility (the message key order, with
event,call,replyoridfirst) are pinned by tests. zeropty::plugin::__privateis outside this policy. It exists for zeropty's own binary, bindings and tests, does not follow semver, and can change or disappear without a version bump. If you use it, the next release may break you. The compiler does not stop you (the same convention as serde's__private), so code that uses this name takes that risk on itself.- Events and access are the
EventsandAccesstypes, not rawu32bits. These types hold only the bits this version knows (from_bitsreturnsNonefor unknown bits), so a manifest with unknown bits cannot be built. That is whyPluginError::UnknownEventBitsandUnknownAccessBits, which used to reject such manifests, were removed before the first release (they are in a#[non_exhaustive]enum, so nomatchbreaks from this change). The socket, C and JSON formats (names, comma-separated lists) are unchanged. - The JSON manifest of C plugins and dylibs (
Manifest::from_json) is strict. Keys other than the five (name,events,commands,access,api), list values that are not strings, and values of the wrong type are registration errors (bad manifest: ...). This prevents a typo such as"event"from silently registering a plugin with zero events.
Behavior model
This section details the rules in What plugin authors need to know. The tests that verify each rule are listed in Tests. How the registry, the server and the engine are connected is described in Plugin internals.
Threads
- Plugin code never runs on the engine thread. Each plugin has one dedicated thread, and the hooks (
start,event,command,stop) are called one at a time on that thread. Hooks may block. - Commands a plugin asks the engine to run (
Host::command, theidcommand over the socket,zeropty_host_commandin C) run on the engine thread just like any other command. - The engine thread never blocks on a plugin socket. The success reply to
plugin-attachand any replies backed up on that connection are sent by the plugin thread, and by the time the success reply goes out, registration is already complete.
Events
- The engine does not create events of kinds that no plugin wants.
- Events go to a per-plugin queue (1024 events). They are pushed with
try_sendon each turn of the host loop (pump); if the queue is full, the event is dropped and counted indropped=oflist-plugins. A slow plugin only loses events; it cannot hold up the engine. - Queues are bounded by bytes as well as by count.
outputevents are split into pieces of 64KB or less, and each plugin queue holds at most 8MB of output (pieces beyond that are dropped and counted indropped=). The engine also copies at most 16MB of a single read, and counts the rest as one dropped event. So even with a plugin that never reads, the extra memory the server uses stays within these budgets (tens of MB combined). Previously a single event was everything read in one go, so 200MB ofyesplus oneoutputplugin that never read took server memory from 141MB to 656MB.a_burst_to_a_plugin_that_never_reads_stays_within_the_budgetspins the server's peak memory (ri_lifetime_max_phys_footprint) to within 64MB of the no-plugin case (101MB vs 131MB on this machine). This measurement can be affected by machine load, so it runs only withZEROPTY_REQUIRE_ALL=1; the budgets themselves are always checked by deterministic unit tests (bursts_come_in_bounded_piecesinengine::tap, and the registry's byte-budget tests). - When every plugin that wants
output(oractivity) has a full queue, the engine neither copies output nor creates events, and only countsdropped=. As soon as one of those queues drains below a quarter (256 events), copying resumes. Lifecycle events (session-*) are never switched off this way; they are counted one by one at the queue. - The engine-side queue also has a limit (16384 events, 16MB of output). Events that overflow because the host calls
pumprarely are added todropped=of the plugins that wanted that kind.
Commands and deadlines
- A caller of a plugin command that gets no reply within the deadline receives a
plugin timed outerror. - The socket server waits for plugin command replies asynchronously. Only that connection waits; other clients are served as usual. The next command on the same connection is processed after it, to preserve order.
- A host that embeds the engine can call in two ways.
commanddoes not return until the reply or the deadline.command_start(Czeropty_command_start; NodecommandStartalways returnsPromise<Reply>) returns immediately, and the reply is fetched later withCall::poll. Details are in Calling plugin commands from the host. - The polled engine's
Polled::commandkeeps doing the work ofprocesswhile it waits (reading session output, clients of alistened socket, engine commands issued by plugins, the watchdog). In between, it sleeps on the engine's kqueue and is woken by the plugin's reply, session output, clients or engine timers. It never spins the CPU while waiting. callreplies from socket plugins andplugin-runlibraries are awaited up toreply_wait(deadline)(7/8 of the deadline, 8.75 seconds by default). Past that, only that call fails withplugin timed out (no reply within N ms; a later reply is ignored), and areplythat arrives late is discarded. The plugin staysrunning. Writing one line (event, call, reply) to a plugin is also given this same time, once in total. If the socket buffer is full and the write cannot complete within that time, the plugin becomesfailed (connection: not read for N ms). Since this happens before the deadline, the watchdog never cuts it off asstuck in a hook. (With a time limit on eachwrite, macOS reports awritethat sent even a few bytes as successful, sowrite_allwaited out the limit several times and the watchdog cut in first.)Host::commandwaits up to half the deadline. Past that, it getscommand: no answer within N ms (half the plugin deadline). The remaining half is used to answer whoever called this plugin, with the reason.- A command the caller gave up on is never run later. A command not yet started is discarded, and
not runis appended to the error (busy; not runif it was only waiting in the plugin's queue). If it had already started,started, may still take effectis appended (plugin timed outfor plugin commands). Starting and giving up are decided exactly once through a single atomic variable, so a command reported as failed never runs afterwards. The same applies to aCalldropped before its reply (Czeropty_call_free). - Calls that would form a cycle are rejected immediately. If B calls one of A's commands while A is waiting on one of B's commands (with
callaccess), it getscommand: call cycle (b -> a -> b): plugin a is waiting for b. Longer cycles are handled the same way. Both plugins stayrunning.
Failure isolation
- If a hook panics, only that plugin becomes
failed (panicked in hook: message), and the caller gets an error. From the moment it fails, no more commands are routed to that plugin, and the same name can be registered again. - If a single hook runs longer than the deadline on its own time, the plugin becomes
failed (stuck in a hook for more than N ms)and receives no more events or commands. A Rust thread cannot be terminated from outside, so that thread ends when it returns from the hook. - A hook that never returns holds its thread forever. This is unavoidable (Rust has no way to kill a thread from outside). The engine is still unaffected. The watchdog marks the plugin
failedand closes its queue, so no more events or commands go to it, and the engine never waits on plugin queues or threads in the first place (it only doestry_send). Callers of that plugin's commands get an error at the deadline, andshutdowndetaches the thread and returns once its wait (1 second by default) is over. What remains is one stuck thread and whatever memory the plugin holds, which go away when the process exits. If the plugin needs to restart, it can be registered again under the same name. If this worries you for a given plugin, run it out of process (as a socket plugin or withplugin-run), where the OS can terminate a stuck process. manifest()also runs on the plugin thread.addstarts the plugin thread first, callsmanifest()there, and waits for the answer only until the manifest deadline (the shorter of the registry deadline and 1 second). Past that, it rejects withManifestTimeoutand leaves the name free. Whenmanifest()returns, that thread drops the plugin and exits (if it never returns, one thread is left behind, like the stuck hook above). The registry lock is taken only aftermanifest()has answered, so other plugin registrations, commands and the engine are not blocked while waiting.- Only the hook's own time counts. Time spent inside a hook waiting on the engine or another plugin through
Host::commandis excluded. The slow party is judged by its own hook time. - The watchdog runs in the host loop's
pump. While there is at least one plugin, the host loop never sleeps longer than a quarter of the deadline (10ms~1 second) (HostLoop::timeout_mstakesPlugins::watchdog_msinto account; for the polled engine this is intimeout_ms(), and for the server andEmbeddedit is in the loop's sleep time). So even on an idle engine, a stuck hook is cut off within 1.25 times the deadline. With no plugins, this cap does not exist either. - When an out-of-process plugin dies, the server just sees a closed connection. That plugin is removed from the registry.
Shutdown
Plugins::shutdown(and droppingEmbeddedorPolled) sendsstopto each plugin and waits for the threads to finish for up toset_shutdown_wait(1 second by default; 0 means no waiting). Threads still stuck in a hook at that point are detached, and it returns. Such a thread exits withoutstoponce it returns from the hook.- Out-of-process plugins get EOF when the server exits.