Skip to content

fix(host): make the hosted VST3 editor actually embed#6444

Merged
shipyard-local[bot] merged 6 commits into
mainfrom
fix/vst3-hosted-editor-null
Jul 22, 2026
Merged

fix(host): make the hosted VST3 editor actually embed#6444
shipyard-local[bot] merged 6 commits into
mainfrom
fix/vst3-hosted-editor-null

Conversation

@shipyard-local

@shipyard-local shipyard-local Bot commented Jul 21, 2026

Copy link
Copy Markdown
Contributor

Reported against SDK 0.691: hosting a VST3 works, but its editor never appears —
create_hosted_editor() returns null, so EditorAttachment::create() returns
null and nothing attaches.

The report's own cause is not a bug: v0.691.0 was tagged 2026-07-17 and
VST3 editor embedding landed 2026-07-18 (74305e4fb, first shipped in
v0.702.0). At their pin the slot was has_editor() -> false with no
create_hosted_editor override at all. That half needs an SDK upgrade, not a fix.

Auditing the newer path for the same symptom turned up four real defects, plus
one found reviewing this branch.

Fixed

  1. IPlugView::setFrame() was never called anywhere in the repo. IPlugView
    documents IPlugFrame::resizeView() as callable from inside attached()
    it is how a plug-in reports the size it really wants. With no frame a plug-in
    either mis-sizes itself or refuses to attach, reproducing the reported
    null on current builds. The frame is now installed immediately after
    createView, resizeView routes through the container + onSize, and every
    release path clears it so a plug-in can never call a destroyed frame.

  2. Combined-vs-separated identity used a static_cast<FUnknown*> compare. A
    combined plug-in inherits IComponent and IEditController separately, so
    each carries its own FUnknown base subobject at a different address
    every combined VST3 was classified "separated", which called
    IPluginBase::terminate() twice on it at unload and stored its state
    twice. Verified against the SDK's own SingleComponentEffect. Identity now
    goes through the FUnknown query.

  3. Separated halves were never joined over IConnectionPoint, so a
    plug-in's editor could not send its own processor a message.

  4. IHostApplication::createInstance returned kNotImplemented, so the
    plug-in could not allocate the IMessage / IAttributeList objects (3) runs
    on. HostApp now derives from the SDK's Vst::HostApplication.

  5. Identity failed toward the crash. vst3_same_object returned
    "separated" when the FUnknown query could not be answered — dropping a
    non-conformant plug-in onto the double-terminate() path above. It now fails
    toward the leak, which is recoverable.

Also: a separated controller now gets setComponentState at load (it opened its
editor on its own defaults), and EditorAttachment owns the slot's
resize-request channel so a plug-in-driven resize moves the child view the host
placed, not just the slot's private container.

Unrelated fix carried here

setup.sh's reuse_shared_git_source accepted any existing symlink at
external/<dep>, so a re-pin left it aimed at the old shared cache forever.
That let a v3.7.12 VST3 SDK — GPL-or-proprietary — outlive the move to the MIT
v3.8.0 pin, with the build, tests and plug-ins silently using it. A stale
symlink is now re-pointed; a real directory is reported, never deleted.

Verification

  • New test_vst3_connection.cpp (11 cases) and test_vst3_hosted_editor.mm
    (7 cases — the real Vst3Slot against a real NSWindow, with a plug-in that
    calls resizeView from inside attached()), plus frame-lifecycle and
    resize-wiring coverage added to two existing suites.
  • Negative controls on every load-bearing claim: dropping setFrame fails
    4 of 7; publishing the view/container after attached() fails 3 of 7; the
    FUnknown*-cast identity makes a combined plug-in report terminate_calls == 2.
  • 5 new test groups for the setup.sh fix; pre-fix setup.sh fails 5 groups.
  • Full ctest on the pre-rebase tree: 15,215 / 15,220. The 5 failures are
    pre-existing examples/PulpSampler + a -j6 import-design flake, none
    reachable from this diff (it touches no examples/ file, and
    pulp-sampler-test links neither pulp::host nor vst3-sdk).
  • Five real third-party VST3 editors attach and tear down cleanly via
    plugin-host-demo --editor against VST3 SDK v3.8.0_build_66.

🔎 Provenance

Agent claude
Machine m3
Tab vst3-editor
Directory /Volumes/Workshop/Code/pulp-vst3-editor
Session 48c1f115-283b-4780-9bd4-95175eaf79ee

Resume

claude --resume 48c1f115-283b-4780-9bd4-95175eaf79ee

Jump to this tab

cmux surface focus 75D59C47-3202-455F-8B64-88D58EF2E3B4

Relaunch (any agent)

cmux surface resume get --surface 75D59C47-3202-455F-8B64-88D58EF2E3B4

stamped 2026-07-22 12:50 UTC

@shipyard-local shipyard-local Bot changed the title fix(host): fail VST3 identity toward the leak, not the double-terminate fix(host): make the hosted VST3 editor actually embed Jul 22, 2026
@shipyard-local
shipyard-local Bot enabled auto-merge July 22, 2026 10:41
A hosted VST3's editor never appeared: create_hosted_editor() returned
null, so EditorAttachment::create() returned null and nothing attached.

Four separate defects, all on the path from "load a VST3" to "its editor
is on screen":

1. No IPlugFrame. IPlugView documents IPlugFrame::resizeView() as
   callable from inside attached() — it is how a plug-in reports the size
   it really wants — so a view with no frame either mis-sizes itself or
   refuses to attach, and a refused attached() is exactly the null the
   host sees. Install the frame right after createView (before anything
   else is asked of the view), route resizeView through the container +
   the slot's resize-request handler + onSize, and clear the frame on
   every release path so a plug-in can never call a destroyed frame.
   The slot now publishes its view/container before attached() and
   reports the post-attach size.

2. Combined-vs-separated was decided by casting both interface pointers
   to FUnknown* and comparing. A combined plug-in inherits IComponent and
   IEditController separately, so each carries its own FUnknown base
   subobject at a different address and every combined plug-in was
   classified "separated" — which called IPluginBase::terminate() twice
   on it at unload and stored its state twice. Identity now goes through
   the FUnknown query, verified against the SDK's SingleComponentEffect.

3. A separated plug-in's two halves were never joined over
   IConnectionPoint, so its editor could not send its own processor a
   message. Connect both directions at load, unwind the accepted half if
   the other refuses, and disconnect before either terminates.

4. IHostApplication::createInstance returned kNotImplemented, so the
   plug-in could not allocate the IMessage / IAttributeList objects that
   connection-point traffic is built from. Derive HostApp from the SDK's
   Vst::HostApplication (which also supplies IPlugInterfaceSupport),
   keeping the singleton's refcount neutered.

Also: push the component state into a separated controller at load (it
otherwise opens the editor on its own defaults), and have EditorAttachment
own the slot's resize-request channel so a plug-in-driven resize moves the
child view the host placed, not just the slot's private container.

The three new seams are headless-testable; test_vst3_connection.cpp is
new and test_vst3_editor.cpp / test_hosted_editor_migration.cpp grow
frame-lifecycle and resize-wiring coverage.
…quest

The two formats' no-handler answer is deliberately opposite — VST3 accepts,
CLAP denies — because VST3's resizeView is UI-thread-only and reports a
plug-in's real size from inside attached(), while CLAP's request_resize is
[thread-safe] and can arrive somewhere a native view must not be touched.
Write it down so it does not get 'harmonized'.
reuse_shared_git_source accepted ANY existing symlink at external/<dep>
as long as the marker file resolved through it. The shared cache
directory name embeds the ref, so a re-pin creates a new cache next to
the old one and leaves the link aimed at the old one — every later
./setup.sh then prints "linked from shared cache" while the tree stays a
version behind, forever.

That is how a v3.7.12 VST3 SDK survived the move to the MIT v3.8.0 pin on
a machine whose shared cache already held v3.8.0_build_66: the build, the
tests, and every plug-in built from that checkout silently used the older,
GPL-or-proprietary tree, and only `tools/deps/audit.py --verify-licenses`
noticed.

A symlink can be repaired without destroying anything, so a stale one is
now re-pointed. A real directory is developer-managed (a hand clone, an
unpacked release), so it is never deleted — it is reported instead, via
fail(), which ERRORS already turns into a non-zero setup exit.
git_checkout_matches_cache answers "matches" whenever it cannot tell (no
.git, or an unprovisioned cache) so an honest unknown never fails a run.
… parent

The free-function seams cover the VST3 negotiation calls in isolation, but
not the part that only exists once the slot is holding the IPlugFrame:
IPlugView documents IPlugFrame::resizeView() as callable from inside
attached(), and honoring it requires the slot to publish its view and
container BEFORE the attach call. That ordering is the fix, and nothing
was covering the composition.

make_vst3_slot() mirrors make_clap_slot(): build a slot around
caller-created SDK interfaces, no dlopen and no bundle. The new
macOS-only suite drives it with a fake IEditController whose view calls
back into the host mid-attach, parented into a real NSWindow content view.

Covers: the container really becomes a subview at the plug-in's size; the
frame is live during attached(); a resize requested from inside attached()
resizes the container, commits with onSize, and is what create_hosted_editor
reports; an embedder handler sees the request and can veto it; a refused
attach leaves nothing parented and does not wedge the slot; and a combined
plug-in is terminated exactly once.

Negative controls, each reverting one piece of the fix: dropping setFrame
fails 4 of 7, publishing the view/container after attached() fails 3 of 7,
and the FUnknown*-cast identity check makes the combined plug-in report
terminate_calls == 2.
The guide implied the exposure starts at "deeper analysis by instantiating
and processing untrusted plug-ins", which understates it: a plug-in can
fault inside its own factory or initialize() and take the host down before
load() returns, with no audio processed at all.

Observed with a shipping commercial VST3 whose licensing prerequisites are
absent on the machine — it segfaults four frames deep inside its own binary
during load_vst3_plugin, with PluginSlot::load the only Pulp frame on the
stack, and reproduces regardless of Pulp version. Worth naming so the next
reader of that crash log does not go hunting for a host-side bug.
vst3_same_object returned false — "separated" — when a plug-in could not
answer the FUnknown query identity depends on. That is the wrong default:
it lands a non-conformant plug-in on exactly the path this branch just
fixed, calling IPluginBase::terminate() on one object twice.

The two wrong answers are not equally bad. Treating one object as two is
undefined behavior inside the plug-in; treating two objects as one skips a
terminate and leaks. An undeterminable identity now reports "same object".
@danielraffel
danielraffel force-pushed the fix/vst3-hosted-editor-null branch from b95111a to 2f294bd Compare July 22, 2026 12:50
@shipyard-local
shipyard-local Bot added this pull request to the merge queue Jul 22, 2026
Merged via the queue into main with commit 4cac6a1 Jul 22, 2026
25 of 31 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant