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",
],
},
},
],
}
Your diagnosis seems plausible to me: "when
setOnAudioOutputDeviceChangereturns, 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
jsCbobject, but you pass it in toAudioObjectAddPropertyListener()as thecontextwhich is avoid*, so nothing is retaining that object. Instead, try passing it as(__bridge_retained void*)jsCborCFBridgingRetain(jsCb), which will increment the reference count before adding the callback. Then you can retrieve it in your audioOutputDeviceChanged function asJSCallback *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_transferorCFRelease/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
staticobject that you initialize once (for example in yourInitializefunction) or creating it inNODE_MODULE_INITIALIZERfor a "context-aware addon". Then you could use__bridgewhen passing this object asvoid* contextand not worry about retaining and releasing.