feat(plugin-api): entity-class display declarations on the manifest (API v10)

Plugins can now declare the entity classes they identify via XDATA, and the
host resolves them to display-only type labels while the declaring plugin is
loaded.

- Append `BuiltinPlugin::entity_classes()` (a guarded trailing vtable slot,
  the same additive pattern as `on_load` in v5) returning a &'static
  `EntityClasses { app_id, class_prefix, labels }`. The static
  `PluginManifest` struct is deliberately untouched: older cdylibs share it
  by pointer and its layout is frozen ABI since v1, so adding a field would
  read past their allocation.
- `OwnedEntityClasses` travels on the registered manifest as
  `OwnedPluginManifest.entity_classes` with `#[serde(default)]`, so manifests
  serialized before the field keep decoding (older runners tolerate it
  both ways).
- The runner folds the declaration into the GetManifest registration only
  when the loaded cdylib's exported `ocs_plugin_api_version` symbol reports
  >= 10 — an older binary's vtable predates the slot, so it is never called;
  its entities simply keep their native type names.
- `PluginManager::entity_class_declarations` exposes the live set, filtered
  to alive, non-disabled plugins in load order. The registered manifests are
  the only source and nothing is cached: not installed, disabled,
  cap-excluded (`OCS_PLUGIN_MAX_API_VERSION`), failed at load, removed, or a
  dead process all contribute nothing.
- Embedded version info `api_version` follows the bump to 10;
  `API_VERSION_MIN_SUPPORTED` stays 3.

Tests: runner fold (v10 folds, pre-v10 skipped, `entity_classes()` panic is
contained and reported), manager filtering (disabled / dead / undeclaring
skipped; declarations disappear once the process dies — verified with a real
killed child), bincode registration round-trip, and older-manifest serde
default.
This commit is contained in:
Sebastian committed 2026-10-15 22:39:09 +02:00
1 parent 859070403c
commit 60eb7b086e
9 files changed
+502 -13

No files matched your search

+69
View File
@@ -1150,6 +1150,74 @@ fn generate_token() -> Result<String, PluginError> {
Ok(out)
}
/// In-crate test support: build a `PluginProcess` whose fields are otherwise
/// private to this module, so sibling test modules (`process::manager`) can
/// put one in a `PluginManager` without spawning a real runner.
#[cfg(all(test, feature = "host"))]
impl PluginProcess {
/// A process backed by a real sleeping child process and a local socket
/// pair, carrying the given manifest (no cdylib, no runner). The
/// runner-side peer socket is dropped immediately; nothing reads it.
pub(crate) fn fake(manifest: OwnedPluginManifest) -> Self {
use interprocess::local_socket::traits::Stream as StreamTrait;
static COUNTER: AtomicU64 = AtomicU64::new(0);
let name = format!(
"ocs_plugin_fake_process_test_{}_{}",
std::process::id(),
COUNTER.fetch_add(1, Ordering::Relaxed)
);
let name_ref = name
.clone()
.to_ns_name::<GenericNamespaced>()
.expect("valid name");
let listener = ListenerOptions::new()
.name(name_ref)
.create_sync()
.expect("listener");
let client_name = name.clone();
let client_thread: std::thread::JoinHandle<Stream> = std::thread::spawn(move || {
StreamTrait::connect(client_name.to_ns_name::<GenericNamespaced>().unwrap())
.expect("connect")
});
let host_stream = listener.accept().expect("accept");
drop(client_thread.join().expect("client thread"));
Self {
stream: Mutex::new(Some(host_stream)),
v4: None,
child: Mutex::new(Some(sleepy_test_child())),
id: manifest.id.clone(),
manifest,
ribbon: vec![],
io_lines: Mutex::new(None),
}
}
}
/// A child that lives ~30 s — long enough to read as "alive", short enough to
/// never hang a leaked test. Shared by `PluginProcess::fake` and this module's
/// own tests.
#[cfg(all(test, feature = "host"))]
fn sleepy_test_child() -> Child {
#[cfg(windows)]
{
std::process::Command::new("cmd")
.arg("/c")
.arg("ping -n 30 127.0.0.1")
.stdout(std::process::Stdio::null())
.spawn()
.expect("spawn sleep")
}
#[cfg(not(windows))]
{
std::process::Command::new("sleep")
.arg("30")
.spawn()
.expect("spawn sleep")
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -1352,6 +1420,7 @@ mod timeout_tests {
ribbon_order: 0,
xdata_apps: vec![],
command_prefixes: vec![],
entity_classes: None,
}
}