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
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 toAudioObjectAddPropertyListener()
as thecontext
which is avoid*
, so nothing is retaining that object. Instead, try passing it as(__bridge_retained void*)jsCb
orCFBridgingRetain(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_transfer
orCFRelease
/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 yourInitialize
function) or creating it inNODE_MODULE_INITIALIZER
for a "context-aware addon". Then you could use__bridge
when passing this object asvoid* context
and not worry about retaining and releasing.