Storing a JS Callback to be called later in Node C++ Addon

388 Views Asked by At

I'm creating a Node C++ addon for macOS, so I'm using Objective-C mixed with C++ and the Node Addon API.

I want to provide a function for Node JS, that receives a callback to be called later when an Obj-C observer is called. This is how I tried to achieve this:

#include <node.h>
#import <Foundation/Foundation.h>
#import <AVFoundation/AVFoundation.h>

@interface JSCallback:NSObject {
  //Instance variables
  NSString *testStr;
  v8::Local<v8::Context> context;
  v8::Local<v8::Function> fn;
  v8::Isolate* isolate;
}
@property(retain, nonatomic, readwrite) NSString *testStr;
@property(nonatomic, readwrite) v8::Local<v8::Context> context;
@property(nonatomic, readwrite) v8::Local<v8::Function> fn;
@property(nonatomic, readwrite) v8::Isolate* isolate;
@end

@implementation JSCallback
@synthesize testStr;
@synthesize context;
@synthesize fn;
@synthesize isolate;
@end

namespace demo {

using v8::FunctionCallbackInfo;
using v8::Isolate;
using v8::Local;
using v8::Object;
using v8::String;
using v8::Function;
using v8::Context;
using v8::Value;

static OSStatus audioOutputDeviceChanged(
  AudioObjectID inObjectID,
  UInt32 inNumberAddresses,
  const AudioObjectPropertyAddress* inAddresses,
  void* __nullable inClientData
) {
  JSCallback *jsCb = *((__unsafe_unretained JSCallback **)(&inClientData));
  printf("%s", [jsCb.testStr UTF8String]);
  jsCb.fn->Call(jsCb.context, Null(jsCb.isolate), 0, {}).ToLocalChecked();
  return noErr;
}

void setOnAudioOutputDeviceChange(const FunctionCallbackInfo<Value>& args) {
  AudioObjectPropertyAddress address = {
    kAudioHardwarePropertyDefaultOutputDevice,
    kAudioObjectPropertyScopeGlobal,
    kAudioObjectPropertyElementMaster,
  };

  Isolate* isolate = args.GetIsolate();
  Local<Context> context = isolate->GetCurrentContext();
  Local<Function> cb = Local<Function>::Cast(args[0]);
  // cb->Call(context, Null(isolate), 0, {}).ToLocalChecked();

  JSCallback *jsCb = [[JSCallback alloc]init];

  jsCb.testStr = @"a test string #002";
  jsCb.context = context;
  jsCb.fn = cb;
  jsCb.isolate = isolate;


  OSStatus status = AudioObjectAddPropertyListener(
    kAudioObjectSystemObject,
    &address,
    &audioOutputDeviceChanged,
    jsCb
  );

  if (status != noErr) {
    NSException *e = [NSException
      exceptionWithName:@"OSStatus Error"
      reason:[NSString stringWithFormat:@"OSStatus Error (status: %d)", status]
      userInfo:nil];

    @throw e;
  }
}

void Initialize(Local<Object> exports) {
  NODE_SET_METHOD(exports, "setOnAudioOutputDeviceChange", setOnAudioOutputDeviceChange);
}

NODE_MODULE(NODE_GYP_MODULE_NAME, Initialize)

}

I created the JSCallback class to hold the variables needed to trigger the JS callback function. And then pass it as the inClientData for AudioObjectAddPropertyListener.

Then when audioOutputDeviceChanged is called, I try to trigger the JS callback using the variables I stored. However, when I do so, the JS script crashes, and only prints the following (no stack trace):

#
# Fatal error in v8::HandleScope::CreateHandle()
# Cannot create a handle without a HandleScope
#

I think this could be happening because when setOnAudioOutputDeviceChange returns, it deallocates (or something of the sort) the variables (context, cb, and isolate). And thus they're unusable outside of the function. How can I get around this?

If needed, here's my JS code that uses the addon:

const addon = require('./addon/build/Release/addon');

addon.setOnAudioOutputDeviceChange(() => {
  console.info('setOnAudioOutputDeviceChange called');
});

setTimeout(() => {
  // This keeps the JS script alive for some time
  console.log('timedout');
}, 200000);

And this is my binding.gyp file, though I suspect it's relevant:

{
  "targets": [
    {
      "target_name": "addon",
      "sources": [
        "addon.mm",
      ],
      "xcode_settings": {
        "OTHER_CFLAGS": [
          "-ObjC++",
        ],
      },
      "link_settings": {
        "libraries": [
          "-framework Foundation",
          "-framework AVFoundation",
        ],
      },
    },
  ],
}
1

There are 1 best solutions below

0
On

Your diagnosis seems plausible to me: "when setOnAudioOutputDeviceChange returns, it deallocates (or something of the sort) the variables (context, cb, and isolate). And thus they're unusable outside of the function".

Those variables are held by the jsCb object, but you pass it in to AudioObjectAddPropertyListener() as the context which is a void*, so nothing is retaining that object. Instead, try passing it as (__bridge_retained void*)jsCb or CFBridgingRetain(jsCb), which will increment the reference count before adding the callback. Then you can retrieve it in your audioOutputDeviceChanged function as JSCallback *jsCb = (__bridge JSCallback *)inClientData;, which will neither retain nor release it. Finally, when removing the audio property listener you should release the object via __bridge_transfer or CFRelease/CFBridgingRelease.

Bridged casts are described in documentation here and here and you can find more examples by searching Stack Overflow and elsewhere.

Alternatively, rather than manually retaining/releasing the Obj-C object, you could restructure your code so that the Obj-C object lives as long as the whole Node module, by making a single static object that you initialize once (for example in your Initialize function) or creating it in NODE_MODULE_INITIALIZER for a "context-aware addon". Then you could use __bridge when passing this object as void* context and not worry about retaining and releasing.