Plugin systems eventually run into a blunt architectural question: where should untrusted or expensive work actually execute?

Sometimes the answer is obvious. A report generator that wakes up once a minute can live in another process and pay the IPC cost without anyone noticing. A frame-processing plugin that has to inspect 30 images per second may need to sit in-process because every boundary crossing shows up in the latency budget. Real applications usually need both.

The mistake is not supporting both modes. The mistake is letting that choice leak into the plugin API.

Once that happens, "embedded" and "isolated" stop being runtime deployment choices and become two different products. Plugin authors now need to understand two lifecycles, two failure stories, and two sets of timing assumptions just to ship one module. This article describes the alternative: keep one plugin-facing contract, then let an adapter decide whether a particular plugin instance runs in-process or across a process boundary.

The problem

Isolation and latency pull in opposite directions.

You want isolation when a plugin is third-party, when it loads native libraries you do not fully trust, when it may be updated independently of the host, or when a crash must not take down the rest of the application. You want embedded execution when the plugin sits on a hot path and every call matters.

Those are not abstract tradeoffs. They show up quickly in concrete systems:

  • A PDF export plugin that receives a document snapshot and emits a file path can tolerate process startup, RPC, and supervision overhead.
  • A live image-analysis plugin that consumes a steady stream of frames cannot casually pay for serialization and context switches on every frame.

If your application has both kinds of workloads, it needs both execution shapes. If the architecture assumes only one, the second mode eventually gets bolted on under pressure, and the seam becomes part of the plugin surface.

At a high level, the architecture should look like this:

application
    |
    v
plugin runtime
    |
    +--> embedded adapter  --> in-process worker
    |
    `--> isolated adapter  --> worker process

The contract line should sit above that split, not through it.

In our case, the isolated adapter talks over shared-memory IPC. The important part is not the transport itself but that both execution modes still honor the same plugin contract.

Why the obvious approach fails

The obvious implementation is to build two loaders.

One loader calls directly into a plugin library in the host process. The other spins up a helper process and talks to it over RPC. That sounds reasonable until the differences start to accumulate. The embedded path gets one startup sequence, one threading model, and one shutdown story. The isolated path gets another. Then each path grows its own fixes, callbacks, recovery rules, and edge cases.

At that point the plugin author is effectively targeting two runtimes. A bug fixed in one mode can still exist in the other. Assumptions that were harmless in one mode become invalid in the other.

Consider a plugin contract that quietly depends on pointers:

void submit_frame(const frame_t* frame);

In embedded mode, that can work. The caller and callee live in the same address space, so the pointer has a meaning. Across a process boundary, it does not. To preserve the API, someone now has to invent hidden rules: copy the frame, map shared memory, pin a buffer, delay reuse, wake the remote worker, and somehow make all of that look like "just a pointer."

That is the core failure. The plugin surface has named the transport by accident.

The goal is not to pretend the two modes are identical. They are not. The goal is to identify what must stay identical from the plugin's point of view and move everything else into the adapter.

That boundary becomes easier to hold if you make the split explicit:

Must stay invariantMay differ by adapter
lifecycle statesthread model or process model
input and output semanticstransport, serialization, shared memory
payload lifetime rulesliveness checks and crash detection
failure classessupervision and restart mechanics
generation scopinglocal call path versus RPC path

What has to stay invariant

The plugin contract needs a small set of guarantees that remain true no matter where the code executes. In practice, a few invariants carry most of the weight.

A lifecycle state machine. The plugin should move through a short, named sequence of states such as loaded, started, running, stopping, and failed. Both execution modes need to tell the same story. If startup fails, the layer above should see "startup failed," not a vague mixture of partial initialization and best-effort cleanup. If execution is lost, that should be a distinct state transition, not a hidden retry.

Input and output semantics without transport. The plugin may declare that it accepts a frame, a block of samples, or a document snapshot. It may emit detections, metrics, or rendered output. What it must not depend on is how those values traveled.

A concrete example helps. Suppose the plugin contract says:

void on_frame(frame_view_t frame);
void emit_detection(const detection_t& detection);

In embedded mode, frame_view_t might be backed by a caller-owned memory range and delivered by direct call on a worker thread. In isolated mode, the adapter might deserialize metadata, map shared memory, and construct the same view object on the far side. The plugin still sees one on_frame(...) call with one lifetime rule. If plugin code needs to ask "am I in process mode or IPC mode?" then the contract is already wrong.

A clean split between worker, runtime, and application. The plugin owns its processing logic. The runtime owns execution, supervision, and delivery. The application owns the user session. That separation matters. It means a plugin worker can be restarted without tearing down the whole document session, and a paused session does not have to look like a plugin failure.

Failure classes that are named. "Could not start," "lost while running," and "failed during teardown" are different events. They drive different policy above the runtime. An application might retry a crashed isolated worker, surface a configuration error immediately, or quarantine plugins that fail to stop cleanly. That only works if the contract keeps those cases distinct.

Generation scoping. Every input and every output should belong to a specific lifetime of the worker. If generation 17 crashes and the runtime starts generation 18, stale outputs from generation 17 must not trickle into the new run. This sounds like bookkeeping until the first time a buffered "done" event from the old worker arrives after the restart and marks the new worker's job complete by mistake.

Those are the invariants. They are what the plugin is allowed to rely on. Almost everything else belongs below the contract line.

You can picture it as a narrow contract over two different execution shapes:

plugin-facing contract
----------------------
lifecycle
callbacks
payload rules
failure classes
generation ids
----------------------
embedded adapter   |   isolated adapter
direct calls       |   RPC + shared memory + supervision
same worker logic  |   same worker logic

What the adapter owns

The adapter is the part that knows which execution mode is active.

In embedded mode, the adapter may load a library, create the worker object, and call its entry points directly on a dedicated thread. In isolated mode, the adapter may launch a host process, negotiate shared memory, serialize commands, monitor liveness, and treat process exit as a runtime event. Those implementations are very different, and that is fine. Their job is not to look similar. Their job is to preserve the same plugin-facing contract.

That means the adapter owns details such as:

  • How inputs are transported.
  • Where plugin code runs.
  • How payload lifetime is honored.
  • How ordering is preserved.
  • How crashes or disconnects are detected.

The ownership split is compact enough to keep in a table:

LayerOwns
Applicationuser session, policy, when work should happen
Runtime contractlifecycle states, callback semantics, generation boundaries
Adaptertransport, execution mode, delivery mechanics, liveness observation
Plugin workerdomain logic for the actual task

Take a video-analysis plugin as an example. The application says, "here is frame N." The embedded adapter may hand the plugin a view into an existing ring buffer. The isolated adapter may copy metadata into a message, expose pixel data through shared memory, and wake the worker process. In both cases the plugin sees the same on_frame(...) call and the same rule: the frame view is valid for the duration promised by the contract, no longer and no less.

That is what adapter ownership buys. The plugin does not need to know whether it is one virtual call away from the application or one process boundary away.

Tradeoffs we accepted

This design is not free. It just puts the cost in a better place.

Most of the lifecycle machinery can be shared between adapters: loading, descriptor validation, state transitions, restart bookkeeping, and the public failure model. The asymmetry is narrower and more honest than the full system shape suggests.

Some differences remain fundamental.

An isolated worker can crash and be observed from the outside. The runtime can record that the process died, classify the event, and decide whether to restart it. An embedded worker that crashes hard can take the host application down with it. No adapter design removes that fact.

Transport costs also remain real. Moving a small control message across a pipe is one thing. Moving a 4K video frame 30 times a second is another. Even if shared memory keeps the copy count under control, the boundary still introduces synchronization and supervision costs that do not exist for a direct in-process call.

The point of the contract is not to erase those differences. It is to keep them from leaking upward into the plugin API.

For quick decisions, the tradeoff usually looks like this:

ModeStrengthCost
Embeddedlowest call overheadhost crash risk, shared address space
Isolatedcrash containment and supervisionIPC cost, serialization, extra coordination

Limits

This pattern gives you a cleaner boundary. It does not make either mode behave like the other.

Embedded mode does not contain crashes. If a plugin corrupts memory in-process, the application may go down with it. Choosing embedded execution for latency reasons means accepting that a plugin defect can become a host defect.

Isolated mode does not remove call cost. Cross-process delivery is always more expensive than an in-process call. For hot paths, that cost can dominate the design.

The contract does not replace testing in both modes. A plugin may obey the public API and still depend on accidental timing, ordering, or thread behavior that only shows up under one adapter. The contract makes those assumptions easier to name and test. It does not make them vanish.

It does not solve multi-plugin orchestration. One plugin consuming another plugin's output is a higher-level scheduling and ownership problem. The worker contract covers one plugin instance at a time.


The useful result is narrower, and more practical, than "one abstraction to hide everything." The application still knows that embedded execution is fast and isolated execution is safer. The runtime still knows how each mode actually works. The plugin author, however, writes against one lifecycle, one callback contract, and one set of payload rules.

That is the intended shape in one line:

one contract -> two adapters -> choose execution mode per plugin instance

That is the win. Execution mode stays a deployment decision, not a second API.