ryanmcgrath / cacao

Rust bindings for AppKit (macOS) and UIKit (iOS/tvOS). Experimental, but working!
MIT License
1.8k stars 65 forks source link

webview crashes when used from two different bundles #63

Closed httnn closed 1 year ago

httnn commented 1 year ago

i'm building two different "formats" of my app (they're audio plug-ins) as separate plugin bundles. when loading both bundles together in a host application, both of them crash simultaneously. the following unwrap() seems to be causing the crash: https://github.com/ryanmcgrath/cacao/blob/trunk/src/webview/class.rs#L194

if i've understood correctly, this is just where the class in question gets declared (not even initialised), so might the crash happen when trying to declare the class when it's already declared? any other ideas?

ryanmcgrath commented 1 year ago

Huh, looks like that was never updated to use 'load_or_register_class'. You can see view/appkit.rs for an example of how to swap it.

That newer method does a check to see if the class already exists. Across two bundles it might still fail, in which case we can add a check in there to accommodate - but if you don't mind swapping and trying that first it'd be helpful.

(The issue is that the class registration once-token check happens on the Rust side, but the bundles are using the same ObjC runtime, and so the subclass attempts to get created twice since the Rust code is isolated and doesn't know about one another - my best guess at least, on mobile right now)

httnn commented 1 year ago

still crashes, but now the error is: thread 'unnamed' panicked at 'Subclass of type RSTWebViewDelegate_NSObject could not be allocated.':

this is what the code looks like now:

pub fn register_webview_delegate_class<T: WebViewDelegate>() -> *const Class {
    load_or_register_class("NSObject", "RSTWebViewDelegate", |decl| unsafe {
        decl.add_ivar::<usize>(WEBVIEW_DELEGATE_PTR);

hope i swapped it correctly!

ryanmcgrath commented 1 year ago

Hmmm, yeah, so this use-case more or less means that the way subclass handling is done needs to be rewritten. I'm stuck on a train at the moment and can look into this for a bit.

ryanmcgrath commented 1 year ago

Would you give the feat/change-objc-classdec branch a whirl? I refactored the class loading/register logic to check the runtime if there's no record held for it, and updated the Webview component to route through that method as well. I think this should solve your issue, but lemme know~

ryanmcgrath commented 1 year ago

(The browser and todos_list examples both run without issue, but I don't have a multiple-bundle setup such as yours to test with at the moment)

httnn commented 1 year ago

that was quick! it's still crashing unfortunately, the error being the same as above: thread 'unnamed' panicked at 'Subclass of type RSTWebViewDelegate_NSObject could not be allocated.': /Users/max/code/cacao/src/foundation/class.rs:134

httnn commented 1 year ago

btw, my code is public at https://github.com/maxjvh/nih_plug_webview in the example folder but setting up an environment to load both bundles is a bit involved (requires downloading a DAW and setting it up). can guide you through it tho if you're interested!

ryanmcgrath commented 1 year ago

Huh, so this is an odd one. As far as I understand this all, attempting to get a class in Objective-C will return nil if the class can't be created - i.e, if the name exists already. (in objc_allocateClassPair)

It appears that the attempts to check with the runtime (via getClass) don't return the existing class, but the ClassDecl fails presumably due to the name existing as a class (unless there's some nuance of bundles that I'm missing here...).

As an aside, I think it might be prudent for cacao to also add on a semver to the class definitions that get injected - if plugins share the same visibility of everything in the runtime, then it might be worth doing it to sandbox changes.

ryanmcgrath commented 1 year ago

Well, while I debug this, one workaround would be to just set a custom NAME on your webview delegate depending on how the bundle is being built (a feature flag or something). It's definitely not ideal, but I'd probably need to sit down and debug this further to fix it the "right" way - I'll keep this open to track it in the meantime.

(I pushed a change to the feat/change-objc-classdec branch that'd allow this, so hope it helps for now - it's probably worth doing on that trait anyway, so the change is worth it)

ryanmcgrath commented 1 year ago

(I figure it's also worth tagging @madsmtm for perspective given they probably deal with the intricacies of the ObjC runtime than most people these days, lol)

madsmtm commented 1 year ago

Hmm, haven't really dealt much with bundles (from what I understand they're basically Apple's fancy dynamic libraries), but from what I understand you're trying to load two bundles which contain a class with the same name? Is this even supported in Objective-C proper?

madsmtm commented 1 year ago

It appears that the attempts to check with the runtime (via getClass) don't return the existing class, but the ClassDecl fails presumably due to the name existing as a class (unless there's some nuance of bundles that I'm missing here...).

As a fix, you could probably do something like:

let n: usize = 0;
loop {
    let name = format!("{}_{}_{}", subclass_name, superclass_name, n);
    if let Some(decl) = ClassDecl::new(name, superclass) {
        // Initialize class
        return decl;
    }
    n += 1; // Or a random number, if you want to avoid pathological cases
}

(Though of course the class could also fail to allocate in OOM situations, so this is a bit brittle).

ryanmcgrath commented 1 year ago

Yeah, I contemplated doing this... but it feels like ignoring the root cause (which I still don't understand, frankly). :(

httnn commented 1 year ago

Hmm, haven't really dealt much with bundles (from what I understand they're basically Apple's fancy dynamic libraries), but from what I understand you're trying to load two bundles which contain a class with the same name? Is this even supported in Objective-C proper?

the details on how bundles work in correlation with Objective-C is also a mystery to me unfortunately. i haven't heard of a crash like this ever before though, so can't be too common. i wonder if this might even be because of some kind of missing bundle build option..? like "wrap declared classes in bundle namespace" or something lol. could dig around a little.

Well, while I debug this, one workaround would be to just set a custom NAME on your webview delegate depending on how the bundle is being built

this seems very reasonable, thanks!

httnn commented 1 year ago

specifying different class names for different bundles seemed to work for the delegate class, wohoo! but now it's failing when trying to declare the webview class: let decl = ClassDecl::new("RSTWebView", superclass).unwrap(); 😞

ryanmcgrath commented 1 year ago

Heh, I guess that's not surprising... I wonder if there's some logic towards add a bundled feature flag, and then it just appends a _{bundle} to the subclass name? That's still duplication, but ultimately it shouldn't be that bad I don't think... since this is somewhat of a corner case, and it's at best just doing duplication per bundle.

(I can update the branch for this in a bit if you'd wanna try it out)

ryanmcgrath commented 1 year ago

I also think there has to be something with associating bundles and classes, since bundleForClass: exists.

ryanmcgrath commented 1 year ago

@maxjvh Feel free to pull that branch and give it a whirl; the attempt is very basic so feel free to tweak if you find something I missed, but the examples run with it - if there is a bundle ID for the current running process, it will take that and use it in the injected subclass name.

I would want to clean this up and look more into it before merging this, but your setup is the most ideal for testing it as a solution I think.

(A solution to a problem that I still think I'm missing entirely...)

httnn commented 1 year ago

btw, this is how a very popular and widely used framework for building these plug-ins in various formats seems to be doing it: https://github.com/juce-framework/JUCE/blob/965d0ca4be178c4a0000b116d460e15c30311992/modules/juce_core/native/juce_mac_ObjCHelpers.h#L369 so it essentially just creates random names 😅

ryanmcgrath commented 1 year ago

Ha! Well that makes me feel slightly less like a complete moron... I think we can do better than randomized (I really prefer to keep them readable for debugging purposes), but that's actually super helpful context so thanks for flagging it!

ryanmcgrath commented 1 year ago

(Oh, I forgot to also update the WebView class registration fn to use load_or_register, so that wouldn't have hit... updated/tested/pushed~)

httnn commented 1 year ago

getting closer but: thread 'unnamed' panicked at 'Subclass of type CacaoWebView_WKWebView could not be allocated.': <omitted>/cacao/src/foundation/class.rs:161

just to make sure, the bundle identifier is read from the Info.plist file, right?

ryanmcgrath commented 1 year ago

Huh, well that's interesting - it looks like that class name isn't getting the bundle ID applied, which means [NSBundle mainBundle].bundleIdentifier is returning nil... but I've offhand go no clue why that would return nil if it's in a valid bundle with an Info.plist.

At this point I am growing inclined to just copy what JUCE does, or take something akin to mads approach further up in this thread. What a weird issue, not something I ever expected to be debugging.

I'm a bit busy tonight but poke me if I forget about this tomorrow?

httnn commented 1 year ago

it was actually still only logging the superclass and subclass names but i fixed that and turns out that the mainBundle ID is the one of the host application 🙃 so might be that it's not even possible to get the plug-in bundle ID when running inside a host app. seems like JUCE might've had a reason for randomising the bundle name!

anyway, thanks a lot for your help on this! it's already more than i could ask for.

httnn commented 1 year ago

with a random class name it indeed works. this is what i tried it with in foundation/class.rs: let objc_subclass_name = format!("{}_{}_{:x}", subclass_name, superclass_name, rand::random::<i64>()); using the rand crate.

ryanmcgrath commented 1 year ago

Alright, I updated this branch to do this - we'll now generate and append a random u64 to all subclass names. I think it might still be worth writing some logic here to repeatedly try to declare a new subclass name rather than panic if one can't be declared... i.e, this should remove something like 99% of the issues, but it still feels like there might be a dangling thing to consider here.

I think my final question comes down to: is the only reason class declaration/allocation would return nil that the name is already registered/can't be registered? If so then I think the repeatedly try logic is fine, otherwise I guess I'd be wary of masking another issue.

httnn commented 1 year ago

can confirm that the branch is now working, but don't know in which other cases allocating a class might fail. looks like Apple's documentation isn't super helpful either 😕

ryanmcgrath commented 1 year ago

can confirm that the branch is now working

Cool! I'll merge these changes next chance I get, but there's some other PRs that I've been horribly neglectful of that I'd like to get to first - so if you're cool to use that branch for now...

Glad we got to the bottom of this, and thanks for the odd use-case to test against!

httnn commented 1 year ago

definitely cool with that! thanks for your help 💜

ryanmcgrath commented 1 year ago

Merged via #67.