Skip to content
Closed
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Prev Previous commit
Next Next commit
src: refactor async callback handling
- Merge the two almost-but-not-quite identical `MakeCallback()`
  implementations
- Provide a public `CallbackScope` class for embedders in order
  to enable `MakeCallback()`-like behaviour without tying that
  to calling a JS function

PR-URL: #14697
Reviewed-By: Tobias Nießen <tniessen@tnie.de>
Reviewed-By: Refael Ackermann <refack@gmail.com>
Reviewed-By: James M Snell <jasnell@gmail.com>
  • Loading branch information
addaleax committed Sep 14, 2017
commit f3ef344ac927a9ad67f35c8d1f0ec6f61ec670b4
69 changes: 2 additions & 67 deletions src/async-wrap.cc
Original file line number Diff line number Diff line change
Expand Up @@ -657,73 +657,8 @@ void AsyncWrap::EmitAsyncInit(Environment* env,
MaybeLocal<Value> AsyncWrap::MakeCallback(const Local<Function> cb,
int argc,
Local<Value>* argv) {
CHECK(env()->context() == env()->isolate()->GetCurrentContext());

Environment::AsyncCallbackScope callback_scope(env());

Environment::AsyncHooks::ExecScope exec_scope(env(),
get_id(),
get_trigger_id());

// Return v8::Undefined() because returning an empty handle will cause
// ToLocalChecked() to abort.
if (env()->using_domains() && DomainEnter(env(), object())) {
return Undefined(env()->isolate());
}

// No need to check a return value because the application will exit if an
// exception occurs.
AsyncWrap::EmitBefore(env(), get_id());

MaybeLocal<Value> ret = cb->Call(env()->context(), object(), argc, argv);

if (ret.IsEmpty()) {
return ret;
}

AsyncWrap::EmitAfter(env(), get_id());

// Return v8::Undefined() because returning an empty handle will cause
// ToLocalChecked() to abort.
if (env()->using_domains() && DomainExit(env(), object())) {
return Undefined(env()->isolate());
}

exec_scope.Dispose();

if (callback_scope.in_makecallback()) {
return ret;
}

Environment::TickInfo* tick_info = env()->tick_info();

if (tick_info->length() == 0) {
env()->isolate()->RunMicrotasks();
}

// Make sure the stack unwound properly. If there are nested MakeCallback's
// then it should return early and not reach this code.
CHECK_EQ(env()->current_async_id(), 0);
CHECK_EQ(env()->trigger_id(), 0);

Local<Object> process = env()->process_object();

if (tick_info->length() == 0) {
tick_info->set_index(0);
return ret;
}

MaybeLocal<Value> rcheck =
env()->tick_callback_function()->Call(env()->context(),
process,
0,
nullptr);

// Make sure the stack unwound properly.
CHECK_EQ(env()->current_async_id(), 0);
CHECK_EQ(env()->trigger_id(), 0);

return rcheck.IsEmpty() ? MaybeLocal<Value>() : ret;
async_context context { get_id(), get_trigger_id() };
return InternalMakeCallback(env(), object(), cb, argc, argv, context);
}


Expand Down
169 changes: 119 additions & 50 deletions src/node.cc
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,7 @@ using v8::SealHandleScope;
using v8::String;
using v8::TryCatch;
using v8::Uint32Array;
using v8::Undefined;
using v8::V8;
using v8::Value;

Expand Down Expand Up @@ -1311,85 +1312,153 @@ void AddPromiseHook(v8::Isolate* isolate, promise_hook_func fn, void* arg) {
env->AddPromiseHook(fn, arg);
}

class InternalCallbackScope {
public:
InternalCallbackScope(Environment* env,
Local<Object> object,
const async_context& asyncContext);
~InternalCallbackScope();
void Close();

inline bool Failed() const { return failed_; }
inline void MarkAsFailed() { failed_ = true; }
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

similar to the other comment, this would read better as MarkAsDisposed().

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@jasnell @trevnorris This isn't called for disposed domains.

inline bool IsInnerMakeCallback() const {
return callback_scope_.in_makecallback();
}

private:
Environment* env_;
async_context async_context_;
v8::Local<v8::Object> object_;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Won't this only work if the class instance is wrapped in a v8::HandleScope? Otherwise it's possible the v8::Local<> handle could be GC'd before the instance is closed.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not sure what you mean by wrapped, but yes, there needs to be a valid HandleScope for the object’s lifetime. That is already ensured because you can’t pass the v8::Local argument to its constructor without one, though.

Environment::AsyncCallbackScope callback_scope_;
bool failed_ = false;
bool pushed_ids_ = false;
bool closed_ = false;
};

MaybeLocal<Value> MakeCallback(Environment* env,
Local<Value> recv,
const Local<Function> callback,
int argc,
Local<Value> argv[],
async_context asyncContext) {
// If you hit this assertion, you forgot to enter the v8::Context first.
CHECK_EQ(env->context(), env->isolate()->GetCurrentContext());
CallbackScope::CallbackScope(Isolate* isolate,
Local<Object> object,
async_context asyncContext)
: private_(new InternalCallbackScope(Environment::GetCurrent(isolate),
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The lifetime of private_ should be within the same v8::HandleScope, correct? In that case should be able to use RAII and not need to allocate a new instance for this.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The intention here is that userland code won’t have access to the fields of InternalCallbackScope, or become dependent on the class layout ABI-wise.

Semantically, InternalCallbackScope and CallbackScope are identical, and their lifetimes are tied together.

object,
asyncContext)),
try_catch_(isolate) {
try_catch_.SetVerbose(true);
}

Local<Object> object;
CallbackScope::~CallbackScope() {
if (try_catch_.HasCaught())
private_->MarkAsFailed();
delete private_;
}

Environment::AsyncCallbackScope callback_scope(env);
bool disposed_domain = false;
InternalCallbackScope::InternalCallbackScope(Environment* env,
Local<Object> object,
const async_context& asyncContext)
: env_(env),
async_context_(asyncContext),
object_(object),
callback_scope_(env) {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this probably stems from a lack of C++ knowledge, but why does passing Environment* here properly cast to the AsyncCallbackScope* that is callback_scope_?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the AsyncCallbackScope* that is callback_scope_

callback_scope_ is a AsyncCallbackScope, not a pointer – it’s a normal call to its constructor

CHECK(!object.IsEmpty());

if (recv->IsObject()) {
object = recv.As<Object>();
}
// If you hit this assertion, you forgot to enter the v8::Context first.
CHECK_EQ(env->context(), env->isolate()->GetCurrentContext());

if (env->using_domains()) {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The assumption here is that env->using_domains() is always true if object_->IsObject(). Mind adding the check above this to make sure that's always the case?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hm? Not quite seeing that. The reverse assumption is present here, i.e. that if object_->IsObject() is true then env->using_domains() is true, but there’s already a CHECK for that, just below this line in the diff.

That assumption is already found in the previous code, and I do find it odd. I think we should either always or never do a CHECK(object->IsObject()); (but that would be semver-major).

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Wish I could go back and take a screenshot of what I was seeing b/c the code I thought I wrote this comment for isn't there anymore. Nm me.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Haha! I found it. Was from one of your previous commits:

+InternalCallbackScope::InternalCallbackScope(Environment* env,
+                                             Local<Value> object,
+                                             const async_context& asyncContext)
+  : env_(env),
+    async_context_(asyncContext),
+    object_(object->IsObject() ? object.As<Object>() : Local<Object>()),
+    callback_scope_(env) {

(I was specifically referring to assigning object_. Since that's no longer there, no need to think about it. :)

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah, right. :)

CHECK(recv->IsObject());
disposed_domain = DomainEnter(env, object);
if (disposed_domain) return Undefined(env->isolate());
failed_ = DomainEnter(env, object_);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The name failed_ doesn't properly represent the return value for DomainEnter(). If the domain callback truly failed then the application will FatalError(). A more appropriate name for this would be disposed_, or similar.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

But, like MakeCallback, this has a lot more failure modes than just trying to enter a disposed domain? There are other cases in which failed_ is set and in which MakeCallback previously bailed out.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Like, if you have suggestions that make sense across the board, I'm all for it. Or we could split this into different boolean values, but that might unnecessarily increase complexity.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

what are the states? would a simple bit mask with a couple flags suffice?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@jasnell Currently and previously, those are:

  • Domain enter/exit callback was acting on a disposed domain
  • The invoked callback itself threw
  • Calling process.nextTick() afterwards threw

would a simple bit mask with a couple flags suffice?

@jasnell Just to be clear, we would be setting different bits in a bit mask, but would never inspect those values, only care whether one was set. Am I understanding correctly that that's what you're suggesting?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So failed_ handles all failure cases. I see.

The reason the domain callbacks wrap the async wrap callbacks is that if the domain is disposed then MakeCallback() can return immediately. There's no cleanup that needs to be performed. Though I guess because of the way the code is now structured that fact does need to propagate.

if (failed_)
return;
}

MaybeLocal<Value> ret;
if (asyncContext.async_id != 0) {
// No need to check a return value because the application will exit if
// an exception occurs.
AsyncWrap::EmitBefore(env, asyncContext.async_id);
}

{
AsyncHooks::ExecScope exec_scope(env, asyncContext.async_id,
asyncContext.trigger_async_id);
env->async_hooks()->push_ids(async_context_.async_id,
async_context_.trigger_async_id);
pushed_ids_ = true;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't see how pushed_ids_ in Close() could be false. Mind clarifying?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

pushed_ids_ is only true if the InternalCallbackScope constructor finishes, but that doesn’t happen if DomainEnter() reported that we’re trying to enter a disposed domain.

}

if (asyncContext.async_id != 0) {
// No need to check a return value because the application will exit if
// an exception occurs.
AsyncWrap::EmitBefore(env, asyncContext.async_id);
}
InternalCallbackScope::~InternalCallbackScope() {
Close();
}

ret = callback->Call(env->context(), recv, argc, argv);
void InternalCallbackScope::Close() {
if (closed_) return;
closed_ = true;

if (ret.IsEmpty()) {
// NOTE: For backwards compatibility with public API we return Undefined()
// if the top level call threw.
return callback_scope.in_makecallback() ?
ret : Undefined(env->isolate());
}
if (pushed_ids_)
env_->async_hooks()->pop_ids(async_context_.async_id);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have a patch for an error here. Will get you the commit later today.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not sure what to say here, you can feel free to post the patch or let me know what the problem is (and whether it’s a new one).

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

sorry. that comment isn't a blocker. was a self-reminder b/c i thought the fix depended on this PR.


if (asyncContext.async_id != 0) {
AsyncWrap::EmitAfter(env, asyncContext.async_id);
}
if (failed_) return;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note to look at later: This may need to go before the pushed_ids_ check because the call to _fatalExpression will automatically call all the after() callbacks and drain the queue.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This matches the previous behaviour, and I think that’s okay because you already explicitly accounted for that situation in pop_ids().


if (async_context_.async_id != 0) {
AsyncWrap::EmitAfter(env_, async_context_.async_id);
}

if (env->using_domains()) {
disposed_domain = DomainExit(env, object);
if (disposed_domain) return Undefined(env->isolate());
if (env_->using_domains()) {
failed_ = DomainExit(env_, object_);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks like DomainEnter/DomainExit are both still defined in src/async-wrap.cc. Since they're now only used in src/node.cc mind moving them over?

if (failed_) return;
}

if (callback_scope.in_makecallback()) {
return ret;
if (IsInnerMakeCallback()) {
return;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is purely my personal opinion of having balance between what runs entering and exiting the scope, but I feel that from here down should be put into it's own method (e.g. PostProcessing()) then at the end of InternalMakeCallback() do the IsInnerMakeCallback() check after scope.Close() then run it.

again, this isn't a technical issue, but it jumps at me every time I see it.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I’m not 100 % following – you mean, move this from scope.Close() to InternalMakeCallback()? That would defeat the whole purpose of exposing CallbackScope, though…

}

Environment::TickInfo* tick_info = env->tick_info();
Environment::TickInfo* tick_info = env_->tick_info();

if (tick_info->length() == 0) {
env->isolate()->RunMicrotasks();
env_->isolate()->RunMicrotasks();
}

// Make sure the stack unwound properly. If there are nested MakeCallback's
// then it should return early and not reach this code.
CHECK_EQ(env->current_async_id(), asyncContext.async_id);
CHECK_EQ(env->trigger_id(), asyncContext.trigger_async_id);
CHECK_EQ(env_->current_async_id(), 0);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Question: why is this correct? the stack unwinds completely?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yup, this is only executed for the bottom-most callback scope.

CHECK_EQ(env_->trigger_id(), 0);

Local<Object> process = env->process_object();
Local<Object> process = env_->process_object();

if (tick_info->length() == 0) {
tick_info->set_index(0);
return ret;
return;
}

CHECK_EQ(env_->current_async_id(), 0);
CHECK_EQ(env_->trigger_id(), 0);

if (env_->tick_callback_function()->Call(process, 0, nullptr).IsEmpty()) {
failed_ = true;
}
}

MaybeLocal<Value> InternalMakeCallback(Environment* env,
Local<Object> recv,
const Local<Function> callback,
int argc,
Local<Value> argv[],
async_context asyncContext) {
InternalCallbackScope scope(env, recv, asyncContext);
if (scope.Failed()) {
return Undefined(env->isolate());
}

MaybeLocal<Value> ret;

{
ret = callback->Call(env->context(), recv, argc, argv);

if (ret.IsEmpty()) {
// NOTE: For backwards compatibility with public API we return Undefined()
// if the top level call threw.
scope.MarkAsFailed();
return scope.IsInnerMakeCallback() ? ret : Undefined(env->isolate());
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I question if this is necessary anymore if we're returning a MaybeLocal<>. The only time Undefined() is returned is in the case of InternalMakeCallback() being wrapped in a TryCatch, and is not rethrown. In which case I'd assume the caller would be planning on handling the error anyway.

It may be considered a semver-major, but looks like always returning ret would simplify code elsewhere.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The only time Undefined() is returned is in the case of InternalMakeCallback() being wrapped in a TryCatch, and is not rethrown.

Why is that?

It may be considered a semver-major, but looks like always returning ret would simplify code elsewhere.

Yes, MakeCallback should always return an empty MaybeLocal in case of a thrown exception, but yes, that would be up to a semver-major follow-up PR.

Copy link
Copy Markdown
Contributor

@trevnorris trevnorris Aug 18, 2017

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The only time Undefined() is returned is in the case of InternalMakeCallback() being wrapped in a TryCatch, and is not rethrown.

Why is that?

That's what testing has shown. c++ file:

double zid = 41;

static void Run(const v8::FunctionCallbackInfo<v8::Value>& args) {
  auto isolate = args.GetIsolate();
  auto fn = args[0].As<Function>();
  auto obj = args[1].As<Object>();

  MaybeLocal<Value> ret =
    node::MakeCallback(isolate, obj, fn, 0, nullptr, { ++zid, ++zid });
  fprintf(stderr, "IsEmpty(): %s\n", ret.IsEmpty() ? "true" : "false");
}

js file:

const release_type = process.config.target_defaults.default_configuration;
const addon = require(`./build/${release_type}/addon`);
const async_hooks = require('async_hooks');
const print = (...a) => require('fs').writeSync(1, require('util').format(...a));


function a() {
  print(`a id: ${async_hooks.executionAsyncId()}   ret: ${addon.run(b, {})}\n`);
}
function b() {
  print(`b id: ${async_hooks.executionAsyncId()}   ret: ${addon.run(c, {})}\n`);
}
function c() {
  print(`c id: ${async_hooks.executionAsyncId()}\n`);
  throw new Error('crap');
}

addon.run(a);

output:

$ node run.js
c id: 46
IsEmpty(): true
IsEmpty(): true
IsEmpty(): true
/var/projects/tmp/node-basic-module/run.js:17
  throw new Error('crap');
  ^

Error: crap

And actually, even if the call is wrapped in a TryCatch the first return is still an empty handle. Basically, I can't figure out a scenario where the callback throws and an empty handle isn't returned automatically.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Basically, I can't figure out a scenario where the callback throws and an empty handle isn't returned automatically.

When there is no JS stack below the call ;) #14922 adds a test for it.

}
}

if (env->tick_callback_function()->Call(process, 0, nullptr).IsEmpty()) {
scope.Close();
if (scope.Failed()) {
return Undefined(env->isolate());
}

Expand Down Expand Up @@ -1442,8 +1511,8 @@ MaybeLocal<Value> MakeCallback(Isolate* isolate,
// the two contexts need not be the same.
Environment* env = Environment::GetCurrent(callback->CreationContext());
Context::Scope context_scope(env->context());
return MakeCallback(env, recv.As<Value>(), callback, argc, argv,
asyncContext);
return InternalMakeCallback(env, recv, callback,
argc, argv, asyncContext);
}


Expand Down
40 changes: 40 additions & 0 deletions src/node.h
Original file line number Diff line number Diff line change
Expand Up @@ -570,6 +570,36 @@ NODE_EXTERN async_context EmitAsyncInit(v8::Isolate* isolate,
NODE_EXTERN void EmitAsyncDestroy(v8::Isolate* isolate,
async_context asyncContext);

class InternalCallbackScope;

/* This class works like `MakeCallback()` in that it sets up a specific
* asyncContext as the current one and informs the async_hooks and domains
* modules that this context is currently active.
*
* `MakeCallback()` is a wrapper around this class as well as
* `Function::Call()`. Either one of these mechanisms needs to be used for
* top-level calls into JavaScript (i.e. without any existing JS stack).
*
* This object should be stack-allocated to ensure that it is contained in a
* valid HandleScope.
*/
class NODE_EXTERN CallbackScope {
public:
CallbackScope(v8::Isolate* isolate,
v8::Local<v8::Object> resource,
async_context asyncContext);
~CallbackScope();

private:
InternalCallbackScope* private_;
v8::TryCatch try_catch_;

void operator=(const CallbackScope&) = delete;
void operator=(CallbackScope&&) = delete;
CallbackScope(const CallbackScope&) = delete;
CallbackScope(CallbackScope&&) = delete;
};

/* An API specific to emit before/after callbacks is unnecessary because
* MakeCallback will automatically call them for you.
*
Expand Down Expand Up @@ -659,6 +689,16 @@ class AsyncResource {
async_id get_trigger_async_id() const {
return async_context_.trigger_async_id;
}

protected:
class CallbackScope : public node::CallbackScope {
public:
explicit CallbackScope(AsyncResource* res)
: node::CallbackScope(res->isolate_,
res->resource_.Get(res->isolate_),
res->async_context_) {}
};

private:
v8::Isolate* isolate_;
v8::Persistent<v8::Object> resource_;
Expand Down
8 changes: 8 additions & 0 deletions src/node_internals.h
Original file line number Diff line number Diff line change
Expand Up @@ -286,6 +286,14 @@ static v8::MaybeLocal<v8::Object> New(Environment* env,
}
} // namespace Buffer

v8::MaybeLocal<v8::Value> InternalMakeCallback(
Environment* env,
v8::Local<v8::Object> recv,
const v8::Local<v8::Function> callback,
int argc,
v8::Local<v8::Value> argv[],
async_context asyncContext);

} // namespace node

#endif // defined(NODE_WANT_INTERNALS) && NODE_WANT_INTERNALS
Expand Down
Loading