async function external stack context
Asked Answered
B

3

2

Sometimes code would like to know if a particular function (or children) are running or not. For instance, node.js has domains which works for async stuff as well (not sure if this includes async functions).

Some simple code to explain what I need would by like this:

inUpdate = true;
try {
  doUpdate();
} finally {
  inUpdate = false;
}

This could then be used something like:

function modifyThings() {
  if (inUpdate) throw new Error("Can't modify while updating");
}

With the advent of async this code breaks if the doUpdate() function is asynchronous. This was of course already true using callback-style functions.

The doUpdate function could of course be patched to maintain the variable around every await, but even if you have control over the code, this is cumbersome and error prone and this breaks when trying to track async function calls inside doUpdate.

I tried monkey-patching Promise.prototype:

const origThen = Promise.prototype.then;
Promise.prototype.then = function(resolve, reject) {
  const isInUpdate = inUpdate;
  origThen.call(this, function myResolve(value) {
    inUpdate = isInUpdate;
    try {
      return resolve(value);
    } finally {
      inUpdate = false;
    }
  }, reject);
}

Unfortunately this doesn't work. I'm not sure why, but the async continuation code ends up running outside of the resolve call stack (probably using a microtask).

Note that it's not enough to simply do:

function runUpdate(doUpdate) {
  inUpdate = true;
  doUpdate.then(() => inUpdate = false).catch(() => inUpdate = false);
}

The reason is:

runUpdate(longAsyncFunction);
console.log(inUpdate); // incorrectly returns true

Is there any way to track something from outside an async function so it's possible to tell if the function called, or any of its descendant calls are running?

I know that it's possible to simulate async functions with generators and yield, in which case we have control over the call stack (since we can call gen.next()) but this is a kludge which the advent of async functions just got around to solving, so I'm specifically looking for a solution that works with native (not Babel-generated) async functions.

Edit: To clarify the question: Is there's a way for outside code to know if a particular invocation of an async function is running or if it is suspended, assuming that this code is the caller of the async function. Whether it's running or not would be determined by a function that ultimately is called by the async function (somewhere in the stack).

Edit: To clarify some more: The intended functionality would be the same as domains in node.js, but also for the browser. Domains already work with Promises, so async functions probably work as well (not tested).

Bosky answered 12/3, 2018 at 17:30 Comment(1)
Your question still doesn't make any sense. Again, are you asking for a way to tell if an async invocation is queued?Oarlock
B
2

This code allows me to do what I want to a certain extent:

function installAsyncTrack() {
  /* global Promise: true */
  if (Promise.isAsyncTracker) throw new Error('Only one tracker can be installed');

  const RootPromise = Promise.isAsyncTracker ? Promise.rootPromise : Promise;
  let active = true;

  const tracker = {
    track(f, o, ...args) {
      const prevObj = tracker.trackObj;
      tracker.trackObj = o;
      try {
        return f.apply(this, args);
      } finally {
        tracker.trackObj = prevObj;
      }
    },
    trackObj: undefined,
    uninstall() {
      active = false;
      if (Promise === AsyncTrackPromise.prevPromise) return;
      if (Promise !== AsyncTrackPromise) return;
      Promise = AsyncTrackPromise.prevPromise;
    }
  };

  AsyncTrackPromise.prototype = Object.create(Promise);
  AsyncTrackPromise.rootPromise = RootPromise;
  AsyncTrackPromise.prevPromise = Promise;
  Promise = AsyncTrackPromise;
  AsyncTrackPromise.resolve = value => {
    return new AsyncTrackPromise(resolve => resolve(value));
  };
  AsyncTrackPromise.reject = val => {
    return new AsyncTrackPromise((resolve, reject) => reject(value));
  };
  AsyncTrackPromise.all = iterable => {
    const promises = Array.from(iterable);
    if (!promises.length) return AsyncTrackPromise.resolve();
    return new AsyncTrackPromise((resolve, reject) => {
      let rejected = false;
      let results = new Array(promises.length);
      let done = 0;
      const allPromises = promises.map(promise => {
        if (promise && typeof promise.then === 'function') {
          return promise;
        }
        return new AsyncTrackPromise.resolve(promise);
      });
      allPromises.forEach((promise, ix) => {
        promise.then(value => {
          if (rejected) return;
          results[ix] = value;
          done++;
          if (done === results.length) {
            resolve(results);
          }
        }, reason => {
          if (rejected) return;
          rejected = true;
          reject(reason);
        });
      });
    });
  };
  AsyncTrackPromise.race = iterable => {
    const promises = Array.from(iterable);
    if (!promises.length) return new AsyncTrackPromise(() => {});
    return new AsyncTrackPromise((resolve, reject) => {
      let resolved = false;
      if (promises.some(promise => {
          if (!promise || typeof promise.then !== 'function') {
            resolve(promise);
            return true;
          }
        })) return;
      promises.forEach((promise, ix) => {
        promise.then(value => {
          if (resolved) return;
          resolved = true;
          resolve(value);
        }, reason => {
          if (resolved) return;
          resolved = true;
          reject(reason);
        });
      });
    });
  };

  function AsyncTrackPromise(handler) {
    const promise = new RootPromise(handler);
    promise.trackObj = tracker.trackObj;

    promise.origThen = promise.then;
    promise.then = thenOverride;

    promise.origCatch = promise.catch;
    promise.catch = catchOverride;

    if (promise.finally) {
      promise.origFinally = promise.finally;
      promise.finally = finallyOverride;
    }
    return promise;
  }

  AsyncTrackPromise.isAsyncTracker = true;

  function thenOverride(resolve, reject) {
    const trackObj = this.trackObj;
    if (!active || trackObj === undefined) return this.origThen.apply(this, arguments);
    return this.origThen.call(
      this,
      myResolver(trackObj, resolve),
      reject && myResolver(trackObj, reject)
    );
  }

  function catchOverride(reject) {
    const trackObj = this.trackObj;
    if (!active || trackObj === undefined) return this.origCatch.catch.apply(this, arguments);
    return this.origCatch.call(
      this,
      myResolver(trackObj, reject)
    );
  }

  function finallyOverride(callback) {
    const trackObj = this.trackObj;
    if (!active || trackObj === undefined) return this.origCatch.catch.apply(this, arguments);
    return this.origCatch.call(
      this,
      myResolver(trackObj, reject)
    );
  }

  return tracker;

  function myResolver(trackObj, resolve) {
    return function myResolve(val) {
      if (trackObj === undefined) {
        return resolve(val);
      }
      RootPromise.resolve().then(() => {
        const prevObj = tracker.trackObj;
        tracker.trackObj = trackObj;
        RootPromise.resolve().then(() => {
          tracker.trackObj = prevObj;
        });
      });
      const prevObj = tracker.trackObj;
      tracker.trackObj = trackObj;
      try {
        return resolve(val);
      } finally {
        tracker.trackObj = prevObj;
      }
    };
  }

}

tracker = installAsyncTrack();

function track(func, value, ...args) {
  return tracker.track(func, { value }, value, ...args);
}

function show(where, which) {
  console.log('At call', where, 'from', which, 'the value is: ', tracker.trackObj && tracker.trackObj.value);
}

async function test(which, sub) {
  show(1, which);
  await delay(Math.random() * 100);
  show(2, which);
  if (sub === 'resolve') {
    await Promise.resolve(test('sub'));
    show(3, which);
  }
  if (sub === 'call') {
    await test(which + ' sub');
    show(3, which);
  }
}

function delay(ms) {
  return new Promise(resolve => setTimeout(resolve, ms));
}

track(test, 'test1');
track(test, 'test2');
track(test, 'test3', 'resolve');
track(test, 'test4', 'call');

It replaces the native Promise with my own. This promise stores the current context (taskObj) on the promise.

When the .then callback or its ilk are called, it does the following:

  • It creates a new native promise that immediately resolves. This adds a new microtask to the queue (according to spec, so should be reliable).

  • It calls the original resolve or reject. At least in Chrome and Firefox, this generates another microtask onto the queue that will run next part of the async function. Not sure what the spec has to say about this yet. It also restores the context around the call so that if it's not await that uses it, no microtask gets added here.

  • The first microtask gets executed, which is my first (native) promise being resolved. This code restores the current context (taskObj). It also creates a new resolved promise that queues another microtask

  • The second microtask (if any) gets executed, running the JS in the async function to until it hits the next await or returns.

  • The microtask queued by the first microtask gets executed, which restores the context to what it was before the Promise resolved/rejected (should always be undefined, unless set outside a tracker.track(...) call).

If the intercepted promise is not native (e.g. bluebird), it still works because it restores the state during the resolve(...) (and ilk) call.

There's one situation which I can't seem to find a solution for:

tracker.track(async () => {
  console.log(tracker.taskObj); // 'test'
  await (async () => {})(); //This breaks because the promise generated is native
  console.log(tracker.taskObj); // undefined
}, 'test')

A workaround is to wrap the promise in Promise.resolve():

tracker.track(async () => {
  console.log(tracker.taskObj); // 'test'
  await Promise.resolve((async () => {})());
  console.log(tracker.taskObj); // undefined
}, 'test')

Obviously, a lot of testing for all the different environments is needed and the fact that a workaround for sub-calls is needed is painful. Also, all Promises used need to either be wrapped in Promise.resolve() or use the global Promise.

Bosky answered 12/3, 2018 at 22:33 Comment(2)
I know this is a really old comment, but what should the above output look like for the sub-calls? They appear to be losing context for me before they are getting called?Tinworks
I have given up on this. Note the "to a certain extent" disclaimer. I'm looking for a compiler solution now, that will maintain context by explicitly managing the context around awaits. Note that there is chatter of stack context as a language feature. I don't have a link right now though. It's also not ready to use.Bosky
I
0

[is it] possible to tell if the function called, or any of its descendant calls are running?

Yes. The answer is always no. Cause there is only one piece of code running at a time. Javascript is single threaded per definition.

Iridectomy answered 12/3, 2018 at 17:52 Comment(10)
This doesn't answer the question and I'm not sure why you're mentioning that JS is single threaded as it doesn't seem to be relevant here.Bosky
@Bosky it's quite relevant, JavaScript has run-to-completion semantics. You don't want to know if those functions are running, you seem to want to know if they're queued.Oarlock
But async functions don't run always completion before dropping back to the event loop, they are suspended. The question is if there's a way for outside code to know if a particular invocation of an async function is running or if it is suspended.Bosky
@Bosky not true, they always run until yield, return, or the end of the block. Always. Avoiding the exact scenario you're talking about is why they refused to add coroutines.Oarlock
Indeed. I meant completion of the function.Bosky
@Bosky it's not really clear at all what you're asking here. Are you asking for a way to tell if a generator is exhausted? Because that's the only scenario I can think of where code doesn't run 'to the end of the function'.Oarlock
@Bosky then why // incorrectly returns true ? the promise is a pending state thereIridectomy
It's incorrect because doUpdate isn't running, it's suspended. The inUpdate variable is meant to indicate whether we're inside the doUpdate function. If doUpdate calls some function, this function should be able to determine if doUpdate called it, or if some other function called it by checking inUpdate.Bosky
As we've been trying to tell you, there's no such thing as suspension of execution in JavaScript. Not even in a generator, although that's the closest thing. Certainly not in an async function. I'm voting to close this.Oarlock
I'm fairly sure that exactly as in a generator waiting for a next() call, an async function is suspended until an awaited promise is resolved. The local variables are still valid, for instance. The function isn't stopped, but it's interrupted nonetheless. I understand that the JS simply drops back to the event loop when an await is encountered. But the problem I'm trying to solve is the flag only being set when it's examined from within the call stack of the doUpdate function and not from another stack, i.e. new Error().stack would contain the doUpdate function.Bosky
A
0

Don't make it any more complicated than it needs to be. If doUpdate returns a promise (like when it is an async function), just wait for that:

inUpdate = true;
try {
  await doUpdate();
//^^^^^
} finally {
  inUpdate = false;
}

You can also use the finally Promise method:

var inUpdate = true;
doUpdate().finally(() => {
  inUpdate = false;
});

That'll do just like like your synchronous code, having inUpdate == true while the function call or any of its descendants are running. Of course that only works if the asynchronous function doesn't settle the promise before it is finished doing its thing. And if you feel like the inUpdate flag should only be set during some specific parts of the doUpdate function, then yes the function will need to maintain the flag itself - just like it is the case with synchronous code.

Arndt answered 12/3, 2018 at 18:34 Comment(12)
This doesn't work because the inUpdate flag will be true also when called from outside the function, until the doUpdate function returns/resolves.Bosky
@Bosky What do you mean by "from outside", isn't "until the doUpdate function returns" exactly what you want? What is your actual, practical problem that you need this for?Arndt
I currently need it to track changes made by a function as opposed to changes made by some other function, without cooperation of the function. So, if a function calls makeChange(newData) I need to attribute it to the correct caller.Bosky
@Bosky That's the callstacks are made for - track who called you. Just use new Error().stack, it should work even with async functions. There's no better API for callstacks yet unfortunately.Arndt
There's no way to know which line matches the function because I don't know ahead of time which function will be passed in. And even if I did, I don't know which invocation instance it is, which is important for tracking changes.Bosky
@Bosky Your question really sounded like you were looking for a semaphore, to place around a function that you call, not for something in your function that gets called from a possibly async function. You might want to edit it.Arndt
It is, but in particular, the doUpdate function is a function passed in and not part of the tracking code I'm trying to build.Bosky
@Bosky It's fundamentally impossible to distinguish call instances from each other without cooperation by the function that is called. Maybe apart from using the debugging protocol of your runtime engine.Arndt
In node.js, domains make it work. You can get the current domain and store any data on it, such as an inUpdate flag or an object that tracks all modifications made during the call. Apparently it even works with async functions.Bosky
@Bosky Well if you can wrap the call that will call your function in your own domain, then yes. Sounds like domains are what you want, so why don't you use them - are you looking for a non-node solution? Maybe you're also interested in Realms.Arndt
I'm looking for a general solution that works everywhere. It should work with node.js, native async and Babel async.Bosky
@Bosky I don't think you'll find one, especially for older browsers. Of course, Babel can do anything you want about uncooperative code :-)Arndt

© 2022 - 2024 — McMap. All rights reserved.