Lifetime of JavaScript Native Object

There are times we want to expose native APIs to JavaScript world to enrich its capabilities, which can usually be done by calling JS VM’s API, like V8 embedder API (Node-API) or JavaScriptCore API. It’s usually an easy task when the exposed API is clean and simple, but things can get complicated when you have complex references between JavaScript and C++. In this article, I will share some techniques to help you manage JavaScript Native Object’s lifetime.

Example case

Imagine you need to implement an EventEmitter in DOM Node API, it’s usage is like the following:

const node = new Node()
node.addEventListener('touchstart', () => {
  console.log('touchstart event fired in node:', node)
})

The addEventListener native method can be roughly implemented in C++ like the following code snippet, it’ll receive two parameters and the second one is the callback function.

void AddEventListener(
    const v8::FunctionCallbackInfo<v8::Value>& args) {
  auto* isolate = args.GetIsolate();
  auto* self = Unwrap(args.This());
  // we don't care about the event name for now.
  v8::Local<v8::Object> name = args[0];
  v8::Local<v8::Object> callback =
      args[1]->ToObject(isolate->GetCurrentContext()).ToLocalChecked();
}

We know that the callback function is asynchronous, which means it will be invoked at any time when the node is touched by user. So here comes the question: where should we store the callback function? As you may probably know, the v8::Local<T> is a temporary stack-allocated handle, it can not be accessed after the AddEventListener native function finishes its execution.

Be careful of Persistent Handle

Actually, v8::Persistent<T> is one of the solutions (not really). To put it simplely, one of the usage of v8::Persistent<T> is to make the T value outlive the V8 GC (somewhat like a global variable). So that in this case, we can obtain callback function via v8::Persistent<T> at any time. Yes, it satisfies our needs. We can assign callback to callback_(which is a v8::Persistent handle), and then access the callback_ later in the FireCallback method. callback_ will be Reset (release) when Node is released.

class Node {
  public:
  ~Node() {
    callback_.Reset();
  }

  void AddEventListener(
      const v8::FunctionCallbackInfo<v8::Value>& args) {
    // ...
    callback_.Reset(isolate, callback);
    callback_.ClearWeak();
  }

  void FireCallback() {
    auto callback = g_callback.Get(g_isolate).As<v8::Function>();
    // ThisObject() is fake, it returns the JS this object.
    callback->Call(g_context, ThisObject(), 0, nullptr)
        .ToLocalChecked();
  }

private:
  v8::Persistent<v8::Object> callback_;
};

So currently the EventEmitter method in the DOM Node API is correctly implemented, right? Actually, not really! There is a fatal issue: Memory Leak in the callback. But how does that happen? Let’s recall the JS example above, we printed out node itself in the event callback (which is commonly seen in JS), which forms a closure that contains a reference to the node instance. Since the callback is a persistent handle, which is free from being GCed, the callback is retained by the node, node is referenced in the closure. Cyclic Reference! no one is going to die. Although in pure JS, circular reference is fine, because they will be GC sweeped out all together. Whereas in this case, the callback is made free from GC, and it also makes the node reachable, in which case it is unable to be GCed.

It’s a fairly typical misuse of the V8 API, which will make you frustrated to pinpoint the memory leak. How can be solve this problem then?

Let GC help you

We all know that GC is absolutely good at circular reference, why don’t we make GC to tackle this problem? What we need is to make the callback function outlive node instance so that it can be safely whenever you want. One simple way is to make node instance reference to callback function, so that when node is alive, the callback function is also alive (GC reachable).

void AddEventListener(
    const v8::FunctionCallbackInfo<v8::Value>& args) {
  // ...
  auto private_key = v8::Private::New(isolate);
  ThisObject().SetPrive(context, private_key, callback);
}

We make the callback function a private member of node instance, so that they’ll co-alive. Problem solved!

To be continued…