-
Notifications
You must be signed in to change notification settings - Fork 91
The plugin API is unsound due to multi-threading #49
Comments
Thanks for investigating this.. |
Yes, ergonomics are very important. I think splitting up the methods as I have suggested is within reason. The most cumbersome part of it will be the communication via thread-safe interior mutability, but that is necessary in any case for safety. It will probably be most ergonomic to include all of the state/preset methods into the
Yes. That one should have been on my "what to do next" list. If other hosts completely violate the rules as formulated here, we need to rethink.
It is, but since the associated types will likely be different for each different plugin, the trait objects will be specific to a particular plugin, which defeats their purpose. |
The assoc types could be boxed, like when a trait with async methods is turned into a trait object: https://boats.gitlab.io/blog/post/async-methods-ii/ |
The cost of a virtual call will likely be insignificant for functions like these, which are called a few hundred times per second at most. And for the most common case of the functions being called by the bridge code in a particular plugin, the call targets will always be the same, so the branches will be well predicted, making the virtual calls basically as cheap as static calls. It does make the handles fat pointers, but that memory overhead is likely insignificant as well. Using trait objects here will also solve the issue about default implementations, as we can just have dummy implementations of the traits which are returned by default. |
On closer inspection, the trait object route does not play well together with passing the processing object out and in through However, guarding the In that case, we can slice the API in a simpler way. If we group all methods callable on the UI thread while the plugin is resumed together into a shared, immutable object, the processing methods can be moved back into the main trait. I think this will be much more convenient both for the plugin and the host. Thus: /// Parameter state object shared between the UI and processing threads
trait PluginParameters {
fn change_preset(&self, preset: i32); // Can be called on the processing thread for automation.
fn get_preset_num(&self) -> i32;
fn set_preset_name(&self, name: String);
fn get_preset_name(&self, preset: i32) -> String;
fn get_parameter_label(&self, index: i32) -> String;
fn get_parameter_text(&self, index: i32) -> String;
fn get_parameter_name(&self, index: i32) -> String;
fn get_parameter(&self, index: i32) -> f32;
fn set_parameter(&self, index: i32, value: f32); // Can be called on the processing thread for automation.
fn can_be_automated(&self, index: i32) -> bool;
fn string_to_parameter(&self, index: i32, text: String) -> bool;
fn get_preset_data(&self) -> Vec<u8>;
fn get_bank_data(&self) -> Vec<u8>;
fn load_preset_data(&self, data: &[u8]);
fn load_bank_data(&self, data: &[u8]);
/// Return handle to plugin editor if supported.
/// The method needs only return the object on the first call.
/// Subsequent calls can just return `None`
fn get_editor(&self) -> Option<Box<dyn Editor>>;
}
trait Plugin {
/// Get a handle to the parameter state object.
fn get_parameter_object(&mut self) -> Arc<dyn PluginParameters>;
// All other methods as in the current Plugin trait
} With this structure, the plugin object belongs to the processing thread while processing is ongoing. If the host wants to suspend the plugin and call some setup methods on it from the UI thread (to change the sample rate, for instance), it will need to either transfer ownership by sending the object between the threads, or share it with an It might look cumbersome that all of the parameter methods have only shared access to the underlying object, but since these will most likely need access to the parameter state accessed by the automation methods, it will need to access shared state in any case, so putting them all together will just be more convenient. |
A re-arrangement of the API according to the latest suggestion can be seen here: askeksa@2c3cf00 It falls into place pretty nicely, I think. The only example that needed any changes was the What do you think? Does this change look reasonable? |
You should open a PR and we can take a deeper look into the changes |
I will. First I need to rebase it onto the latest changes (all the clippy/rustfmt stuff is going to be "fun" to merge). Then I will try to port Oidos to the new API to verify that it works well in practice. Another good verification could be to check how well @Boscop's easyvst crate can be adapted. Ideally it will hide some of the complexity arising from separating out the parameter API. |
Chiming in here to vote for this bug and thank @askeksa for the analysis and exploration. The proposed API sounds reasonable to me, and I think is safe. There are a couple other ways to get similar results, but this is probably the cleanest. In my google/synthesizer-io codebase, I have a lock-free queue abstraction that's designed to be rigorously lock-free (including no allocations) in the processing thread. However, it's going to need some adapting, as it's spsc, and I think it needs to be mpsc because there are at least two threads that can generate messages. This is, I believe, the best way to achieve ergonomics when the plugin is doing complex things. For simpler things (just setting a float parameter), direct use of atomics is reasonable. I'm very excited about the possibility of getting this stuff right. |
Thanks for the comment, @raphlinus. I have also been thinking about how to construct an appropriate queue or rwlock-like data structure for this use case. I think it needs to be quite specialized because of the special situation. In particular:
I haven't yet found a surefire way of constructing such a data structure, but it ought to be possible to achieve, if not exactly this, then at least something useful. |
@askeksa The design of a proper nonblocking queue is probably outside the scope of this issue, though it intersects due to the goal of there being an ergonomic interface. To address your points in order:
It would be easier to just use an atomic float for parameters, but one of my goals is to smooth inputs (and I already have a smoothing filter which behaves very nicely). |
Putting this thought in a separate comment. Another API approach I've thought about (which I think is a little closer to my data-oriented ideas) is as follows:
I'm not going to make the claim that this is significantly better than @askeksa's proposal, but I think there would be slightly less Obviously, in the case of a lock-free queue, the tx half would be in the With some really fancy unsafe hackery, both the Plugin and Engine could be allocated together (the type would basically be Another point, for methods called only from "the UI thread" there could be another associated type with another |
Regarding the queue, I believe that my latest commit to the synthesizer-io lock-free queue makes it suitable for the uses described above. That commit is not the whole story, as it doesn't provide for the pool of set_parameter messages as described above. My feeling is that we should fork off the deeper discussions of thread management and allocation, and I'm open to ideas about the best place for such a discussion. I think such a queue, and other supporting infrastructure, should be in a separate crate, as different VST's might have different ideas how to handle queuing/atomics. Also, I think such a queue might be useful for other plugin formats besides VST. I took a quick skim through #65 and feel I can live with it, but didn't do a deep critique. |
I've been playing with the crate a bit (I just sent a PR to fix rust-noise-vst-tutorial to work in Ableton 1), and am starting to itch to get my synthesizer working in it. I'm ok with #65 being merged, but think my |
It would involve slightly less I do see two problems with it, though:
I brought up the PR on the Telegram thread a few weeks back. @zyvitski was a bit wary of it, as it is a big change and nobody has had the chance to do a proper review of it yet (to check that it does not break anything apart from what it is meant to break). If you decide to back it (as opposed to developing your Also input from @Boscop about how this ties in with easyvst could be really useful. |
Ah, I hadn't thought about the object-safety aspect. If that's compelling, then I defer to the design in your PR. The degree of change does indeed depend on the plugin. Part of my reasoning is that, though processing is where "meat" of the plugin is likely to be, it's actually a relatively small API surface area. That's even more so for GUI plugins, which I haven't started exploring in detail yet but hope to soon. I'm happy to do a thorough review as soon as we see consensus that's the next step - and that seems close. |
Today I subjected Bitwig Studio to the concurrency tester to get some more DAW coverage. The behavior that I saw is consistent with the assumptions described here. It is actually slightly more well-behaved, in the sense that it always calls |
Did another test with Reaper, again with consistent results. Like Renoise, Reaper calls |
Ableton Live also happily calls various |
Tested Cubase as well (which could be considered somewhat authoritative as far as the VST standard goes). Calls |
Fixes #49 This is a breaking change. If your plugin uses parameters or an editor, you will need to update your code. A step-by-step guide on how to port a plugin written for the old API to the new API is available in the transfer_and_smooth example.
Safe buffer creation in the host was just the warm-up. Now the real fun begins! 😉
TL;DR: To adhere to the requirements of Safe Rust, the
Plugin
API of therust-vst
crate needs to be restructured in a way that will require substantial changes to all existing plugins.The problem
The VST API is multi-threaded. A host may call multiple methods concurrently on a plugin instance.
The way the
rust-vst
crate is structured, all methods have access to the same data - an instance of a type implementing thePlugin
trait. In the presence of concurrency, this sharing causes data races, which is undefined behavior and violates the assumptions of Safe Rust. In practice, this leads to crashes and other weird behavior, as the Rust compiler assumes that access through a mutable reference is exclusive and performs optimizations and other transformations based on this assumption.If the
rust-vst
crate is to be a safe wrapper around the VST API, it must be restructured in such a way that data races cannot occur. In Rust terms, this means that:Sync
.On the host side, we have the opposite problem: a host would potentially like to call plugin methods from multiple threads (as outlined in the next section), but it is currently not possible to do so in Safe Rust, because many of the methods require an exclusive reference to the plugin instance.
VST concurrency
The concurrency between VST plugin methods is, fortunately, not arbitrary. The multi-threading characteristics of VST plugins are described here. I found this description to be somewhat vague and incomplete, so in order to uncover the details of how a host might call into a plugin, I wrote a test plugin (which detects and reports which methods are called concurrently) and stress-tested it in Renoise (ran a few notes in a loop with parameter automation while tweaking everything in the GUI I could think of).
My understanding based on both of these sources is the following:
Methods in a plugin are called from two threads: the GUI thread and the processing thread. There can be more than one processing thread, but calls will only come from one of them at a time, so for the purposes of concurrency, we can assume there is just one processing thread.
The VST plugin methods fall into four categories:
The setup methods (
can_do
,get_info
,get_input_info
,get_output_info
,init
,resume
,suspend
,set_block_size
,set_sample_size
and (presumably; not seen)get_tail_size
). These methods are never called concurrently with anything. Furthermore, all of these methods (exceptsuspend
) are only ever called when the plugin is in the suspended state. All other methods are only called when the plugin is in the resumed state.The processing methods (
process
,process_f64
andprocess_events
) are always called from the processing thread. Thus, they are never called concurrently with each other, but can be called concurrently with other methods (except the setup methods).The automation methods (
set_parameter
,change_preset
) can be called either from the processing thread (for automation) or from the GUI thread (when parameters are manually changed in the host GUI). Thus, these can be called concurrently with themselves and each other, and with other methods (except the setup methods).The remaining methods (mostly parameter queries, preset handling and editor interaction) are always called from the GUI thread. Thus they are never called concurrently with each other, but can be called concurrently with the processing and automation methods.
Requirements
A solution to this issue should ideally fulfill the following requirements:
A plugin written in Safe Rust never encounters data races (or other undefined behavior) when run in a host that follows the VST concurrency rules described in the previous section.
A host written in Safe Rust is able to call plugin methods from multiple threads, subject to the VST concurrency rules. It is a bonus if the rules are enforced by the API (statically, dynamically, or some combination thereof) such that the host is not able to violate them.
Calling a Rust plugin directly from a Rust host without going through the VST API is still possible and is still safe (i.e. safety should not depend on the VST API bridging code).
The API is not too opinionated about how the plugin implements communication between the threads. In particular, it should be possible, within the API constraints, for the processing thread to be completely free of allocation and blocking synchronization.
Implementation
To achieve safety, the plugin state needs to be split into separate chunks of state such that methods that can be called concurrently do not have mutable access to the same chunk.
Note that since the automation methods can be called concurrently with themselves, this implies that these methods can't have mutable access to anything. All mutation performed by these methods must thus take place via thread-safe internal mutability (i.e.
Mutex
,RwLock
, spinlocks, atomics and the like).One way to split the state could be something like this:
This design achieves thread safety in plugins, and it prevents hosts from calling processing methods while the plugin is suspended. It does have a few drawbacks, though:
It does not prevent the host from calling setup methods while the plugin is resumed. Such a restriction could be implemented by giving the host a "setup token" that it needs to pass to the setup methods (or maybe the methods are on the token itself). This token would be consumed by the
resume
method and given back by thesuspend
method. This could become somewhat unwieldy for both the plugin and the host, however.Using the standard
Arc
andBox
types to control access to theAutomation
andProcessing
state chunks means that these chunks must be allocated on the heap. This could be avoided by introducing specialized wrapper types with similar semantics but which will allow the chunks to be embedded into the main plugin struct. But again, this would make the API more cumbersome to use.Adding associated types to the
Plugin
trait means the trait is no longer object safe, i.e. it can't be used for trait objects. This can be a problem for the pure Rust use case (bypassing the bridge).Some of the new methods can't have sensible default implementations unless we require the
Processing
andAutomation
types to implementDefault
. Such a requirement can be inconvenient, as theProcessing
chunk would usually want to contain a (non-optional) reference to theAutomation
chunk.What to do now
Discuss. 😀
Then, we should make some prototypes to see how these ideas (and others we come up with) work in practice.
This change is a substantial undertaking (not least in fixing all existing plugins), but I think it is necessary before we can call our crate complete and stable. And if we manage to pull this off in a good way, it could make for a quite good case story (about wrapping an unsafe API safely) for This Week in Rust. 😌
The text was updated successfully, but these errors were encountered: