Bug 167708 - [Cocoa] An exported Objective C class’s prototype and constructor don't persist across JSContext deallocation
Summary: [Cocoa] An exported Objective C class’s prototype and constructor don't persi...
Status: RESOLVED FIXED
Alias: None
Product: WebKit
Classification: Unclassified
Component: JavaScriptCore (show other bugs)
Version: Other
Hardware: Unspecified Unspecified
: P2 Normal
Assignee: Keith Miller
URL:
Keywords: InRadar
Depends on:
Blocks:
 
Reported: 2017-02-01 13:52 PST by mitz
Modified: 2017-06-08 14:22 PDT (History)
9 users (show)

See Also:


Attachments
Patch (21.20 KB, patch)
2017-05-19 11:32 PDT, Keith Miller
no flags Details | Formatted Diff | Diff
Patch for landing (22.18 KB, patch)
2017-05-22 12:04 PDT, Keith Miller
no flags Details | Formatted Diff | Diff

Note You need to log in before you can comment on or make changes to this bug.
Description mitz 2017-02-01 13:52:54 PST
If a class A with an initializer is exported to JavaScriptCore, and its constructor is referenced by a global object in some context, but the JSContext wrapping that context get deallocated, then a new, different constructor is created the next time it’s needed.

To see the problem, build and run this program:

@import JavaScriptCore;
@import Foundation;

@protocol AJSExport <JSExport>
- (instancetype)init;
@end

@interface A : NSObject <AJSExport>
@end

@implementation A
@end

int main(int argc, const char * argv[]) {
    JSGlobalContextRef contextRef;
    @autoreleasepool {
        JSContext *context = [[JSContext alloc] init];
        contextRef = JSGlobalContextRetain(context.JSGlobalContextRef);
        context[@"A"] = A.class;
        NSLog(@"%@", [context evaluateScript:@"new A().constructor === A"]);
    }

    @autoreleasepool {
        JSContext *context = [JSContext contextWithJSGlobalContextRef:contextRef];
        NSLog(@"%@", [context evaluateScript:@"new A().constructor === A"]);
    }

    JSGlobalContextRelease(contextRef);

    return 0;
}

The first NSLog statement logs true, and the second one logs false. It’s expected to log true as well.
Comment 1 Radar WebKit Bug Importer 2017-02-06 16:36:23 PST
<rdar://problem/30387376>
Comment 2 Geoffrey Garen 2017-02-07 07:56:37 PST
Note that this problem would reproduce with any methodology for passing an ObjC object to JS -- it is incidental in this example that we allocate the ObjC object using an ObjC constructor.
Comment 3 Geoffrey Garen 2017-02-07 07:58:30 PST
The cause of this bug is that we tie the lifetime of JSWrapperMap to the lifetime of JSContext. Instead, we need to tie the lifetime of JSWrapperMap to the GC lifetime of a given JSGlobalObject.
Comment 4 Keith Miller 2017-05-15 22:59:51 PDT
I would propose a different solution to fix this. I think we should just set the .prototype property on A and make it read-only and non-configurable. Then we can run the same code for constructing ObjC classes as we do for host constructors. This also has the nice property that it does what people would expect from JS.

There are a couple of behavior changes to consider with this proposal, however: 

1) If you set the .prototype property on A, right now it will work but do nothing. This change will make code that relies on changing the .prototype property 

2) If you make two constructors for your class both share the same .prototype. This change would make them have different prototypes, which seems closer to what one would expect from JS.

3) If you extend (in JS) your constructor, any super() calls don't respect new.target. This change would make it respect new.target. (I didn't test this but the API code doesn't call createSubclassStructure so I assume it doesn't)

The only thing I would worry about causing incompatible changes is 2. I could totally imagine someone making two constructors, say A and B, for their class and only setting properties on A's .prototype. Currently instances of B will still see A's .prototype properties since both constructors will share the same .prototype object.

I'm not sure if that's something we should worry about. I would guess most users only put their constructors on the global object. Geoff, do you have any insight here?
Comment 5 Geoffrey Garen 2017-05-16 09:55:20 PDT
I'm not too worried about the compatibility risk to these changes.
Comment 6 Keith Miller 2017-05-19 11:32:19 PDT
Created attachment 310677 [details]
Patch
Comment 7 Mark Lam 2017-05-19 13:01:38 PDT
Comment on attachment 310677 [details]
Patch

View in context: https://bugs.webkit.org/attachment.cgi?id=310677&action=review

> Source/JavaScriptCore/runtime/JSGlobalObject.h:52
> +OBJC_CLASS JSWrapperMap;

Need to wrap with #if JSC_OBJC_API_ENABLED.

> Source/JavaScriptCore/runtime/JSGlobalObject.h:831
> +    JSWrapperMap* wrapperMap() const { return m_wrapperMap.get(); }
> +    void setWrapperMap(JSWrapperMap* map) { m_wrapperMap = map; }

Ditto.

> Source/JavaScriptCore/runtime/JSGlobalObject.h:862
> +    RetainPtr<JSWrapperMap> m_wrapperMap;

Ditto.
Comment 8 Geoffrey Garen 2017-05-19 14:46:09 PDT
Comment on attachment 310677 [details]
Patch

View in context: https://bugs.webkit.org/attachment.cgi?id=310677&action=review

Mark is right about JSC_OBJC_API_ENABLED -- that's the GTK build failure.

> Source/JavaScriptCore/ChangeLog:11
> +        Also, this patch fixes a "bug" where we would not just used the object constructor/prototype for NSObject.

NSObject doesn't export anything to JavaScript. Is this change observable? If so, we should add a test for it.
Comment 9 Geoffrey Garen 2017-05-19 14:53:12 PDT
Comment on attachment 310677 [details]
Patch

View in context: https://bugs.webkit.org/attachment.cgi?id=310677&action=review

> Source/JavaScriptCore/API/JSWrapperMap.mm:476
> -        if (!jsPrototype) {
> -            JSValue *prototype = constructor[@"prototype"];
> -            jsPrototype = toJS(JSValueToObject(cContext, valueInternalValue(prototype), 0));
> -        }
> +        if (!jsPrototype)
> +            jsPrototype = globalObject->objectPrototype();

A better way to describe this, rather than saying "we would not just used the object constructor/prototype for NSObject", is to fully describe the edge case, where we would do the wrong thing if and only if the user had overwritten the window.Object property.
Comment 10 Keith Miller 2017-05-22 12:04:30 PDT
Created attachment 310895 [details]
Patch for landing
Comment 11 WebKit Commit Bot 2017-05-22 12:48:41 PDT
Comment on attachment 310895 [details]
Patch for landing

Clearing flags on attachment: 310895

Committed r217240: <http://trac.webkit.org/changeset/217240>
Comment 12 WebKit Commit Bot 2017-05-22 12:48:43 PDT
All reviewed patches have been landed.  Closing bug.
Comment 13 Joseph Pecoraro 2017-06-08 14:10:16 PDT
Comment on attachment 310895 [details]
Patch for landing

View in context: https://bugs.webkit.org/attachment.cgi?id=310895&action=review

> Source/JavaScriptCore/API/JSContext.mm:57
> +        [[JSWrapperMap alloc] initWithGlobalContextRef:[self JSGlobalContextRef]];

This looks like it will leak the JSWrapperMap.

- This alloc produces a +1 instance
- Inside the initializer, the JSGlobalObject is made to retain the wrapper via a RetainPtr<JSWrapperMap>
- However nobody ends up releasing this +1. It seems this code should release or autorelease this new instance and probably include a healthy comment about the non-obvious lifetime

I'll look into cleaning this up.
Comment 14 Joseph Pecoraro 2017-06-08 14:22:02 PDT
> I'll look into cleaning this up.

Bug 173110.