Files
feedBack-desktop/docs/SANDBOX-DESIGN.md
T
2026-06-16 18:48:12 +02:00

447 lines
26 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 34; 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) | 12 d |
| Control channel (pipe + JSON + 12 message types) | 23 d |
| Audio channel (shm + events + ring) | 23 d |
| `SandboxedProcessor` glue inside the addon | 2 d |
| Detection list + denylist promotion | 1 d |
| Editor reparenting into Electron | 24 d (focus + DPI is fiddly) |
| Crash detection + restart + state cache | 2 d |
| QA pass on the top-10 NI plugins + iterating on weird behaviours | 35 d |
| **Total** | **~1522 working days** |
Roughly 34 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).