V8 lazy generation of stack traces seems to cause an infinite loop in the vows library
Asked Answered
H

1

11

I spent some time debugging a strange infinite loop problem in a NodeJS testsuite. It only happens under rare conditions but I can reproduce it when I attach to the chrome debugger.

I think it has to do with V8's handling of stack traces in exceptions and a extension that the vows library did to the AssertionError object (vows added an toString method). I could also be mistaken, so I wanted to ask whether my understanding of V8's implementation is correct.

Here is a minimal example to reproduce the error:

$ git clone https://github.com/flatiron/vows.git
$ cd vows && npm install && npm install should

$ cat > example.js
var should = require('should');
var error = require('./lib/assert/error.js');

try {
  'x'.should.be.json;
} catch (e) {
  console.log(e.toString());
}

// without debug, it should fail as expected
$ node example.js
expected 'x' to have property 'headers' // should.js:61

// now with debug
$ node-inspector &
$ node --debug-brk example.js

// 1) open http://127.0.0.1:8080/debug?port=5858 in Chrome
// 2) set breakpoint at lib/assert/error.js#79 (the toString method)
// 3) Resume script execution (F8)

Now the program ends up in an infinite loop: the toString method (added by the vows library) is called again and again when this.stack is accessed in the regexp on line 83.

require('assert').AssertionError.prototype.toString = function () {
var that = this, // line 79: breakpoint
    source;

if (this.stack) {
    source = this.stack.match(/([a-zA-Z0-9._-]+\.(?:js|coffee))(:\d+):\d+/); // line 83: infinite loop takes place here (however, this.stack is undefined)
}

When I inspect this in the debugger, it shows that it is an AssertionError but its stack property is undefined. However, when I hover with the mouse over it, it shows the actual stack trace.

I assume this phenomena is caused by the laziness optimization of V8. It only computes the stack trace on demand. In doing so, it interferes with the added toString method of vows. The toString method again accesses the stack trace (this.stack), so the loop continues.

Is that conclusion correct? If so, is there a way to patch the vows code, so it works with V8 (or can I at least report it as a bug in the vows project)?

I'm using node v0.10.28 under Ubuntu.

Update: Simplified example without vows

Here is a simplified version. It no longer depends on vows, but instead I have only copied the relevant parts of its toString extension:

var should = require('should');

require('assert').AssertionError.prototype.toString = function () {
  var that = this,
    source;

  if (this.stack) {
    source = this.stack.match(/([a-zA-Z0-9._-]+\.(?:js|coffee))(:\d+):\d+/);
  }

  return '<dummy-result>';
};

try {
  'x'.should.be.json;
} catch (e) {
  console.log(e.toString());
}

// expected result (without debug mode)
$ node example.js
<dummy-result>

In debug mode, the recursion takes place in the if statement.

Update: Even simplier version with ReferenceError

ReferenceError.prototype.toString = function () {
  var that = this,
    source;

  if (this.stack) {
    source = this.stack.match(/([a-zA-Z0-9._-]+\.(?:js|coffee))(:\d+):\d+/);
  }

  return '<dummy-result>';
};

try {
  throw new ReferenceError('ABC');
} catch (e) {
  console.log(e.toString());
}

(I also create a jsfiddle example, but I cannot reproduce the infinite loop there, only with node.)

Homogeneous answered 26/5, 2014 at 20:5 Comment(7)
Can you create a self contained example that doesn't use vows?Confucius
@BenjaminGruenbaum Yes, I was able to reproduce the same behavior by just using its toString method. I also removed the non-relevant parts, only the recursion is left.Afterclap
What about should.js? Ideally, I'd love to see this problem isolated to the point it's reproducable without it (and to be even bolder, in the browser by every reader inside jsfiddle).Confucius
What about ` ReferenceError.prototype.toString = function () { var that = this, source; if (this.stack) { source = this.stack.match(/([a-zA-Z0-9._-]+\.(?:js|coffee))(:\d+):\d+/); } return '<dummy-result>'; }; try { foobarbaz; } catch (e) { console.log(e.toString()); }`Confucius
When I modify ReferenceError, it's normal behavior (the exception is thrown) and no infinite loop. AssertionError is the exception thrown by "should". I will try to simplify the example further by replace the "should" part by the actual throw.Afterclap
@BenjaminGruenbaum OK, now I understood what you meant. It is quite simple to reproduce it with a simplified ReferenceError example. I will update my post.Afterclap
Major apologies, this reproduces just fine with the node debugger. I forgot to step into -_-.Confucius
C
7

Congratulations, you have found a bug in V8!

Yep, that's definitely a bug in the V8 version in that version of node.

The code in the version of V8 your version of Node uses code that looks something like:

function FormatStackTrace(error, frames) {
  var lines = [];
  try {
    lines.push(error.toString());
  } catch (e) {
    try {
      lines.push("<error: " + e + ">");
    } catch (ee) {
      lines.push("<error>");
    }
 }

Here is the actual code from the version NodeJS uses.

The fact it's doing error.toString() itself is causing the loop, this.stack accesses FormatStackTrace which in turn is doing .toString(). Your observation is correct.

If that's any comfort, that code looks very different in newer versions of V8. In Node 0.11, this bug is already fixed.

Here is the commit that fixed it commited A year and a half ago by Vyacheslav Egorov.

What you can do about it?

Well, your options are limited, but since this is for debugging anyway, can always prevent the second iteration and stop the loop:

ReferenceError.prototype.toString = function () {
  var source;
  if(this.alreadyCalled) return "ReferenceError";
  this.alreadyCalled = true;
  if (this.stack) {
    source = this.stack.match(/([a-zA-Z0-9._-]+\.(?:js|coffee))(:\d+):\d+/);
  }

  return '<dummy-result>';
};

Does not exhibit the same issue. There is not much more you could do without access to the core code.

Confucius answered 26/5, 2014 at 21:51 Comment(4)
Cool. Currently, I have no access to unstable node, so I cannot test it against 0.11 (maybe I can do that later). Do you think that I should file a bug report, so the patch can be backported to stable node 0.10.x?Afterclap
I seriously doubt there is any chance they'll backport it since it doesn't affect them very much, vows itself has not been updated in 9 months. I think a dirty hack to fix it would be to override its .toString() yourself, with a boolean "already called" flag. Nice catch by the way.Confucius
I opened an issue in node just in case though :)Confucius
Thanks, I also opened an issue in vows as a warning if you use vows (or easy-api) and "should" together: github.com/flatiron/vows/issues/303.Afterclap

© 2022 - 2024 — McMap. All rights reserved.