Repository: mochajs/mocha

Problem statement:
🐛 Bug: Parallel mode crashes if test exception contains circular references
<!--
Have you read Mocha's Code of Conduct? By filing an Issue, you are expected to comply with it, including treating everyone with respect: https://github.com/mochajs/mocha/blob/master/.github/CODE_OF_CONDUCT.md
For more, check out the Mocha Gitter chat room: https://gitter.im/mochajs/mocha

Detail the steps necessary to reproduce the problem. To get the fastest support, create an MCVE and upload it to GitHub.
create an [MCVE](https://stackoverflow.com/help/mcve) and upload it to GitHub.
-->

### Prerequisites

<!--
Place an `x` between the square brackets on the lines below for every satisfied prerequisite.
-->

- [x] Checked that your issue hasn't already been filed by cross-referencing [issues with the `faq` label](https://github.com/mochajs/mocha/issues?utf8=%E2%9C%93&q=is%3Aissue%20label%3Afaq%20)
- [x] Checked next-gen ES issues and syntax problems by using the same environment and/or transpiler configuration without Mocha to ensure it isn't just a feature that actually isn't supported in the environment in question or a bug in your code.
- [x] 'Smoke tested' the code to be tested by running it outside the real test suite to get a better sense of whether the problem is in the code under test, your usage of Mocha, or Mocha itself
- [x] Ensured that there is no discrepancy between the locally and globally installed versions of Mocha. You can find them with: `node node_modules/.bin/mocha --version`(Local) and `mocha --version`(Global). We recommend that you _not_ install Mocha globally.

### Description

Parallel mode crashes or hangs if test exception contains circular references

### Steps to Reproduce

#### 1. Hangs if error has self reference:

_Test file `test.js`_
```javascript
describe('Test', () => {
  it('test', () => {
    const error = new Error('Foo');
    error.self = error;
    throw error;
  });
});
```

Running following will simply hang:

```bash
$ mocha --parallel test.js
```


#### 2. Crashes if error has circular references passed within array properties

_Test file `test.js`_
```javascript
describe('Test', () => {
  it('test', () => {
    const error = new Error('Foo');
    error.foo = { props: [] };
    error.foo.props.push(error.foo);
    throw error;
  });
});
```

Running following

```bash
$ mocha --parallel test.js
```

will result with an error as:

```
 1) Uncaught error outside test suite

  0 passing (308ms)
  1 failing

  1) Uncaught error outside test suite:
     Uncaught TypeError: Converting circular structure to JSON
    --> starting at object with constructor 'Object'
    |     property 'props' -> object with constructor 'Array'
    --- index 0 closes the circle
      at stringify (<anonymous>)
      at writeChannelMessage (node:internal/child_process/serialization:120:20)
      at process.target._send (node:internal/child_process:819:17)
      at process.target.send (node:internal/child_process:719:19)
      at processTicksAndRejections (node:internal/process/task_queues:93:5)
```


**Expected behavior:** 

Originally thrown exception should surface in report

### Versions

<!-- If applicable, please specify: -->

- The output of `mocha --version` and `node node_modules/.bin/mocha --version`: 8.2.1
- The output of `node --version`: 15.5.1
- Your operating system
  - name and version: macOS 10.15.7 
  - architecture (32 or 64-bit): 64-bit
- Your shell (e.g., bash, zsh, PowerShell, cmd): zsh

🐛 Bug: Parallel mode crashes if test exception contains circular references
<!--
Have you read Mocha's Code of Conduct? By filing an Issue, you are expected to comply with it, including treating everyone with respect: https://github.com/mochajs/mocha/blob/master/.github/CODE_OF_CONDUCT.md
For more, check out the Mocha Gitter chat room: https://gitter.im/mochajs/mocha

Detail the steps necessary to reproduce the problem. To get the fastest support, create an MCVE and upload it to GitHub.
create an [MCVE](https://stackoverflow.com/help/mcve) and upload it to GitHub.
-->

### Prerequisites

<!--
Place an `x` between the square brackets on the lines below for every satisfied prerequisite.
-->

- [x] Checked that your issue hasn't already been filed by cross-referencing [issues with the `faq` label](https://github.com/mochajs/mocha/issues?utf8=%E2%9C%93&q=is%3Aissue%20label%3Afaq%20)
- [x] Checked next-gen ES issues and syntax problems by using the same environment and/or transpiler configuration without Mocha to ensure it isn't just a feature that actually isn't supported in the environment in question or a bug in your code.
- [x] 'Smoke tested' the code to be tested by running it outside the real test suite to get a better sense of whether the problem is in the code under test, your usage of Mocha, or Mocha itself
- [x] Ensured that there is no discrepancy between the locally and globally installed versions of Mocha. You can find them with: `node node_modules/.bin/mocha --version`(Local) and `mocha --version`(Global). We recommend that you _not_ install Mocha globally.

### Description

Parallel mode crashes or hangs if test exception contains circular references

### Steps to Reproduce

#### 1. Hangs if error has self reference:

_Test file `test.js`_
```javascript
describe('Test', () => {
  it('test', () => {
    const error = new Error('Foo');
    error.self = error;
    throw error;
  });
});
```

Running following will simply hang:

```bash
$ mocha --parallel test.js
```


#### 2. Crashes if error has circular references passed within array properties

_Test file `test.js`_
```javascript
describe('Test', () => {
  it('test', () => {
    const error = new Error('Foo');
    error.foo = { props: [] };
    error.foo.props.push(error.foo);
    throw error;
  });
});
```

Running following

```bash
$ mocha --parallel test.js
```

will result with an error as:

```
 1) Uncaught error outside test suite

  0 passing (308ms)
  1 failing

  1) Uncaught error outside test suite:
     Uncaught TypeError: Converting circular structure to JSON
    --> starting at object with constructor 'Object'
    |     property 'props' -> object with constructor 'Array'
    --- index 0 closes the circle
      at stringify (<anonymous>)
      at writeChannelMessage (node:internal/child_process/serialization:120:20)
      at process.target._send (node:internal/child_process:819:17)
      at process.target.send (node:internal/child_process:719:19)
      at processTicksAndRejections (node:internal/process/task_queues:93:5)
```


**Expected behavior:** 

Originally thrown exception should surface in report

### Versions

<!-- If applicable, please specify: -->

- The output of `mocha --version` and `node node_modules/.bin/mocha --version`: 8.2.1
- The output of `node --version`: 15.5.1
- Your operating system
  - name and version: macOS 10.15.7 
  - architecture (32 or 64-bit): 64-bit
- Your shell (e.g., bash, zsh, PowerShell, cmd): zsh



Hints:

Patch:
diff --git a/lib/nodejs/serializer.js b/lib/nodejs/serializer.js
--- a/lib/nodejs/serializer.js
+++ b/lib/nodejs/serializer.js
@@ -6,7 +6,7 @@
 
 'use strict';
 
-const {type} = require('../utils');
+const {type, breakCircularDeps} = require('../utils');
 const {createInvalidArgumentTypeError} = require('../errors');
 // this is not named `mocha:parallel:serializer` because it's noisy and it's
 // helpful to be able to write `DEBUG=mocha:parallel*` and get everything else.
@@ -188,14 +188,9 @@ class SerializableEvent {
    * @param {Array<object|string>} pairs - List of parent/key tuples to process; modified in-place. This JSDoc type is an approximation
    * @param {object} parent - Some parent object
    * @param {string} key - Key to inspect
-   * @param {WeakSet<Object>} seenObjects - For avoiding circular references
    */
-  static _serialize(pairs, parent, key, seenObjects) {
+  static _serialize(pairs, parent, key) {
     let value = parent[key];
-    if (seenObjects.has(value)) {
-      parent[key] = Object.create(null);
-      return;
-    }
     let _type = type(value);
     if (_type === 'error') {
       // we need to reference the stack prop b/c it's lazily-loaded.
@@ -263,13 +258,14 @@ class SerializableEvent {
       error: this.originalError
     });
 
+    // mutates the object
+    breakCircularDeps(result);
+
     const pairs = Object.keys(result).map(key => [result, key]);
-    const seenObjects = new WeakSet();
 
     let pair;
     while ((pair = pairs.shift())) {
-      SerializableEvent._serialize(pairs, ...pair, seenObjects);
-      seenObjects.add(pair[0]);
+      SerializableEvent._serialize(pairs, ...pair);
     }
 
     this.data = result.data;
diff --git a/lib/utils.js b/lib/utils.js
--- a/lib/utils.js
+++ b/lib/utils.js
@@ -647,3 +647,36 @@ exports.assignNewMochaID = obj => {
  */
 exports.getMochaID = obj =>
   obj && typeof obj === 'object' ? obj[MOCHA_ID_PROP_NAME] : undefined;
+
+/**
+ * Replaces any detected circular dependency with the string '[Circular]'
+ * Mutates original object
+ * @param inputObj {*}
+ * @returns {*}
+ */
+exports.breakCircularDeps = inputObj => {
+  const seen = new Set();
+
+  function _breakCircularDeps(obj) {
+    if (obj && typeof obj !== 'object') {
+      return obj;
+    }
+
+    if (seen.has(obj)) {
+      return '[Circular]';
+    }
+
+    seen.add(obj);
+    for (const k in obj) {
+      if (Object.prototype.hasOwnProperty.call(obj, k)) {
+        obj[k] = _breakCircularDeps(obj[k], k);
+      }
+    }
+
+    // deleting means only a seen object that is its own child will be detected
+    seen.delete(obj);
+    return obj;
+  }
+
+  return _breakCircularDeps(inputObj);
+};


Test patch:
diff --git a/test/integration/fixtures/parallel/circular-error.mjs b/test/integration/fixtures/parallel/circular-error.mjs
new file mode 100644
--- /dev/null
+++ b/test/integration/fixtures/parallel/circular-error.mjs
@@ -0,0 +1,10 @@
+import {describe,it} from "../../../../index.js";
+
+describe('test1', () => {
+  it('test', () => {
+    const error = new Error('Foo');
+    error.foo = { props: [] };
+    error.foo.props.push(error.foo);
+    throw error;
+  });
+});
diff --git a/test/integration/parallel.spec.js b/test/integration/parallel.spec.js
--- a/test/integration/parallel.spec.js
+++ b/test/integration/parallel.spec.js
@@ -30,4 +30,17 @@ describe('parallel run', () => {
     assert.strictEqual(result.stats.failures, 0);
     assert.strictEqual(result.stats.passes, 3);
   });
+
+  it('should correctly handle circular references in an exception', async () => {
+    const result = await runMochaJSONAsync('parallel/circular-error.mjs', [
+      '--parallel',
+      '--jobs',
+      '2',
+      require.resolve('./fixtures/parallel/testworkerid1.mjs')
+    ]);
+    assert.strictEqual(result.stats.failures, 1);
+    assert.strictEqual(result.stats.passes, 1);
+    assert.strictEqual(result.failures[0].err.message, 'Foo');
+    assert.strictEqual(result.failures[0].err.foo.props[0], '[Circular]');
+  });
 });