iOS implemention of "window.setTimeout" with JavascriptCore
Asked Answered
W

6

6

I am using JavaScriptCore library inside iOS application and I am trying to implement setTimeout function.

setTimeout(func, period)

After application is launched, the JSC engine with global context is created and two functions are added to that context:

_JSContext = JSGlobalContextCreate(NULL);

[self mapName:"iosSetTimeout" toFunction:_setTimeout];
[self mapName:"iosLog" toFunction:_log];

Here is native implementation that is mapping global JS function with desired name to static objective C function:

- (void) mapName:(const char*)name toFunction:(JSObjectCallAsFunctionCallback)func
{
  JSStringRef nameRef = JSStringCreateWithUTF8CString(name);
  JSObjectRef funcRef = JSObjectMakeFunctionWithCallback(_JSContext, nameRef, func);
  JSObjectSetProperty(_JSContext, JSContextGetGlobalObject(_JSContext), nameRef, funcRef, kJSPropertyAttributeNone, NULL);
  JSStringRelease(nameRef);
}

And here is the implementation of objective C setTimeout function:

JSValueRef _setTimeout(JSContextRef ctx,
                     JSObjectRef function,
                     JSObjectRef thisObject,
                     size_t argumentCount,
                     const JSValueRef arguments[],
                     JSValueRef* exception)
{
  if(argumentCount == 2)
  {
    JSEngine *jsEngine = [JSEngine shared];
    jsEngine.timeoutCtx =  ctx;
    jsEngine.timeoutFunc = (JSObjectRef)arguments[0];
    [jsEngine performSelector:@selector(onTimeout) withObject:nil afterDelay:5];
  }
  return JSValueMakeNull(ctx);
}

Function that should be called on jsEngine after some delay:

- (void) onTimeout
{
  JSValueRef excp = NULL;
  JSObjectCallAsFunction(timeoutCtx, timeoutFunc, NULL, 0, 0, &excp);
  if (excp) {
    JSStringRef exceptionArg = JSValueToStringCopy([self JSContext], excp, NULL);
    NSString* exceptionRes = (__bridge_transfer NSString*)JSStringCopyCFString(kCFAllocatorDefault, exceptionArg);  
    JSStringRelease(exceptionArg);
    NSLog(@"[JSC] JavaScript exception: %@", exceptionRes);
  }
}

Native function for javascript evaluation:

- (NSString *)evaluate:(NSString *)script
{
    if (!script) {
        NSLog(@"[JSC] JS String is empty!");
        return nil;
    }


    JSStringRef scriptJS = JSStringCreateWithUTF8CString([script UTF8String]);
    JSValueRef exception = NULL;

    JSValueRef result = JSEvaluateScript([self JSContext], scriptJS, NULL, NULL, 0, &exception);
    NSString *res = nil;

    if (!result) {
        if (exception) {
            JSStringRef exceptionArg = JSValueToStringCopy([self JSContext], exception, NULL);
            NSString* exceptionRes = (__bridge_transfer NSString*)JSStringCopyCFString(kCFAllocatorDefault, exceptionArg);

            JSStringRelease(exceptionArg);
            NSLog(@"[JSC] JavaScript exception: %@", exceptionRes);
        }

        NSLog(@"[JSC] No result returned");
    } else {
        JSStringRef jstrArg = JSValueToStringCopy([self JSContext], result, NULL);
        res = (__bridge_transfer NSString*)JSStringCopyCFString(kCFAllocatorDefault, jstrArg);

        JSStringRelease(jstrArg);
    }

    JSStringRelease(scriptJS);

    return res;
}

After that whole setup, the JSC engine should evaluate this:

[jsEngine evaluate:@"iosSetTimeout(function(){iosLog('timeout done')}, 5000)"];

The JS execution calls the native _setTimeout, and after five seconds, the native onTimeout is called and crash happens in JSObjectCallAsFunction. The timeoutCtx becomes invalid. Sounds like timeout function context is local and during the time period garbage collector deletes that context in JSC side.

The interesting thing is also, if _setTimeout function is changed in order to call JSObjectCllAsFunction immediately, without waiting for timeout, then it works as expected.

How to prevent automatic context deletion in such asynchronous callbacks?

Whittier answered 13/4, 2013 at 17:58 Comment(4)
Are you able to solve this issue?? I am also facing similar kind of issue for setTimeOut() function.Lyricism
If you are registered iOS developer, take a look to the video about javascript core from wwdc 2013. You will find there the solution for this problem.Mistrustful
Yes Prcela, I have already taken a look but that is for iOS 7. I will try your answer for iOS 5 and iOS 6 solution for third party JSCore library. Thanks... +1.Lyricism
Hi Prcela, From where do you get JSEngine class?? I am using JSCore library provided by phoboslab.org/log/2011/06/javascriptcore-project-files-for-ios. This I have to use as I have to support iOS 5 and iOS 6 devices. Can you please guide me for this??Lyricism
W
2

For registered iOS developer, take a look at the new video about javascript core from wwdc 2013 called "Integrating JavaScript into Native Apps". You will find there the solution for newest iOS version.

My alternative solution, for the current iOS version, was to make a global array in JSC for storing objects that should be protected from garbage collector. So, you have control to pop variable from array when it is not needed any more.

Whittier answered 23/7, 2013 at 11:8 Comment(1)
the link is brokenEtude
M
4

I ended up adding setTimeout to a specific JavaScriptCore context like this, and it worked well:

JSVirtualMachine *vm = [[JSVirtualMachine alloc] init];
JSContext *context = [[JSContext alloc] initWithVirtualMachine: vm];

// Add setTimout
context[@"setTimeout"] = ^(JSValue* function, JSValue* timeout) {
    dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)([timeout toInt32] * NSEC_PER_MSEC)), dispatch_get_main_queue(), ^{
        [function callWithArguments:@[]];
    });
};

In my case, this allowed me to use cljs.core.async/timeout inside of JavaScriptCore.

Mckibben answered 6/4, 2014 at 16:44 Comment(0)
W
3

I have implemented setTimout, setInterval and clearTimeout on Swift to solve this problem. Usually, the examples show only the setTimeout function without the option to use clearTimeout. If you are using JS dependencies, there's a big chance that you are going to need the clearTimeout and setInterval functions as well.

import Foundation
import JavaScriptCore

let timerJSSharedInstance = TimerJS()

@objc protocol TimerJSExport : JSExport {

    func setTimeout(_ callback : JSValue,_ ms : Double) -> String

    func clearTimeout(_ identifier: String)

    func setInterval(_ callback : JSValue,_ ms : Double) -> String

}

// Custom class must inherit from `NSObject`
@objc class TimerJS: NSObject, TimerJSExport {
    var timers = [String: Timer]()

    static func registerInto(jsContext: JSContext, forKeyedSubscript: String = "timerJS") {
        jsContext.setObject(timerJSSharedInstance,
                            forKeyedSubscript: forKeyedSubscript as (NSCopying & NSObjectProtocol))
        jsContext.evaluateScript(
            "function setTimeout(callback, ms) {" +
            "    return timerJS.setTimeout(callback, ms)" +
            "}" +
            "function clearTimeout(indentifier) {" +
            "    timerJS.clearTimeout(indentifier)" +
            "}" +
            "function setInterval(callback, ms) {" +
            "    return timerJS.setInterval(callback, ms)" +
            "}"
        )       
    }

    func clearTimeout(_ identifier: String) {
        let timer = timers.removeValue(forKey: identifier)

        timer?.invalidate()
    }


    func setInterval(_ callback: JSValue,_ ms: Double) -> String {
        return createTimer(callback: callback, ms: ms, repeats: true)
    }

    func setTimeout(_ callback: JSValue, _ ms: Double) -> String {
        return createTimer(callback: callback, ms: ms , repeats: false)
    }

    func createTimer(callback: JSValue, ms: Double, repeats : Bool) -> String {
        let timeInterval  = ms/1000.0

        let uuid = NSUUID().uuidString

        // make sure that we are queueing it all in the same executable queue...
        // JS calls are getting lost if the queue is not specified... that's what we believe... ;)
        DispatchQueue.main.async(execute: {
            let timer = Timer.scheduledTimer(timeInterval: timeInterval,
                                             target: self,
                                             selector: #selector(self.callJsCallback),
                                             userInfo: callback,
                                             repeats: repeats)
            self.timers[uuid] = timer
        })


        return uuid
    }

Usage Example:

jsContext = JSContext()
TimerJS.registerInto(jsContext: jsContext)

I hope that helps. :)

Wellmeaning answered 5/10, 2016 at 2:3 Comment(1)
Missing callJsCallback: func callJsCallback(timer: Timer) { let callback = (timer.userInfo as! JSValue) callback.call(withArguments: nil) }Shuck
N
2

Don't hang onto a JSContextRefs except for the one you created with JSGlobalContextCreate

Specifically, this is bad:

jsEngine.timeoutCtx =  ctx;
....
JSObjectCallAsFunction(timeoutCtx

Instead of saving ctx, pass your global context to JSObjectCallAsFunction.

You must JSValueProtect any values you want to keep around longer than your callback

The JavaScriptCore garbage collector could run anytime you call a JavaScriptCore function. In your example, the anonymous function created as the single argument to setTimeout is not referenced by anything in JavaScript, which means it could be garbage collected at any point in time after the call to setTimeout completes. setTimeout must therefore JSValueProtect the function to tell JavaScriptCore not to collect it. After you invoke the function with JSObjectCallAsFunction you should then JSValueUnprotect it, otherwise that anonymous function will hang around in memory until the global context is destroyed.

Bonus: you usually should return JSValueMakeUndefined(ctx) if you don't want to return anything from a function

JSValueMakeNull(ctx) is a different than undefined. My general rule is to return JSValueMakeUndefined if I would return void in Objective-C and JSValueMakeNull if I would return a nil object. However, if you want to implement setTimeout like the window object then it needs to return an ID/handle that can be passed to clearTimeout to cancel the timer.

Neuropath answered 7/5, 2013 at 5:46 Comment(1)
An excellent and interesting insight into the internals ... thxVallery
W
2

For registered iOS developer, take a look at the new video about javascript core from wwdc 2013 called "Integrating JavaScript into Native Apps". You will find there the solution for newest iOS version.

My alternative solution, for the current iOS version, was to make a global array in JSC for storing objects that should be protected from garbage collector. So, you have control to pop variable from array when it is not needed any more.

Whittier answered 23/7, 2013 at 11:8 Comment(1)
the link is brokenEtude
P
1

Based on @ninjudd's answer here is what I did in swift

    let setTimeout: @objc_block (JSValue, Int) -> Void = {
        [weak self] (cb, wait) in

        let callback = cb as JSValue

        dispatch_after(dispatch_time(DISPATCH_TIME_NOW, Int64(UInt64(wait) * NSEC_PER_MSEC)), dispatch_get_main_queue(), { () -> Void in
            callback.callWithArguments([])
        })
    }
    context.setObject(unsafeBitCast(setTimeout, AnyObject.self), forKeyedSubscript: "setTimeout")
Prosector answered 15/6, 2015 at 11:18 Comment(0)
S
0

Here is my two cents.

I think there is no need to keep a reference to context in _setTimeout. You can leverage the global context to invoke a timer function later.

You should use JSValueProtect to protect jsEngine.timeoutFunc from GC in _setTimeout. Otherwise it can turn to an invalid reference and cause crash later.

Superphosphate answered 14/4, 2013 at 2:48 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.