mirror of
https://github.com/got-feedBack/feedBack-desktop.git
synced 2026-08-11 11:19:24 +00:00
447 lines
26 KiB
Markdown
447 lines
26 KiB
Markdown
# 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).
|