# Slopsmith plugin-sandbox — IPC + lifecycle design

Date: 2026-05-13
Companion to: `docs/VST-SANDBOX-DIAG.md`
Status: Windows v1 shipped; POSIX (macOS/Linux) IPC foundation + runtime +
editor wired and active (§11) — VST3 plugins route through the sandbox on all
three desktop platforms, editor included. The sandbox-child editor is a floating
top-level window on every platform (HWND / NSWindow + foreground activation
policy / X11 via JUCE 8's VST3 IRunLoop hosting). True parent-window embedding
(CARemoteLayer on macOS, XEmbed on Linux) remains out of scope.

## 1. Topology

```text
┌────────────────────────────────────┐         ┌──────────────────────────────┐
│ Slopsmith Desktop (Electron main)  │         │  slopsmith-vst-host.exe      │
│                                    │         │  (one per sandboxed plugin)  │
│  Node main process                 │         │                              │
│  └── slopsmith_audio.node          │         │  WinMain → main thread       │
│      └── SignalChain               │         │  └── JUCE MessageManager     │
│          └── SandboxedProcessor ◀┐ │         │      └── one AudioPlugin     │
│              ▲                   │  │         │          └── editor (HWND)  │
│              │                   │  │         │                              │
│              │ control:          │  │  pipe   │                              │
│              │ JSON over named   ├──┼─────────┤                              │
│              │ pipe (req/resp +  │  │         │                              │
│              │ events)           │  │         │                              │
│              │                   │  │         │                              │
│              │ audio: shared     │  │  shm    │                              │
│              │ memory ring +     ├──┼─────────┤                              │
│              │ Win32 events      │  │         │                              │
└────────────────────────────────────┘         └──────────────────────────────┘
```

One sandbox process per sandboxed plugin (simplest; matches the PoC). Pooling is a v2
optimisation, not v1.

In-process loading remains the default. A plugin only goes through the sandbox if it
matches a denylist (see §5).

## 2. Process spawn + handshake

```text
slopsmith-vst-host.exe
    --plugin-path "<absolute vst3 path>"
    --control-pipe "\\.\pipe\slopsmith-vst-<uuid>"
    --audio-shm    "Local\slopsmith-vst-<uuid>-audio"
    --audio-event-in  "Local\slopsmith-vst-<uuid>-evt-in"
    --audio-event-out "Local\slopsmith-vst-<uuid>-evt-out"
    --sample-rate 48000 --max-block 1024 --channels 2
```

Spawned via `CreateProcess`. The main process creates the pipe + shm + events first,
then spawns; the sandbox connects on startup. Watchdog: if no `ready` event arrives
within `kReadyTimeoutMs` (30 s — long enough for Qt-using plugins like GR6 to
spin up their QML engine on a cold cache), kill and report failure.

`<uuid>` is a v4 UUID generated per spawn so multiple sandboxes coexist cleanly.

## 3. Control channel — named pipe

Transport: `PIPE_TYPE_BYTE | PIPE_READMODE_BYTE`, bidirectional, overlapped I/O,
with an explicit `[u32 length-LE][body]` framing layer the channel applies on
top. (Message-mode was the original plan, but the sandbox's first `ready` frame
wasn't being delivered to the host I/O thread reliably; byte mode + length
prefixes is what shipped — see commit `2cb9ae9`.)

Framing per message: `[u32 length-LE] [json body]`. JSON is small and human-readable
for logging; the audio fast path is *not* on this channel.

Every request from main→sandbox carries a `requestId`. The sandbox echoes it on the
matching reply. Events the sandbox originates (parameter automation, log lines)
carry `requestId: null`.

### Main → sandbox

| `op` | Status | Payload | Reply |
|---|---|---|---|
| `prepare` | v1 | `{ sampleRate, blockSize }` | `{ ok, latencySamples, numInputs, numOutputs }` |
| `setBlockSize` | v2 | `{ blockSize }` | `{ ok }` — pause-guarded against the audio worker |
| `setParameter` | v1 | `{ index, value }` | `{ ok }` (omit reply if `fireAndForget: true`) |
| `getState` | v1 | `{}` | `{ stateBase64 }` — pause-guarded |
| `setState` | v1 | `{ stateBase64 }` | `{ ok }` — pause-guarded |
| `midiEvent` | removed v2 | (n/a) | MIDI now flows inline in the audio shm; sandbox keeps a deprecation-warning no-op handler for one release |
| `openEditor` | v1 | `{}` | `{ hwnd: "0x...", w, h }` |
| `closeEditor` | v1 | `{}` | `{ ok }` |
| `shutdown` | v1 | `{}` | `{ ok }` then sandbox exits 0 |
| `resizeEditor` | planned | `{ w, h }` | `{ ok }` |
| `listParameters` | planned | `{}` | `{ params: [{index,name,defaultValue,...}] }` |

Status reflects the current dispatcher in `src/vst-host/main.cpp`. "Planned"
ops are on the PR-body follow-up checklist. Pause-guarded ops are listed in
§4 "Audio-thread sync" below.

### Sandbox → main (events, `requestId: null`)

| `event` | Payload |
|---|---|
| `ready` | `{ pluginName, manufacturer, numParams, hasEditor, latencySamples }` (first message after pipe connect) |
| `parameterChanged` | `{ index, value }` — plugin moved its own knobs (automation, GUI) |
| `editorClosed` | `{ reason }` — user closed window via X, or plugin self-closed |
| `log` | `{ level, message }` — surface plugin stderr / JUCE asserts |
| `error` | `{ code, message }` — non-fatal recoverable error |
| `goodbye` | `{}` — last message before clean exit |

A broken pipe with no `goodbye` means the sandbox crashed.

## 4. Audio channel — shared memory + events

Audio is too latency-sensitive for JSON-on-pipes. One block at 48 k / 256 samples is
5.33 ms; we want round-trip overhead well under 1 ms.

Layout in `audio-shm` (single mapping):

```text
offset           size                                 contents
0                sizeof(Header)                       Header (indices, offsets, counters)
inputRingOffset  maxBlocks × maxBlockSamples × maxCh × 4 B   Ring A (host → sandbox, input audio)
outputRingOffset maxBlocks × maxBlockSamples × maxCh × 4 B   Ring B (sandbox → host, output audio)
midiQueueOffset  maxBlocks × sizeof(MidiQueue)        One MidiQueue per input slot (host → sandbox MIDI)
```

```c
struct Header {
    uint32_t magic;              // kAudioShmMagic
    uint32_t version;            // kProtocolVersion (= 3)
    uint32_t maxBlocks;          // typically 4
    uint32_t maxBlockSamples;    // capped at e.g. 1024
    uint32_t maxChannels;        // 2 for stereo
    uint32_t sampleRate;
    // Per-direction indices — input (host→sandbox) and output (sandbox→host)
    // each have their own writer/reader pair so the two directions advance
    // independently without sharing state.
    uint64_t inWriteIdx;   // host produces ring A
    uint64_t inReadIdx;    // sandbox consumes ring A
    uint64_t outWriteIdx;  // sandbox produces ring B
    uint64_t outReadIdx;   // host consumes ring B
    // diagnostic — direction-agnostic
    uint64_t xruns;
    uint64_t dropouts;
    uint64_t midiOverflows;     // events dropped by pushInputBlock for being SysEx-sized or past kMidiEventsPerSlot
    // Byte offsets into the mapping (computed at spawn time)
    uint64_t inputRingOffset;
    uint64_t outputRingOffset;
    uint64_t midiQueueOffset;   // base of MidiQueue[maxBlocks]
    uint64_t ringBytesPerSlot;
};
```

Indices are stored as plain `uint64_t` and accessed via `std::atomic_ref<uint64_t>`
at the call site (C++20). Don't `reinterpret_cast` to `std::atomic<uint64_t>*`
— that's not layout-guaranteed and the shm needs to stay trivially copyable.

Float32, planar (channel0 then channel1 — matches JUCE's `AudioBuffer<float>`).

### Inline MIDI

MIDI is bundled with the input audio block in a per-slot `MidiQueue`. The host
fills the upcoming slot's queue immediately before pushing the audio block; the
sandbox drains it immediately after popping the same slot, before calling
`plugin->processBlock`. The audio thread does no control-pipe I/O.

```c
struct MidiEvent {
    uint32_t frame;                       // sample offset within the block
    uint32_t size;                        // 1..kMidiEventMaxBytes
    uint8_t  bytes[kMidiEventMaxBytes];   // packed; SysEx > 4 B is dropped
};
struct MidiQueue {
    uint32_t count;                       // valid events for the upcoming block
    MidiEvent events[kMidiEventsPerSlot]; // 64 events @ ≤ 4 bytes each
};
```

Caps in `Protocol.h`: `kMidiEventMaxBytes = 4`, `kMidiEventsPerSlot = 64`.
Lossy-by-design: events past the cap (or larger than 4 bytes) bump the global
`AudioShmHeader.midiOverflows` counter and are dropped. Audio-thread safety is non-negotiable;
back-pressure on a real-time path would be the wrong trade-off. SysEx delivery,
if a real workload ever needs it, is a v3 op carried via the control channel
rather than the audio fast path.

### Per-block protocol

Host audio thread:
```text
1. Wait until (inWriteIdx - inReadIdx) < maxBlocks  (drop block + bump xruns if not)
2. Copy input PCM + any MIDI to Ring A[inWriteIdx % maxBlocks]
3. ++inWriteIdx (release)
4. SetEvent(audio-event-in)
5. WaitForSingleObject(audio-event-out, timeout = blockSize / sampleRate * 2)
6. Copy Ring B[outReadIdx % maxBlocks] into output buffer
7. ++outReadIdx (release)
```

Sandbox audio thread (or sandbox main thread's audio callback):
```text
1. WaitForSingleObject(audio-event-in, INFINITE)
2. Read input from Ring A[inReadIdx % maxBlocks]
3. processBlock(in, out) on the plugin
4. Write output to Ring B[outWriteIdx % maxBlocks]
5. ++inReadIdx (release); ++outWriteIdx (release); SetEvent(audio-event-out)
```

Both events are auto-reset. Worst-case added latency vs in-process: one block period
(~5 ms at 48k/256) due to the producer-consumer hop. Acceptable for guitar processing,
not great for live monitoring — same trade-off any sandboxed host has.

### Audio-thread sync for non-realtime ops

Several control ops mutate plugin or buffer state in ways that race the audio
thread's `processBlock` call: `kPrepare` and `kSetBlockSize` change the working
block size and re-enter the plugin's `prepareToPlay`; `kGetState` /
`kSetState` serialise/restore plugin internals. A v1-style "just touch it from
the control thread" implementation is a data race + buffer-overrun footgun.

v2 introduces a lightweight pause/drain/resume protocol on the sandbox side:

- HostState owns `audioPauseRequested` (atomic bool), `audioPausedAck`, and
  `audioResume` (`juce::WaitableEvent`s).
- The audio thread checks `audioPauseRequested` at the top of every loop
  iteration. When set, it signals `audioPausedAck` and blocks on `audioResume`.
  On resume it re-reads `blockSize` and `setSize`s its working buffer
  (capacity is pre-allocated at the spawn-time `maxBlockSamples` cap, so the
  resize is reallocation-free).
- The control thread wraps each non-realtime op in an `AudioPauseGuard`:
  set the flag, `signalSandboxWake()` to break the audio worker out of its
  `popInputBlock` wait without waiting the full 200 ms timeout, wait for the
  ack, perform the op, then signal resume in the guard's destructor.

Pause-guarded ops (sandbox dispatcher, `src/vst-host/main.cpp`):
`kPrepare`, `kSetBlockSize`, `kGetState`, `kSetState`. `kOpenEditor` and
`kCloseEditor` do not need the guard — they only mutate editor pointers via
`MessageManager::callAsync` and don't touch processor state, and JUCE's
`AudioProcessor::editorBeingDeleted` / VST3 `IPlugView::removed` are
contractually safe to call alongside `processBlock` (the same way every DAW
does).

## 5. Plugin selection (sandbox vs in-process)

Slopsmith maintains a list of plugin signatures that need the sandbox:

```jsonc
// %APPDATA%/Slopsmith/sandbox-list.json
{
    "needsSandbox": [
        { "match": "manufacturer", "value": "Native Instruments" },
        { "match": "vst3Uid",      "value": "4E545356-24696752-..." },
        { "match": "linksDll",     "value": "Qt5Core.dll" }
    ]
}
```

Resolution order on `loadVST`:
1. If plugin matches an entry → spawn sandbox.
2. Else → in-process (today's path).
3. If in-process load aborts the addon (`SIGABRT`, `STATUS_STACK_BUFFER_OVERRUN`, …),
   the watchdog promotes the plugin's UID into the list automatically, with a
   `learned-from-crash: true` flag, so the next load is sandboxed.

`linksDll` matching needs a quick prescan: open the vst3 file, walk its PE import
table. Cached in `%LOCALAPPDATA%\Slopsmith\plugin-deps.json`.

## 6. Window reparenting into Electron

1. Sandbox creates its editor in its own top-level window (the PoC's `EditorWindow`)
   — but with `WS_POPUP` style instead of an overlapped frame so it has no border.
2. Sandbox sends `editorOpened { hwnd, w, h }` to main.
3. Renderer asks Electron for its `BrowserWindow.getNativeWindowHandle()`. From the
   renderer, a placeholder `<div>` in the plugin chain's UI has a known position and
   size; the main process reads its bounds via IPC from the renderer.
4. Main process calls
   - `SetWindowLongPtrW(pluginHwnd, GWL_STYLE, (style | WS_CHILD) & ~WS_POPUP)`
   - `SetParent(pluginHwnd, electronHwnd)`
   - `SetWindowPos(pluginHwnd, NULL, placeholderX, placeholderY, w, h, SWP_NOZORDER | SWP_FRAMECHANGED)`
5. On placeholder resize/move (renderer → main IPC), main `SetWindowPos`'s and also
   sends `resizeEditor { w, h }` to the sandbox so the plugin re-lays out.
6. On `closeEditor`, main `SetParent(pluginHwnd, NULL)` first (un-embeds so the
   sandbox can DestroyWindow cleanly), then sends `closeEditor`.

Edge cases:
- DPI: the sandbox enables per-monitor DPI awareness; sandbox and Electron must agree.
  Use `SetThreadDpiAwarenessContext(DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2)`.
- Floating mode (user preference): skip steps 3–4; just send the HWND back informatively.
- Focus: WM_ACTIVATE inside the embedded child can confuse Electron's accelerator
  routing — likely needs a focus-shim subclassed window between Electron and the plugin.

## 7. Crash + restart

States that need to survive a sandbox crash:
- The plugin's parameter values (Slopsmith caches these as the user changes them, so
  free)
- The plugin's opaque state blob (Slopsmith calls `getState` after every "stable"
  change — patch load, preset switch — and caches it)
- The signal-chain position (already in `SignalChain`, not in the sandbox)

Restart flow:
1. Main detects broken pipe → marks slot as crashed; the SignalChain inserts a silent
   passthrough for the slot so audio keeps flowing.
2. UI shows "plugin crashed — retry" on the slot.
3. On retry (auto after 1 s for the first crash; manual for subsequent), spawn a
   fresh `slopsmith-vst-host.exe`.
4. After `ready`, replay: `prepare` → `setState` (last cached blob) → editor reopen
   if it was open.

Crash loop detection: if same plugin crashes 3× within 60 s, stop auto-retrying and
require a manual restart from the UI.

## 8. Build / repo layout

```text
slopsmith-desktop/
├── src/
│   ├── audio/
│   │   ├── NodeAddon.cpp                       (existing — selects sandbox vs in-process at LoadVST)
│   │   ├── VSTHost.cpp                         (existing — used both in-process and inside the sandbox)
│   │   └── Sandbox/
│   │       ├── Protocol.{h,cpp}                (wire protocol — ops, events, encoding)
│   │       ├── ControlChannel.{h,cpp}          (named-pipe request/response + sandbox-event dispatch)
│   │       ├── AudioChannel.{h,cpp}            (shared-memory ring + Win32 events for the audio path)
│   │       ├── SubprocessHandle.{h,cpp}        (sandbox process lifecycle: CreateProcessW, watcher, shutdown)
│   │       ├── SandboxedProcessor.{h,cpp}      (juce::AudioProcessor that forwards into the sandbox)
│   │       ├── SandboxFactory_win.cpp          (Windows: shouldSandbox() + tryLoadSandboxed())
│   │       └── SandboxFactory_stub.cpp         (non-Windows fallback — always returns nullptr)
│   └── vst-host/                               (sandbox subprocess)
│       ├── main.cpp                            (WinMain + JUCE main-thread message pump)
│       └── CMakeLists.txt                      (target: slopsmith-vst-host.exe)
└── CMakeLists.txt                              (top-level — adds the addon + host targets)
```

`slopsmith-vst-host.exe` ships in the Electron app's `resources/` and is launched from
`SandboxedProcessor::initialise()` via `SandboxFactory_win::resolveSandboxExe()`.

## 9. Out of scope for v1

- Cross-platform sandbox (macOS NSView/XPC, Linux X11-embed). v1 is Windows-only.
- AU/LV2 sandboxing. Most LV2 plugins are well-behaved in-process; reconsider if a
  specific plugin proves otherwise.
- Sandbox pooling (multiple plugins per process). Worth it for memory if a user
  loads 10+ NI plugins; not v1.
- Sample-accurate parameter automation across the IPC boundary. v1 sends parameter
  changes through the control channel with whatever latency that gives (~ms).
  v2 can co-opt the audio shm to embed parameter events per-block.
- Editor-side input redirection (keyboard for VST3 `IPlugViewContentScaleSupport`).
  Probably mostly just works through the reparented HWND.

## 10. Estimate

Wall clock for a single engineer, assuming the PoC's foundations:

| Piece | Effort |
|---|---|
| `slopsmith-vst-host.exe` skeleton (extend the PoC) | 1–2 d |
| Control channel (pipe + JSON + 12 message types) | 2–3 d |
| Audio channel (shm + events + ring) | 2–3 d |
| `SandboxedProcessor` glue inside the addon | 2 d |
| Detection list + denylist promotion | 1 d |
| Editor reparenting into Electron | 2–4 d (focus + DPI is fiddly) |
| Crash detection + restart + state cache | 2 d |
| QA pass on the top-10 NI plugins + iterating on weird behaviours | 3–5 d |
| **Total** | **~15–22 working days** |

Roughly 3–4 calendar weeks, in line with the diag report's original estimate, with
none of it spent fighting Qt.

## 11. POSIX backend (macOS / Linux) — IPC foundation

Tracked by issue #264 (macOS port). The IPC layer is split into a platform-neutral
core (`*_shared.cpp`: the lock-free ring algorithm, the request/reply/dispatch loop)
plus per-OS backends (`*_win.cpp` / `*_posix.cpp`) selected in CMake, with the private
`Impl` struct in `*Impl.h`. The Win32 primitives map to POSIX as follows — the choices
are driven by what is simultaneously *implemented on macOS*, *crash-safe*, and *tolerant
of coalesced wakeups*:

| Win32 | POSIX | Why |
|---|---|---|
| Named pipe + overlapped I/O + `WaitForMultipleObjects(io, stopEvent)` | `socketpair(AF_UNIX, SOCK_STREAM)` + `poll()` + self-pipe stop | UDS has identical stream/partial-read semantics, so the `[u32-LE][body]` framing carries over unchanged; the self-pipe is the manual-reset stopEvent (one never-drained byte). fd-passed, not named → dodges the macOS `sun_path` 104-char limit and leaves no socket file to leak. |
| Named file-mapping shm (`CreateFileMappingW` + name) | `shm_open(O_CREAT\|O_EXCL)` + `ftruncate` + **immediate `shm_unlink`**, fd-passed | Anonymous after unlink (fd keeps it alive) → no `/dev/shm` leak on crash, and the macOS 31-char shm-name limit is irrelevant past creation. `fstat().st_size` replaces `VirtualQuery` for the size-bounds check. |
| Named auto-reset events (`CreateEventW`/`SetEvent`/`WaitForSingleObject`) | **socketpair doorbell** (write 1 byte = signal; `poll` + drain = wait) | The *only* cross-process option that is (a) implemented on macOS — `sem_init` is ENOSYS there; (b) crash-safe — a process-shared pthread mutex has no robust-mutex support on macOS, so a producer crash would deadlock the consumer, whereas a dead socketpair peer surfaces as `POLLHUP`; (c) tolerant of coalesced signals — the ring consumers re-read the atomic index on wake exactly as on the Win32 auto-reset path. |
| `CreateProcessW`, `bInheritHandles=FALSE` | `posix_spawn` + `POSIX_SPAWN_CLOEXEC_DEFAULT` + `file_actions_adddup2` | `posix_spawn` (never a bare fork — the host has touched CoreAudio/Obj-C and fork-without-exec aborts in CF). `CLOEXEC_DEFAULT` (macOS) is the analog of `bInheritHandles=FALSE`: only the dup2()'d channel/doorbell/shm fds reach the child. Linux lacks the flag; the host keeps its fds CLOEXEC instead. |
| `WaitForSingleObject(hProcess)` watcher | blocking `waitpid` in a thread | No global SIGCHLD handler (it would fight libuv's child reaping inside Electron); ECHILD (someone else reaped) is tolerated. `kqueue`/`EVFILT_PROC` is the documented fallback if ECHILD proves common. |
| `PostThreadMessageW(WM_QUIT)` → `TerminateProcess` | `shutdown` control op → `SIGTERM` → `SIGKILL` | |

Trap handled in slice 1: writing to a socket whose peer has closed raises **SIGPIPE**
(kills the process by default) where Windows merely returns an error — every send uses
`MSG_NOSIGNAL` (Linux) and the fd carries `SO_NOSIGPIPE` (macOS), so a dead peer surfaces
as `EPIPE`, never a signal.

The POSIX backends compile on both macOS and Linux (shared primitives) and are verified
by `tests/sandbox/` — an in-process audio-ring loopback (incl. a contended threaded
producer/consumer over the doorbell), an in-process control-channel loopback, a real
`posix_spawn` smoke test (handshake, fd inheritance, clean exit, crash detection, SIGPIPE
suppression), and a full **end-to-end** harness (`tests/sandbox/e2e/`) that spawns the
real `slopsmith-vst-host`, loads a passthrough VST3, and round-trips paced audio over the
shm ring with state + clean shutdown. The unit tests run plain + ASan + TSan and the e2e
runs on `ubuntu-22.04` + `macos-14` in `.github/workflows/sandbox.yml`; the TSan run on the
threaded audio loopback validates the release/acquire ring ordering on arm64.

Runtime wiring: `SandboxFactory_posix` + `SandboxedProcessor` are compiled into
`slopsmith_audio.node` on macOS/Linux, and `slopsmith-vst-host` is built by the same
cmake-js invocation + co-located with the addon (and bundled via `package.json`), so VST3
plugins route through the sandbox on all three platforms.

A subtlety the e2e surfaced: `signalSandboxWake()` must wake the sandbox's *own* audio
worker, but a write to the bidirectional doorbell socketpair goes to the *peer*. The POSIX
backend uses a dedicated per-side self-wake pipe for it (`waitEvent` polls the socketpair
*and* the self-pipe); Windows is unaffected (separate auto-reset events per direction).

Editor (Slice 3): the sandbox child owns a floating top-level editor window (Reaper-style —
the host never reparents it, it only tracks the open/closed bit). On macOS the child calls
`juce::Process::makeForegroundProcess()` before showing the window so a `posix_spawn`'d
executable (default background activation policy) can show + focus its `NSWindow`; NodeAddon's
editor open/close IPC paths are widened from `#if JUCE_WINDOWS` to all platforms. The
open/close protocol is asserted by the e2e on macOS + Linux CI; visual focus/DPI is the
irreducible headless blind spot (manual on real hardware).

**Linux** (issue #265) hosts the editor on the same top-level-window model via JUCE 8's VST3
editor hosting (`Steinberg::Linux::IRunLoop` integrated with the child's `MessageManager` X11
event loop); `getWindowHandle()` returns the X11 `Window`. One Linux-specific gotcha: JUCE only
installs its non-fatal X11 error handlers (and calls `XInitThreads`) for *standalone
JUCEApplications* — `XWindowSystem`'s ctor gates both on `isStandaloneApp()`. This sandbox
child is not a `JUCEApplication` (bare `main()` + `ScopedJuceInitialiser_GUI`), so Xlib's
DEFAULT handler stays active and `exit()`s the child on any protocol error — e.g. a benign
`BadAtom` from JUCE querying `_NET_WM_STATE` on a window-manager-less server. The child
therefore installs its own non-fatal handler at startup (`installLinuxX11Safety` in
`src/vst-host/main.cpp`), mirroring the SIGPIPE suppression and the `createEditor` exception
containment elsewhere. `slopsmith-vst-host` now link-time depends on `libX11` for those direct
`XInitThreads`/`XSetErrorHandler` calls (JUCE otherwise `dlopen`s it). Verified on the e2e
across bare Xvfb (CI), and manually on KWin/Xwayland; a non-EWMH WM (twm) fails the editor open
*cleanly* (10s reply timeout, no crash) rather than hanging the audio path. True cross-process
embedding (CARemoteLayer / Mach-port on macOS, XEmbed on Linux) remains future work.

Orphan cleanup (Linux): a crashed host already triggers child teardown via the control-socket
disconnect callback, but that runs on the message thread (`callAsync`) — which a wedged plugin
(the sandbox's raison d'être) can block. As a kernel-level backstop the child sets
`prctl(PR_SET_PDEATHSIG, SIGTERM)` at startup (`installLinuxParentDeathSignal`), so the OS
reaps it on host death regardless of wedged threads. Covered by the `sandbox_e2e_leak` test
(`tests/sandbox/e2e/leak_test.sh`): the driver crashes without a clean shutdown and asserts the
child is gone. macOS equivalent (kqueue `EVFILT_PROC`) is future work.

Packaging (Linux): electron-builder's `linux.files` + `linux.asarUnpack` ship `slopsmith-vst-host`
into the **same** `app.asar.unpacked/build/Release/` directory as `slopsmith_audio.node`, so
`resolveSandboxExe()` (dladdr on the addon → parent dir → `slopsmith-vst-host`) resolves in both
AppImage and `.deb`. The exec bit survives the unpack, and the editor's X libraries (libX11 plus
the libXext/libXrandr/libXcursor/… JUCE `dlopen`s) are a subset of Electron/Chromium's own runtime
deps, so no extra `deb.depends` is required. Verified with an `electron-builder --linux dir` pack:
the e2e drives the *packaged* host binary end-to-end (spawn → audio → state → editor → shutdown).
