Repository: mochajs/mocha

Problem statement:
Mocha 7: A global `uncaughtException` handle is leaked for every completed test runner
### Description
A global `uncaughtException` handle is leaked for every completed test runner, in turn causing a `MaxListenersExceededWarning: Possible EventEmitter memory leak detected. 11 uncaughtException listeners added to [process]. Use emitter.setMaxListeners() to increase limit` warning to be thrown by `node`.

Since every handler attached is identical in content [here](https://github.com/mochajs/mocha/pull/4030/files#r363540897), attaching _one_ for the first completed runner should be just as correct (although, functionality-wise, it may need to be detcahced once a new runner has started - it's unclear how the sequencing between multiple runners is intended to work out for this handler).

### Steps to Reproduce

```js
const mocha = require("mocha");
for (let i = 0; i < 15; i++) {
    const r = new mocha.Runner(new mocha.Suite(""+i, undefined));
    r.run();
}
```

**Expected behavior:**
No warnings from node.

**Actual behavior:**

A `MaxListenersExceededWarning: Possible EventEmitter memory leak detected. 11 uncaughtException listeners added to [process]. Use emitter.setMaxListeners() to increase limit` warning is printed.

**Reproduces how often:**
Every time. You can paste that example code into a [repl](https://runkit.com/weswigham/5e13d0b85c4aff001b6c768f).



Hints:

Patch:
diff --git a/lib/runner.js b/lib/runner.js
--- a/lib/runner.js
+++ b/lib/runner.js
@@ -800,7 +800,7 @@ Runner.prototype.runSuite = function(suite, fn) {
 };
 
 /**
- * Handle uncaught exceptions.
+ * Handle uncaught exceptions within runner.
  *
  * @param {Error} err
  * @private
@@ -893,6 +893,17 @@ Runner.prototype.uncaught = function(err) {
   this.abort();
 };
 
+/**
+ * Handle uncaught exceptions after runner's end event.
+ *
+ * @param {Error} err
+ * @private
+ */
+Runner.prototype.uncaughtEnd = function uncaughtEnd(err) {
+  if (err instanceof Pending) return;
+  throw err;
+};
+
 /**
  * Run the root suite and invoke `fn(failures)`
  * on completion.
@@ -940,16 +951,12 @@ Runner.prototype.run = function(fn) {
   this.on(constants.EVENT_RUN_END, function() {
     debug(constants.EVENT_RUN_END);
     process.removeListener('uncaughtException', uncaught);
-    process.on('uncaughtException', function(err) {
-      if (err instanceof Pending) {
-        return;
-      }
-      throw err;
-    });
+    process.on('uncaughtException', self.uncaughtEnd);
     fn(self.failures);
   });
 
   // uncaught exception
+  process.removeListener('uncaughtException', self.uncaughtEnd);
   process.on('uncaughtException', uncaught);
 
   if (this._delay) {


Test patch:
diff --git a/test/integration/fixtures/uncaught/listeners.fixture.js b/test/integration/fixtures/uncaught/listeners.fixture.js
new file mode 100644
--- /dev/null
+++ b/test/integration/fixtures/uncaught/listeners.fixture.js
@@ -0,0 +1,12 @@
+'use strict';
+
+const assert = require('assert');
+const mocha = require("../../../../lib/mocha");
+
+for (let i = 0; i < 15; i++) {
+  const r = new mocha.Runner(new mocha.Suite("" + i, undefined));
+  r.run();
+}
+
+assert.equal(process.listenerCount('uncaughtException'), 1);
+assert.equal(process.listeners('uncaughtException')[0].name, 'uncaughtEnd');
diff --git a/test/integration/uncaught.spec.js b/test/integration/uncaught.spec.js
--- a/test/integration/uncaught.spec.js
+++ b/test/integration/uncaught.spec.js
@@ -86,4 +86,16 @@ describe('uncaught exceptions', function() {
       done();
     });
   });
+
+  it('removes uncaught exceptions handlers correctly', function(done) {
+    run('uncaught/listeners.fixture.js', args, function(err, res) {
+      if (err) {
+        return done(err);
+      }
+
+      expect(res, 'to have passed').and('to have passed test count', 0);
+
+      done();
+    });
+  });
 });