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.
Plugins read the host's document through a shared reference across the
dynamic-library boundary, and the entity index hashed with foldhash's
per-process seed, so every handle lookup from the plugin missed. The
codec now uses a fixed hasher (opencadcodec f385f75; kernel 9841749,
graph e9c23ad).
The Python IPC suite also caught up with intended changes: ATTDEF
renders its tag, DXF now keeps the lock-position flag, and the modify
audit turns associative arrays off since it checks loose copies.
The rate-limiter test waits for the host's check instead of a fixed
sleep that raced on macOS, and ENV_LOCK no longer cascades a poisoned
lock into every later test.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Bug:
The typed ensure_plugin_state helper and the object-safe trait method
it wraps turned two recoverable conditions into process deaths. On the
in-process host, the helper downcast the stored state with
.expect("plugin state type mismatch for plugin_id"), so registering
state under one plugin_id as one type and later asking for another
(e.g. a reloaded plugin version changes the type while the per-tab
state map survives) panicked the host. On out-of-process hosts
(ipc/client.rs, ipc/v4/client.rs) and on NullHost, the trait method
itself was implemented as a bare panic!("ensure_plugin_state is not
supported for out-of-process plugins..."), so any out-of-process
plugin calling the documented helper crashed its own runner process -
even though the sibling plugin_state_any and plugin_state_any_mut
methods on those same implementations already degrade to None
gracefully. The helper also used .expect("init called once") on the
initialiser closure.
Reproduce:
1. Out-of-process: a plugin whose runner calls ensure_plugin_state
(&mut host, "my.id", ...).
2. Type mismatch: in an in-process plugin, ensure state under one id
as u32, then ensure a different type under the same id.
3. Before: step 1 kills the plugin runner at the trait panic; step 2
kills the host at the downcast expect. After: both get None, keep
running, and existing state is untouched.
Fix:
Give the trait method a failure channel: ensure_plugin_state_any now
returns Option<&mut (dyn Any + Send + Sync)>, mirroring the sibling
plugin_state_any_mut on the same implementations, and the typed helper
returns Option<&mut T> (any?.downcast_mut). The panicking
implementations (ipc v3, ipc v4, NullHost, and the three never-called
test dummies) return None; the in-process app host wraps its real
storage in Some with unchanged insert-or-return semantics. The init
closure is now guarded by take() with a unit-Any fallback, so even an
implementation invoking it twice fails the typed downcast instead of
panicking. Object safety, argument types and vtable slot are
unchanged; external plugin sources get a compile error at the helper
and adopt Option handling on rebuild. Regression tests use catch_unwind
so they compile against both signatures: one per panic site (out-of-
process, NullHost, type mismatch), each additionally asserting the
None result and that pre-existing state survived.
The codec and kernel repositories and crates are renamed opencadcodec and
opencadkernel. Inside OCS they are depended on under short keys, so code
reads `codec::`, `kernel::` and the new `graph::` (opencadgraph, the node
graph engine). File-format and protocol spellings stay as they were: the
ACIS product id `acadrust`, the ACADRUST_* switches and the plugin
version keys `acadrust_version` / `acadrust_source`.
A button left of MCP on the command line opens a translucent node graph
over the viewport, with a palette of object, input, math, logic, list,
text and geometry nodes added by click or drag. An object node owns
drawing entities and its rows are the Properties panel's own rows for
them: the single-object page is extracted from refresh_properties as
entity_property_sections, values use the control protocol's
property_json, and writes go through set_property_value with a property
target override, so the panel, MCP and the graph share one read/write
path. A list reaching an object row makes one entity per element. Each
graph action is one undo step; the canvas pans and zooms.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Collapse the triplicated send/timeout/deadline/nested-request loop in interactive_event, get_prompt and needs_entity_pick into PluginProcess::call_simple. Timeout floors, error strings and UnexpectedResponse behavior preserved exactly. Net: +64 / -72 in one file, no behavior change. Verified: cargo check clean, cargo test -p ocs_plugin_api --lib --features host (92 passed), clippy shows only pre-existing warnings.
Sync current main and companion codec changes before preserving unsupported frame rows, rendering in the entity plane, and sharing final layout metrics with placement preview.
PluginManager::broadcast_notification was fanning out host notifications to every loaded plugin. Only V4 plugin processes can receive host->plugin notifications; V2/V3 plugins correctly rejected them with notify_plugin requires V4 protocol.
Add PluginProcess::is_v4() and skip non-V4 plugins in broadcast_notification so V4 document snapshot notifications only go to V4-capable plugins. Fixes repeated console errors for Example, Land Survey and V2 Compat.
- Run Plugin Manager release fetches on OS threads so they execute in
parallel instead of serialising on the async executor.
- Reduce V4Connection shutdown poll from 3s to 100ms; the runner is killed
and reaped without blocking host shutdown.
- Add PluginManager::remove and PluginProcess::shutdown_and_wait so
uninstall can stop the runner before deleting files, fixing Windows
access-denied errors on plugin removal.
- Rebuild ribbon immediately after uninstall so the tab disappears.
A normal run dumped the whole host↔runner trace (spawn, handshake, every
per-command dispatch and request/response) to the terminal. Gate that
behind OCS_PLUGIN_VERBOSE and, in normal runs, print just one line per
plugin: `Loaded plugin: <name> (<id> <version>)`.
The chatty host-side `[plugin]` lines now go through a `vlog!` macro that
only fires when OCS_PLUGIN_VERBOSE is set. Runner-side logs were already
suppressed (the runner is spawned with stderr = null); the remaining
`[plugin]` lines in the IPC client are genuine error messages, left as-is.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The handshake verification used a raw blocking `recv`, the one unbounded
read in the spawn path: `accept` is guarded by `spawn_timeout` and every
host->runner `call` by `call_timeout`, but a process that won the accept
race and then sent nothing — or a runner that died mid-handshake — would
hang the host forever.
Route the handshake through the existing `recv_with_deadline` helper so
the first frame is bounded too (marking the process dead on timeout), and
reduce `verify_runner_handshake` to a pure token check on the received
message. Tests updated to match.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The call-timeout test asserted the real 10 s Dispatch floor, adding 10 s+
to every suite run. Add a cfg(test)-only OCS_PLUGIN_TEST_FLOOR_SECS seam
so the test fires at its 1 s base instead; production still enforces the
hard safety floors. Test now finishes in ~1 s.
Also document in mark_dead that the host-side socket is closed indirectly
by killing the child (which unblocks and drops the detached reader
thread's Stream), not by the take() in mark_dead itself.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Follow-ups after merging the out-of-process plugin isolation work:
- Convert the out-of-process interactive adapter's `on_point` /
`on_entity_pick` to `glam::DVec3`, matching the `CadCommand` trait,
which moved to f64 pick coordinates. The branch predated that change
and no longer compiled against the trait.
- Contain plugin constructor panics in `export_plugin!`: the constructor
runs across the C ABI boundary in `ocs_plugin_register`, where an
unwinding panic is undefined behavior. Catch it and return null, which
the loader already treats as a failed registration.
- Reap killed runner processes. The spawn-timeout and disconnect paths
called `child.kill()` without `wait()`, leaving a zombie on Unix until
the host exited. Route both — and `shutdown()` — through a shared
`reap()` that kills and waits on a detached thread.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>