17

I'm trying to write a function that will reintroduce a stack trace when an object literal is thrown. (See this related question).

What I've noticed is that if a pass an async function as a callback into another async caller function, if the caller function has a try/catch, and catches any errors, and throws a new Error, then the stack trace gets lost.

I've tried several variants of this:

function alpha() {
  throw Error("I am an error!");
}

function alphaObectLiberal() {
  throw "I am an object literal!";  //Ordinarily this will cause the stack trace to be lost. 
}

function syncFunctionCaller(fn) {
  return fn();
}

function syncFunctionCaller2(fn) { //This wrapper wraps it in a proper error and subsequently preserves the stack trace. 
  try {
    return fn();
  } catch (err) {
    throw new Error(err); //Stack trace is preserved when it is synchronous. 
  }
}


async function asyncAlpha() {
  throw Error("I am also an error!"); //Stack trace is preseved if a proper error is thown from callback
}

async function asyncAlphaObjectLiteral() {
  throw "I am an object literal!"; //I want to catch this, and convert it to a proper Error object. 
}

async function asyncFunctionCaller(fn) {
  return await fn();
}

async function asyncFunctionCaller2(fn) {
  try {
    await fn();
  } catch (err) {
    throw new Error(err);
  }
}

async function asyncFunctionCaller3(fn) {
  try {
    await fn();
  } catch (err) {
    throw new Error("I'm an error thrown from the function caller!");
  }
}

async function asyncFunctionCaller4(fn) {
  throw new Error("No try catch here!");
}

async function everything() {
  try {
    syncFunctionCaller(alpha);
  } catch (err) {
    console.log(err);
  }


  try {
    syncFunctionCaller2(alphaObectLiberal);
  } catch (err) {
    console.log(err);
  }

  try {
    await asyncFunctionCaller(asyncAlpha);
  } catch (err) {
    console.log(err);
  }

  try {
    await asyncFunctionCaller2(asyncAlphaObjectLiteral);
  } catch (err) {
    console.log(err); //We've lost the `everthing` line number from the stack trace
  }

  try {
    await asyncFunctionCaller3(asyncAlphaObjectLiteral);
  } catch (err) {
    console.log(err); //We've lost the `everthing` line number from the stack trace
  }

  try {
    await asyncFunctionCaller4(asyncAlphaObjectLiteral);
  } catch (err) {
    console.log(err); //This one is fine
  }
}

everything();

(Code Sandbox)

Output: note my comments in the stack trace

[nodemon] starting `node src/index.js localhost 8080`
Error: I am an error!
    at alpha (/sandbox/src/index.js:2:9)
    at syncFunctionCaller (/sandbox/src/index.js:6:10)
    at everything (/sandbox/src/index.js:43:5) 
    //We can see what function caused this error
    at Object.<anonymous> (/sandbox/src/index.js:73:1)
    at Module._compile (internal/modules/cjs/loader.js:776:30)
    at Object.Module._extensions..js (internal/modules/cjs/loader.js:787:10)
    at Module.load (internal/modules/cjs/loader.js:653:32)
    at tryModuleLoad (internal/modules/cjs/loader.js:593:12)
    at Function.Module._load (internal/modules/cjs/loader.js:585:3)
    at Function.Module.runMain (internal/modules/cjs/loader.js:829:12)
Error: I am an object literal!
    at syncFunctionCaller2 (/sandbox/src/index.js:17:11)
    at everything (/sandbox/src/index.js:65:5)
    //In a synchronous wrapper, the stack trace is preserved
    at Object.<anonymous> (/sandbox/src/index.js:95:1)
    at Module._compile (internal/modules/cjs/loader.js:776:30)
    at Object.Module._extensions..js (internal/modules/cjs/loader.js:787:10)
    at Module.load (internal/modules/cjs/loader.js:653:32)
    at tryModuleLoad (internal/modules/cjs/loader.js:593:12)
    at Function.Module._load (internal/modules/cjs/loader.js:585:3)
    at Function.Module.runMain (internal/modules/cjs/loader.js:829:12)
    at startup (internal/bootstrap/node.js:283:19)
Error: I am also an error!
    at asyncAlpha (/sandbox/src/index.js:10:9)
    at asyncFunctionCaller (/sandbox/src/index.js:18:16)
    at everything (/sandbox/src/index.js:49:11) 
    //We can see what function caused this error
    at Object.<anonymous> (/sandbox/src/index.js:73:1)
    at Module._compile (internal/modules/cjs/loader.js:776:30)
    at Object.Module._extensions..js (internal/modules/cjs/loader.js:787:10)
    at Module.load (internal/modules/cjs/loader.js:653:32)
    at tryModuleLoad (internal/modules/cjs/loader.js:593:12)
    at Function.Module._load (internal/modules/cjs/loader.js:585:3)
    at Function.Module.runMain (internal/modules/cjs/loader.js:829:12)
Error: I am an object literal!
    at asyncFunctionCaller2 (/sandbox/src/index.js:25:11) 
   //We've lost the stacktrace in `everything`
    at process._tickCallback (internal/process/next_tick.js:68:7)
    at Function.Module.runMain (internal/modules/cjs/loader.js:832:11)
    at startup (internal/bootstrap/node.js:283:19)
    at bootstrapNodeJSCore (internal/bootstrap/node.js:622:3)
Error: I'm an error thrown from the function caller!
    at asyncFunctionCaller3 (/sandbox/src/index.js:33:11)
    //We've lost the stacktrace in `everything`
    at process._tickCallback (internal/process/next_tick.js:68:7)
    at Function.Module.runMain (internal/modules/cjs/loader.js:832:11)
    at startup (internal/bootstrap/node.js:283:19)
    at bootstrapNodeJSCore (internal/bootstrap/node.js:622:3)
Error: No try catch here!
    at asyncFunctionCaller4 (/sandbox/src/index.js:38:9)
    at everything (/sandbox/src/index.js:67:11)
    //We can see what function caused this error
    at process._tickCallback (internal/process/next_tick.js:68:7)
    at Function.Module.runMain (internal/modules/cjs/loader.js:832:11)
    at startup (internal/bootstrap/node.js:283:19)
    at bootstrapNodeJSCore (internal/bootstrap/node.js:622:3)
[nodemon] clean exit - waiting for changes before restart

It seems to me that the await statement is what is screwing this up.

What's going on here?

dwjohnston
  • 11,163
  • 32
  • 99
  • 194
  • I have some more examples here: https://codesandbox.io/s/fast-wave-04fpo – dwjohnston Jun 18 '19 at 08:12
  • It happens because you were `throw`ing the errors in those two ases in async functions after an `await` expression - so basically in a promise callback. – Bergi Jun 21 '19 at 13:52

3 Answers3

8

Missing stack trace has nothing to do with Promises. Write the same code that has functions calling each other in synchronous way and you will observe exactly the same behavior i.e. loosing complete stack trace data when rethrowing new Error. It is only Error object that offers stack access. It in turn is backed by native code (like this of V8 engine) responsible for capturing stack trace of crossed stack frames. To make it worse each time you create Error object it captures stack from this point across the stackframes (at least it is observable in browser, nodejs implementation may differ). So that if you catch and retrow different Error object then its stack trace is visible on top of bubbling exception. Missing exceptions chaining for Error (no way to wrap new exception around caught one) makes it hard to fill these gaps. More interesting is that ECMA-262 spec chapter 19.5 does not introduce Error.prototype.stack property at all, in MDN in turn you find stack property is JS engine non-standard extension.

EDIT: Regarding missing "everything" function on stack it is side effect of how engine translates "async/await" into microtask calls and who is really calling specific callbacks. Refer to V8 engine team explanation as well as their zero-cost async stack traces document covering details. NodeJS starting from version 12.x will incorporate more cleaner stack traces, available with --async-stack-traces option offered by V8 engine.

andy
  • 757
  • 5
  • 13
  • I must admit you're absolutely right, promises have nothing to do with lost context. I'll amend my answer to reflect this. – alx Jun 21 '19 at 14:12
  • What about the second stack trace in the example I've posted? We can the see that it was index.js65 that was the the familiar top level bit of code that eventually caused the errror. – dwjohnston Jun 23 '19 at 23:51
  • @dwjohnston find my answer extended with explanation of missing elements of stack trace and way to get more cleaner stack trace. – andy Jun 24 '19 at 15:02
  • >Write the same code that has functions calling each other in synchronous way and you will observe exactly the same behavior i.e. loosing complete stack trace data when rethrowing new Error Can you give an example. I feel like my code does do this, and the stack trace is preserved. – dwjohnston Jun 26 '19 at 05:54
  • Thanks for the v8 references. That does indeed seem to get to the heart of the issue. – dwjohnston Jun 26 '19 at 07:38
  • @dwjohnston take a look on browser sample of JS here https://jsfiddle.net/f1t479z0 open browser console to observe that stack is initially printed for `a-b-c-d` functions, when `c` throws new exception then on top stack is printed for `c-d` only. Run that code in nodejs to see similar behavior. Yet this is not related to swallowed `everything` function but more about missing other functions in stack there were involved in originally thrown exception. – andy Jun 26 '19 at 13:19
  • If my answer sufficiently explained the problem I would appreciate marking it as accepted ;) – andy Jun 26 '19 at 15:22
  • @andy - Here I've modified your example: https://jsfiddle.net/yh6n0emf/1/ You can see that in this example, the `c()` function effectively reintroduces a stack trace. But this works for synchronous functions only. – dwjohnston Jun 26 '19 at 23:44
  • Agreed. I was emphasizing that throwing new error in the middle of bubbling up path (no matter sync or async) makes path travelled so far missing from stack. So even if you throw error (collecting trace from `a` function) then rethrowing new error in `c` makes `a-b` part forgotten. – andy Jun 27 '19 at 05:36
  • @andy - Huh you're right. For the context I'm using, this isn't a problem because I'm assuming that the stack trace so far is missing (because it's an object literal). – dwjohnston Jun 27 '19 at 05:44
0

This might not be a direct answer, but my team and I are building a library to handle async/await promises without the need for try/catch blocks.

  1. Install the module

    npm install await-catcher

  2. Import the awaitCatcher

    const { awaitCatcher } = require("await-catcher")

  3. Use it!

Instead of doing this:

async function asyncFunctionCaller2(fn) {
  try {
    await fn();
  } catch (err) {
    throw new Error(err);
  }
}

Now you can do this:

async function asyncFunctionCaller2(fn) {
  let [ data, err ] = await awaitCatcher(fn);

  // Now you can do whatever you want with data or error
  if ( err ) throw err;
  if ( data ) return data;
}
  // Note:
  // You can name the variables whatever you want. 
  // They don't have to be "data" or "err"

The await-catcher library is simple. It returns an array with two indexes.

1) The first index contains the results/data OR undefined if there is an error "[ data , undefined]"

2) The second index contains the error OR undefined if there is no error "[undefined, error]"


Await-catcher also supports types in TypeScript. You can pass types to be checked against the return value if you use TypeScript.

Example:

 interface promiseType {
     test: string
 }

 (async () => {
     let p = Promise.resolve({test: "hi mom"})
     let [ data , error ] = await awaitCatcher<promiseType>(p);
     console.log(data, error);
 })()

We will update our GitHub repo to include documentation very soon. https://github.com/canaanites/await-catcher


EDIT:

Seems like the V8 engine is "losing" the error stack trace when it starts a new tick. It only returns the error stack from that point. Someone has answered a similar question here.

Change your code to this: https://codesandbox.io/embed/empty-wave-k3tdj

const { awaitCatcher } = require("await-catcher");

async function asyncAlphaObjectLiteral() {
  throw Error("I am an object literal!"); // 1) You need to create an Error object here

  // ~~~~> try throwing just a string and see the difference
}

async function asyncFunctionCaller2(fn) {
  try {
    await fn();
  } catch (err) {
    throw err; // 2) Don't create a new error, just throw the error.
  }
}

/**
 * Or you can just do this...
 * the "awaitCatcher" will catch the errors :)
 *
 * async function asyncFunctionCaller2(fn) {
 *  await fn();
 * }
 */

async function everything() {
  /**
   * notice we don't need try/catch here either!
   */

  let [data, error] = await awaitCatcher(
    asyncFunctionCaller2(asyncAlphaObjectLiteral)
  );
  console.log(error); // 3) Now you have the full error stack trace
}

everything();

Conclusion

It is not a best practice to throw a string instead of an Error object. It will be more difficult to debug and might cause to lose the error stack trace. Highly recommend reading this: Throwing strings instead of Errors

Moe kanan
  • 189
  • 12
  • 1
    This is helpful - but what I'm really looking for is a technical reason as to why the stack trace get lost. – dwjohnston Jun 24 '19 at 05:15
  • @dwjohnston someone has answered a similar question. https://stackoverflow.com/questions/55162619/why-do-i-loose-stack-trace-when-using-async-await-in-node-js/55162787#55162787 I have updated my answer to include a solution :) – Moe kanan Jun 26 '19 at 05:05
  • @Moekanan The problem I am trying to solve is that a library I am using is throwing object literals. That's why I want to create a wrapper function. – dwjohnston Jun 26 '19 at 07:37
-1

EDIT: this answer seems to be absolutely incorrect, see answer by @andy which describes exactly what is going on here.

I think the context is not exactly lost -- it was never there. You're using async/await, and your code is effectively split into "chunks" which are executed in somewhat non-linear way -- asynchronously. Which means that at certain points interpreter leaves main thread, does a 'tick' (thus you see process._tickCallback in stacktrace), and executes next "chunk".

Why that happens? Because async/await is a syntactic sugar to Promise, which is effectively nicely wrapped callbacks guided by external events (I believe in this particular case it is a timer).

What can you do about this? Maybe, can't say for sure as never did that. But I think the following is a good start: https://github.com/nodejs/node/issues/11865

alx
  • 2,314
  • 2
  • 18
  • 22