Repository: raszi/node-tmp (js)

Issue:
tmp installs process listeners multiple times
## Operating System
- all OS

## NodeJS Version
- all versions 

## Tmp Version
all versions

## Expected Behavior
tmp must not try to install process exit and uncaughtException listeners multiple times.

## Experienced Behavior
tmp installs listeners multiple times, causing a warning "possible event listener/memory leak".
This occurs when other projects use facebook/jest which removes and thus reloads existing modules during each test run. And it might also occur when using node's vm (sandboxed modules) API.

## Rationale

Personally, I believe that facebook/jest is doing it wrong, but for the time being we should provide a work around for that problem.

As for node's sandbox VM module, we need to have additional tests that make sure that in such a scenario the listeners will not be added multiple times. Implementing such a test case properly will
require preventing tmp to install its listeners safely. Since this cannot be done, we will simply make sure that in a multiple sandbox environment the aforementioned warning will not be triggered.   

In addition, we should notify the owners of dependent other projects of the update.
And, by analyzing the package dependencies of https://github.com/chrisprice/react-app-webdriver, the most prominent would be

- makeerror
- selenium-webdriver

## See Also

#125 


Pull request description:
fix #129 install process listeners safely
See #129 

WIP
TODO: additional tests

~~~In order for the tests to run without error this requires #135 which has been merged.~~~

Patch:
diff --git a/lib/tmp.js b/lib/tmp.js
index 223bba2..1175a4f 100644
--- a/lib/tmp.js
+++ b/lib/tmp.js
@@ -40,12 +40,13 @@ const
   DIR_MODE = 448 /* 0o700 */,
   FILE_MODE = 384 /* 0o600 */,
 
+  EVENT = 'exit',
+
   // this will hold the objects need to be removed on exit
   _removeObjects = [];
 
 var
-  _gracefulCleanup = false,
-  _uncaughtException = false;
+  _gracefulCleanup = false;
 
 /**
  * Random name generator based on crypto.
@@ -97,7 +98,7 @@ function _isUndefined(obj) {
  */
 function _parseArguments(options, callback) {
   if (typeof options == 'function') {
-    return [callback || {}, options];
+    return [{}, options];
   }
 
   if (_isUndefined(options)) {
@@ -460,7 +461,7 @@ function _prepareRemoveCallback(removeFunction, arg) {
  * @private
  */
 function _garbageCollector() {
-  if (_uncaughtException && !_gracefulCleanup) {
+  if (!_gracefulCleanup) {
     return;
   }
 
@@ -515,8 +516,6 @@ function isExpectedError(error, code, errno) {
 
 /**
  * Sets the graceful cleanup.
- *
- * Also removes the created files and directories when an uncaught exception occurs.
  */
 function setGracefulCleanup() {
   _gracefulCleanup = true;
@@ -526,19 +525,40 @@ const version = process.versions.node.split('.').map(function (value) {
   return parseInt(value, 10);
 });
 
-if (version[0] === 0 && (version[1] < 9 || version[1] === 9 && version[2] < 5)) {
-  process.addListener('uncaughtException', function _uncaughtExceptionThrown(err) {
-    _uncaughtException = true;
-    _garbageCollector();
+/**
+ * If there are multiple different versions of tmp in place, make sure that
+ * we recognize the old listeners.
+ */
+function _is_legacy_listener(listener) {
+  return (listener.name == '_exit' || listener.name == '_uncaughtExceptionThrown')
+    && listener.toString().indexOf('_garbageCollector();') > -1;
+}
 
-    throw err;
+function _safely_install_listener() {
+  var listeners = process.listeners(EVENT);
+
+  // collect any existing listeners
+  var existingListeners = [];
+  for (var i = 0, length = listeners.length; i < length; i++) {
+    var lstnr = listeners[i];
+    if (lstnr.name == '_tmp$safe_listener' || _is_legacy_listener(lstnr)) {
+      if (lstnr.name != '_uncaughtExceptionThrown') existingListeners.push(lstnr);
+      process.removeListener(EVENT, lstnr);
+    }
+  }
+
+  process.addListener(EVENT, function _tmp$safe_listener(data) {
+    if (existingListeners.length) {
+      for (var i = 0, length = existingListeners.length; i < length; i++) {
+        existingListeners[i](data);
+      }
+    }
+    _garbageCollector();
   });
 }
 
-process.addListener('exit', function _exit(code) {
-  if (code) _uncaughtException = true;
-  _garbageCollector();
-});
+_safely_install_listener();
+
 
 /**
  * Configuration options.


Test patch:
diff --git a/test/assertions.js b/test/assertions.js
index a25c3de..8e7a246 100644
--- a/test/assertions.js
+++ b/test/assertions.js
@@ -67,3 +67,7 @@ module.exports.assertDoesNotExist = function assertDoesNotExist(name) {
   assert.ok(!existsSync(name), name + ' should not exist');
 };
 
+module.exports.assertDoesNotStartWith = function assertDoesNotStartWith(s, prefix, msg) {
+  if (s.indexOf(prefix) == 0) assert.fail(msg || s);
+};
+
diff --git a/test/child-process.js b/test/child-process.js
index bb017f0..20c6c02 100644
--- a/test/child-process.js
+++ b/test/child-process.js
@@ -7,10 +7,33 @@ var
   spawn = require('child_process').spawn;
 
 
-module.exports = function spawnChildProcess(configFile, cb) {
+module.exports.genericChildProcess = function spawnGenericChildProcess(configFile, cb) {
+  var
+    configFilePath = path.join(__dirname, 'outband', configFile),
+    command_args = [path.join(__dirname, 'spawn-generic.js'), configFilePath];
+
+  // make sure that the config file exists
+  if (!existsSync(configFilePath))
+    return cb(new Error('ENOENT: configFile ' + configFilePath + ' does not exist'));
+
+  _do_spawn(command_args, cb);
+};
+
+module.exports.childProcess = function spawnChildProcess(configFile, cb) {
+  var
+    configFilePath = path.join(__dirname, 'outband', configFile),
+    command_args = [path.join(__dirname, 'spawn-custom.js'), configFilePath];
+
+  // make sure that the config file exists
+  if (!existsSync(configFilePath))
+    return cb(new Error('ENOENT: configFile ' + configFilePath + ' does not exist'));
+
+  _do_spawn(command_args, cb);
+}
+
+function _do_spawn(command_args, cb) {
   var
     node_path = process.argv[0],
-    command_args = [path.join(__dirname, 'spawn.js')].concat(configFile),
     stdoutBufs = [],
     stderrBufs = [],
     child,
@@ -18,14 +41,11 @@ module.exports = function spawnChildProcess(configFile, cb) {
     stderrDone = false,
     stdoutDone = false;
 
-  // make sure that the config file exists
-  if (!existsSync(path.join(__dirname, configFile)))
-    return cb(new Error('ENOENT: configFile ' + path.join(__dirname, configFile) + ' does not exist'));
-
   // spawn doesn’t have the quoting problems that exec does,
   // especially when going for Windows portability.
   child = spawn(node_path, command_args);
   child.stdin.end();
+  // TODO:we no longer support node 0.6
   // Cannot use 'close' event because not on node-0.6.
   function _close() {
     var
@@ -56,7 +76,6 @@ module.exports = function spawnChildProcess(configFile, cb) {
   });
 }
 
-
 function _bufferConcat(buffers) {
   if (Buffer.concat) {
     return Buffer.concat.apply(this, arguments);
diff --git a/test/dir-sync-test.js b/test/dir-sync-test.js
index e7289f8..91e13b1 100644
--- a/test/dir-sync-test.js
+++ b/test/dir-sync-test.js
@@ -6,7 +6,7 @@ var
   fs = require('fs'),
   path = require('path'),
   inbandStandardTests = require('./inband-standard'),
-  childProcess = require('./child-process'),
+  childProcess = require('./child-process').genericChildProcess,
   assertions = require('./assertions'),
   tmp = require('../lib/tmp');
 
@@ -54,7 +54,7 @@ describe('tmp', function () {
     // API call standard outband tests
     describe('when running standard outband tests', function () {
       it('on graceful cleanup', function (done) {
-        childProcess('outband/graceful-dir-sync.json', function (err, stderr, stdout) {
+        childProcess('graceful-dir-sync.json', function (err, stderr, stdout) {
           if (err) return done(err);
           else if (!stderr) assert.fail('stderr expected');
           else assertions.assertDoesNotExist(stdout);
@@ -62,7 +62,7 @@ describe('tmp', function () {
         });
       });
       it('on non graceful cleanup', function (done) {
-        childProcess('outband/non-graceful-dir-sync.json', function (err, stderr, stdout) {
+        childProcess('non-graceful-dir-sync.json', function (err, stderr, stdout) {
           if (err) return done(err);
           else if (!stderr) assert.fail('stderr expected');
           else {
@@ -73,7 +73,7 @@ describe('tmp', function () {
         });
       });
       it('on keep', function (done) {
-        childProcess('outband/keep-dir-sync.json', function (err, stderr, stdout) {
+        childProcess('keep-dir-sync.json', function (err, stderr, stdout) {
           if (err) return done(err);
           else if (stderr) assert.fail(stderr);
           else {
@@ -84,7 +84,7 @@ describe('tmp', function () {
         });
       });
       it('on unlink (keep == false)', function (done) {
-        childProcess('outband/unlink-dir-sync.json', function (err, stderr, stdout) {
+        childProcess('unlink-dir-sync.json', function (err, stderr, stdout) {
           if (err) return done(err);
           else if (stderr) assert.fail(stderr);
           else assertions.assertDoesNotExist(stdout);
@@ -92,7 +92,7 @@ describe('tmp', function () {
         });
       });
       it('on unsafe cleanup', function (done) {
-        childProcess('outband/unsafe-sync.json', function (err, stderr, stdout) {
+        childProcess('unsafe-sync.json', function (err, stderr, stdout) {
           if (err) return done(err);
           else if (stderr) assert.fail(stderr);
           else {
@@ -105,7 +105,7 @@ describe('tmp', function () {
         });
       });
       it('on non unsafe cleanup', function (done) {
-        childProcess('outband/non-unsafe-sync.json', function (err, stderr, stdout) {
+        childProcess('non-unsafe-sync.json', function (err, stderr, stdout) {
           if (err) return done(err);
           else if (stderr) assert.fail(stderr);
           else {
@@ -128,7 +128,7 @@ describe('tmp', function () {
     describe('when running issue specific outband tests', function () {
       // add your issue specific tests here
       it('on issue #62', function (done) {
-        childProcess('outband/issue62-sync.json', function (err, stderr, stdout) {
+        childProcess('issue62-sync.json', function (err, stderr, stdout) {
           if (err) return done(err);
           else if (stderr) assert.fail(stderr);
           else assertions.assertDoesNotExist(stdout);
diff --git a/test/dir-test.js b/test/dir-test.js
index 2d8e682..74ce178 100644
--- a/test/dir-test.js
+++ b/test/dir-test.js
@@ -6,7 +6,7 @@ var
   fs = require('fs'),
   path = require('path'),
   inbandStandardTests = require('./inband-standard'),
-  childProcess = require('./child-process'),
+  childProcess = require('./child-process').genericChildProcess,
   assertions = require('./assertions'),
   tmp = require('../lib/tmp');
 
@@ -55,7 +55,7 @@ describe('tmp', function () {
     // API call standard outband tests
     describe('when running standard outband tests', function () {
       it('on graceful cleanup', function (done) {
-        childProcess('outband/graceful-dir.json', function (err, stderr, stdout) {
+        childProcess('graceful-dir.json', function (err, stderr, stdout) {
           if (err) return done(err);
           else if (!stderr) assert.fail('stderr expected');
           else assertions.assertDoesNotExist(stdout);
@@ -63,7 +63,7 @@ describe('tmp', function () {
         });
       });
       it('on non graceful cleanup', function (done) {
-        childProcess('outband/non-graceful-dir.json', function (err, stderr, stdout) {
+        childProcess('non-graceful-dir.json', function (err, stderr, stdout) {
           if (err) return done(err);
           else if (!stderr) assert.fail('stderr expected');
           else {
@@ -74,7 +74,7 @@ describe('tmp', function () {
         });
       });
       it('on keep', function (done) {
-        childProcess('outband/keep-dir.json', function (err, stderr, stdout) {
+        childProcess('keep-dir.json', function (err, stderr, stdout) {
           if (err) return done(err);
           else if (stderr) assert.fail(stderr);
           else {
@@ -85,7 +85,7 @@ describe('tmp', function () {
         });
       });
       it('on unlink (keep == false)', function (done) {
-        childProcess('outband/unlink-dir.json', function (err, stderr, stdout) {
+        childProcess('unlink-dir.json', function (err, stderr, stdout) {
           if (err) return done(err);
           else if (stderr) assert.fail(stderr);
           else assertions.assertDoesNotExist(stdout);
@@ -93,7 +93,7 @@ describe('tmp', function () {
         });
       });
       it('on unsafe cleanup', function (done) {
-        childProcess('outband/unsafe.json', function (err, stderr, stdout) {
+        childProcess('unsafe.json', function (err, stderr, stdout) {
           if (err) return done(err);
           else if (stderr) assert.fail(stderr);
           else {
@@ -106,7 +106,7 @@ describe('tmp', function () {
         });
       });
       it('on non unsafe cleanup', function (done) {
-        childProcess('outband/non-unsafe.json', function (err, stderr, stdout) {
+        childProcess('non-unsafe.json', function (err, stderr, stdout) {
           if (err) return done(err);
           else if (stderr) assert.fail(stderr);
           else {
@@ -129,7 +129,7 @@ describe('tmp', function () {
     describe('when running issue specific outband tests', function () {
       // add your issue specific tests here
       it('on issue #62', function (done) {
-        childProcess('outband/issue62.json', function (err, stderr, stdout) {
+        childProcess('issue62.json', function (err, stderr, stdout) {
           if (err) return done(err);
           else if (stderr) assert.fail(stderr);
           else assertions.assertDoesNotExist(stdout);
diff --git a/test/file-sync-test.js b/test/file-sync-test.js
index 40377b9..d418b53 100644
--- a/test/file-sync-test.js
+++ b/test/file-sync-test.js
@@ -6,7 +6,7 @@ var
   fs = require('fs'),
   inbandStandardTests = require('./inband-standard'),
   assertions = require('./assertions'),
-  childProcess = require('./child-process'),
+  childProcess = require('./child-process').genericChildProcess,
   tmp = require('../lib/tmp');
 
 
@@ -51,7 +51,7 @@ describe('tmp', function () {
     // API call standard outband tests
     describe('when running standard outband tests', function () {
       it('on graceful', function (done) {
-        childProcess('outband/graceful-file-sync.json', function (err, stderr, stdout) {
+        childProcess('graceful-file-sync.json', function (err, stderr, stdout) {
           if (err) return done(err);
           else if (!stderr) assert.fail('stderr expected');
           else assertions.assertDoesNotExist(stdout);
@@ -59,7 +59,7 @@ describe('tmp', function () {
         });
       });
       it('on non graceful', function (done) {
-        childProcess('outband/non-graceful-file-sync.json', function (err, stderr, stdout) {
+        childProcess('non-graceful-file-sync.json', function (err, stderr, stdout) {
           if (err) return done(err);
           else if (!stderr) assert.fail('stderr expected');
           else {
@@ -70,7 +70,7 @@ describe('tmp', function () {
         });
       });
       it('on keep', function (done) {
-        childProcess('outband/keep-file-sync.json', function (err, stderr, stdout) {
+        childProcess('keep-file-sync.json', function (err, stderr, stdout) {
           if (err) return done(err);
           else if (stderr) assert.fail(stderr);
           else {
@@ -81,7 +81,7 @@ describe('tmp', function () {
         });
       });
       it('on unlink (keep == false)', function (done) {
-        childProcess('outband/unlink-file-sync.json', function (err, stderr, stdout) {
+        childProcess('unlink-file-sync.json', function (err, stderr, stdout) {
           if (err) return done(err);
           else if (stderr) assert.fail(stderr);
           else assertions.assertDoesNotExist(stdout);
@@ -94,7 +94,7 @@ describe('tmp', function () {
     describe('when running issue specific outband tests', function () {
       // add your issue specific tests here
       it('on issue #115', function (done) {
-        childProcess('outband/issue115-sync.json', function (err, stderr, stdout) {
+        childProcess('issue115-sync.json', function (err, stderr, stdout) {
           if (err) return done(err);
           else if (stderr) assert.fail(stderr);
           else assertions.assertDoesNotExist(stdout);
diff --git a/test/file-test.js b/test/file-test.js
index 40a4ccf..a07899c 100644
--- a/test/file-test.js
+++ b/test/file-test.js
@@ -6,7 +6,7 @@ var
   fs = require('fs'),
   inbandStandardTests = require('./inband-standard'),
   assertions = require('./assertions'),
-  childProcess = require('./child-process'),
+  childProcess = require('./child-process').genericChildProcess,
   tmp = require('../lib/tmp');
 
 
@@ -54,7 +54,7 @@ describe('tmp', function () {
     // API call standard outband tests
     describe('when running standard outband tests', function () {
       it('on graceful', function (done) {
-        childProcess('outband/graceful-file.json', function (err, stderr, stdout) {
+        childProcess('graceful-file.json', function (err, stderr, stdout) {
           if (err) return done(err);
           else if (!stderr) assert.fail('stderr expected');
           else assertions.assertDoesNotExist(stdout);
@@ -62,7 +62,7 @@ describe('tmp', function () {
         });
       });
       it('on non graceful', function (done) {
-        childProcess('outband/non-graceful-file.json', function (err, stderr, stdout) {
+        childProcess('non-graceful-file.json', function (err, stderr, stdout) {
           if (err) return done(err);
           else if (!stderr) assert.fail('stderr expected');
           else {
@@ -73,7 +73,7 @@ describe('tmp', function () {
         });
       });
       it('on keep', function (done) {
-        childProcess('outband/keep-file.json', function (err, stderr, stdout) {
+        childProcess('keep-file.json', function (err, stderr, stdout) {
           if (err) return done(err);
           else if (stderr) assert.fail(stderr);
           else {
@@ -84,7 +84,7 @@ describe('tmp', function () {
         });
       });
       it('on unlink (keep == false)', function (done) {
-        childProcess('outband/unlink-file.json', function (err, stderr, stdout) {
+        childProcess('unlink-file.json', function (err, stderr, stdout) {
           if (err) return done(err);
           else if (stderr) assert.fail(stderr);
           else assertions.assertDoesNotExist(stdout);
@@ -97,7 +97,7 @@ describe('tmp', function () {
     describe('when running issue specific outband tests', function () {
       // add your issue specific tests here
       it('on issue #115', function (done) {
-        childProcess('outband/issue115.json', function (err, stderr, stdout) {
+        childProcess('issue115.json', function (err, stderr, stdout) {
           if (err) return done(err);
           else if (stderr) assert.fail(stderr);
           else assertions.assertDoesNotExist(stdout);
diff --git a/test/issue129-test.js b/test/issue129-test.js
new file mode 100644
index 0000000..4380f0f
--- /dev/null
+++ b/test/issue129-test.js
@@ -0,0 +1,28 @@
+/* eslint-disable no-octal */
+// vim: expandtab:ts=2:sw=2
+
+var
+  assert = require('assert'),
+  assertions = require('./assertions'),
+  childProcess = require('./child-process').childProcess;
+
+describe('tmp', function () {
+  describe('issue129: safely install listeners', function () {
+    it('when simulating sandboxed behavior', function (done) {
+      childProcess('issue129.json', function (err, stderr, stdout) {
+        if (err) return done(err);
+        else if (stderr) {
+          assertions.assertDoesNotStartWith(stderr, 'EEXISTS:LEGACY:EXIT');
+          assertions.assertDoesNotStartWith(stderr, 'EEXISTS:LEGACY:UNCAUGHT');
+          assertions.assertDoesNotStartWith(stderr, 'EEXISTS:NEWSTYLE');
+          assertions.assertDoesNotStartWith(stderr, 'ENOAVAIL:LEGACY:EXIT');
+          assertions.assertDoesNotStartWith(stderr, 'EAVAIL:LEGACY:UNCAUGHT');
+          assertions.assertDoesNotStartWith(stderr, 'ENOAVAIL:NEWSTYLE');
+          assert.equal(stderr, 'EOK', 'existing listeners should have been removed and called');
+        }
+        done();
+      });
+    });
+  });
+});
+
diff --git a/test/outband/issue129.js b/test/outband/issue129.js
new file mode 100644
index 0000000..653e9a1
--- /dev/null
+++ b/test/outband/issue129.js
@@ -0,0 +1,80 @@
+/* eslint-disable no-octal */
+// vim: expandtab:ts=2:sw=2
+
+var fs = require('fs');
+
+// https://github.com/raszi/node-tmp/issues/129
+module.exports = function () {
+
+// dup from lib/tmp.js
+function _is_legacy_listener(listener) {
+  return (listener.name == '_exit' || listener.name == '_uncaughtExceptionThrown')
+    && listener.toString().indexOf('_garbageCollector();') != -1;
+}
+
+function _garbageCollector() {}
+
+  var callState = {
+    newStyleListener : false,
+    legacyExitListener : false,
+    legacyUncaughtListener : false
+  };
+
+  // simulate the new exit listener
+  var listener1 = (function (callState) {
+    return function _tmp$safe_listener(data) {
+      _garbageCollector();
+      callState.newStyleListener = true;
+    };
+  })(callState);
+
+  // simulate the legacy _exit listener
+  var listener2 = (function (callState) {
+    return function _exit(code) {
+      _garbageCollector();
+      callState.legacyExitListener = true;
+    };
+  })(callState);
+
+  // simulate the legacy _uncaughtExceptionThrown listener
+  var listener3 = (function (callState) {
+    return function _uncaughtExceptionThrown(err) {
+      _garbageCollector();
+      callState.legacyUncaughtListener = true;
+    };
+  })(callState);
+
+  process.addListener('exit', listener1); 
+  process.addListener('exit', listener2);
+  process.addListener('exit', listener3);
+
+  // now let tmp install its listener safely
+  var tmp = require('../../lib/tmp');
+
+  var legacyExitListener = null;
+  var legacyUncaughtListener = null;
+  var newStyleListeners = [];
+
+  var listeners = process.listeners('exit');
+  for (var i = 0; i < listeners.length; i++) {
+    var listener = listeners[i];
+    // the order here is important
+    if (listener.name == '_tmp$safe_listener') {
+      newStyleListeners.push(listener);
+    }
+    else if (_is_legacy_listener(listener)) {
+      if (listener.name == '_uncaughtExceptionThrown') legacyUncaughtListener = listener;
+      else legacyExitListener = listener;
+    }
+  }
+
+  if (legacyExitListener) this.fail('EEXISTS:LEGACY:EXIT existing legacy exit listener was not removed', this.exit);
+  if (legacyUncaughtListener) this.fail('EEXISTS:LEGACY:UNCAUGHT existing legacy uncaught exception thrown listener was not removed', this.exit);
+  if (newStyleListeners.length > 1) this.fail('EEXISTS:NEWSTYLE: existing new style listener was not removed', this.exit);
+  newStyleListeners[0]();
+  if (!callState.legacyExitListener) this.fail('ENOAVAIL:LEGACY:EXIT existing legacy exit listener was not called', this.exit);
+  if (callState.legacyUncaughtListener) this.fail('EAVAIL:LEGACY:UNCAUGHT existing legacy uncaught exception thrown listener should not have been called', this.exit);
+  if (!callState.newStyleListener) this.fail('ENOAVAIL:NEWSTYLE: existing new style listener was not called', this.exit);
+  this.out('EOK', this.exit);
+};
+
diff --git a/test/outband/issue129.json b/test/outband/issue129.json
new file mode 100644
index 0000000..3d7794c
--- /dev/null
+++ b/test/outband/issue129.json
@@ -0,0 +1,3 @@
+{
+  "tc": "issue129"
+}
diff --git a/test/outband/issue62-sync.json b/test/outband/issue62-sync.json
index 84d1df6..c4a7118 100644
--- a/test/outband/issue62-sync.json
+++ b/test/outband/issue62-sync.json
@@ -3,5 +3,5 @@
   "async": false,
   "file": false,
   "options": { "unsafeCleanup":true },
-  "graceful": false
+  "graceful": true 
 }
diff --git a/test/outband/issue62.json b/test/outband/issue62.json
index b1fd7b9..457bd5e 100644
--- a/test/outband/issue62.json
+++ b/test/outband/issue62.json
@@ -3,5 +3,5 @@
   "async": true,
   "file": false,
   "options": { "unsafeCleanup":true },
-  "graceful": false
+  "graceful": true 
 }
diff --git a/test/outband/unlink-dir-sync.json b/test/outband/unlink-dir-sync.json
index e1d283c..1b0cf47 100644
--- a/test/outband/unlink-dir-sync.json
+++ b/test/outband/unlink-dir-sync.json
@@ -1,5 +1,5 @@
 {
-  "tc": "keep",
+  "tc": "unlink",
   "async": false,
   "file": false,
   "options": { "keep":false },
diff --git a/test/outband/unlink-dir.json b/test/outband/unlink-dir.json
index 8c9bb81..07a4e7b 100644
--- a/test/outband/unlink-dir.json
+++ b/test/outband/unlink-dir.json
@@ -1,5 +1,5 @@
 {
-  "tc": "keep",
+  "tc": "unlink",
   "async": true,
   "file": false,
   "options": { "keep":false },
diff --git a/test/outband/unlink-file-sync.json b/test/outband/unlink-file-sync.json
index 169a5d7..accec95 100644
--- a/test/outband/unlink-file-sync.json
+++ b/test/outband/unlink-file-sync.json
@@ -1,5 +1,5 @@
 {
-  "tc": "keep",
+  "tc": "unlink",
   "async": false,
   "file": true,
   "options": { "keep":false },
diff --git a/test/outband/unlink-file.json b/test/outband/unlink-file.json
index 873a685..52ab534 100644
--- a/test/outband/unlink-file.json
+++ b/test/outband/unlink-file.json
@@ -1,5 +1,5 @@
 {
-  "tc": "keep",
+  "tc": "unlink",
   "async": true,
   "file": true,
   "options": { "keep":false },
diff --git a/test/outband/unlink.js b/test/outband/unlink.js
new file mode 100644
index 0000000..bda80d1
--- /dev/null
+++ b/test/outband/unlink.js
@@ -0,0 +1,11 @@
+const fs = require('fs');
+
+module.exports = function (result, tmp) {
+  const stat = fs.statSync(result.name);
+  if (stat.isFile()) {
+    fs.unlinkSync(result.name);
+  } else {
+    fs.rmdirSync(result.name);
+  }
+  this.out(result.name);
+};
diff --git a/test/outband/unsafe-sync.json b/test/outband/unsafe-sync.json
index 158a31c..566009d 100644
--- a/test/outband/unsafe-sync.json
+++ b/test/outband/unsafe-sync.json
@@ -3,5 +3,5 @@
   "async": false,
   "file": false,
   "options": { "unsafeCleanup":true },
-  "graceful": false 
+  "graceful": true 
 }
diff --git a/test/outband/unsafe.json b/test/outband/unsafe.json
index ef329b4..b9d73a8 100644
--- a/test/outband/unsafe.json
+++ b/test/outband/unsafe.json
@@ -3,5 +3,5 @@
   "async": true,
   "file": false,
   "options": { "unsafeCleanup":true },
-  "graceful": false 
+  "graceful": true
 }
diff --git a/test/spawn-custom.js b/test/spawn-custom.js
new file mode 100644
index 0000000..bb1cc27
--- /dev/null
+++ b/test/spawn-custom.js
@@ -0,0 +1,14 @@
+// vim: expandtab:ts=2:sw=2
+
+var
+  path = require('path'),
+  readJsonConfig = require('./util').readJsonConfig,
+  spawn = require('./spawn');
+
+var config = readJsonConfig(process.argv[2]);
+spawn.graceful = !!config.graceful;
+
+// import the test case function and execute it
+var fn = require(path.join(__dirname, 'outband', config.tc));
+fn.apply(spawn);
+
diff --git a/test/spawn-generic.js b/test/spawn-generic.js
new file mode 100644
index 0000000..6b4cb5b
--- /dev/null
+++ b/test/spawn-generic.js
@@ -0,0 +1,35 @@
+// vim: expandtab:ts=2:sw=2
+
+var
+  path = require('path'),
+  readJsonConfig = require('./util').readJsonConfig,
+  spawn = require('./spawn'),
+  tmp = require('../lib/tmp');
+
+var config = readJsonConfig(process.argv[2]);
+spawn.graceful = !!config.graceful;
+
+var fnUnderTest = null;
+
+if (config.async) fnUnderTest = (config.file) ? tmp.file : tmp.dir;
+else fnUnderTest = (config.file) ? tmp.fileSync : tmp.dirSync;
+
+// do we test against tmp doing a graceful cleanup?
+if (config.graceful) tmp.setGracefulCleanup();
+
+// import the test case function and execute it
+var fn = require(path.join(__dirname, 'outband', config.tc));
+if (config.async)
+  fnUnderTest(config.options, function (err, name, fdOrCallback, cb) {
+    if (err) spawn.err(err);
+    else {
+      var result = null; 
+      if (config.file) result = { name: name, fd: fdOrCallback, removeCallback: cb };
+      else result = { name: name, removeCallback: fdOrCallback };
+      fn.apply(spawn, [result, tmp]);
+    }
+  });
+else {
+  fn.apply(spawn, [fnUnderTest(config.options), tmp]);
+}
+
diff --git a/test/spawn.js b/test/spawn.js
index efd01fa..9635e94 100644
--- a/test/spawn.js
+++ b/test/spawn.js
@@ -2,8 +2,7 @@
 
 var
   fs = require('fs'),
-  path = require('path'),
-  tmp = require('../lib/tmp');
+  path = require('path');
 
 function _writeSync(stream, str, cb) {
   var flushed = stream.write(str);
@@ -16,16 +15,21 @@ function _writeSync(stream, str, cb) {
   });
 }
 
-var spawn = {
+module.exports = {
+  graceful: false,
   out: function (str, cb) {
-    cb = cb || spawn.exit;
+    cb = cb || this.exit;
     _writeSync(process.stdout, str, cb);
   },
   err: function (errOrStr, cb) {
-    cb = cb || spawn.exit;
-    if (!config.graceful) _writeSync(process.stderr, (errOrStr instanceof Error) ? errOrStr.toString() : errOrStr, cb);
+    cb = cb || this.exit;
+    if (!this.graceful) _writeSync(process.stderr, (errOrStr instanceof Error) ? errOrStr.toString() : errOrStr, cb);
     else cb();
   },
+  fail: function (errOrStr, cb) {
+    cb = cb || this.exit;
+    _writeSync(process.stderr, (errOrStr instanceof Error) ? errOrStr.toString() : errOrStr, cb);
+  },
   exit: function (code) {
     process.exit(code || 0);
   },
@@ -34,39 +38,3 @@ var spawn = {
   }
 };
 
-var config = {};
-try {
-  var contents = fs.readFileSync(path.join(__dirname, process.argv[2]));
-  config = JSON.parse(contents);
-}
-catch (err) {
-  spawn.err(err);
-}
-
-var fnUnderTest = null;
-
-if (config.async) fnUnderTest = (config.file) ? tmp.file : tmp.dir;
-else fnUnderTest = (config.file) ? tmp.fileSync : tmp.dirSync;
-
-// do we test against tmp doing a graceful cleanup?
-if (config.graceful) tmp.setGracefulCleanup();
-
-// import the test case function and execute it
-var fn = require(path.join(__dirname, 'outband', config.tc));
-if (config.async)
-  fnUnderTest(config.options, function (err, name, fdOrCallback, cb) {
-    if (err) spawn.err(err);
-    else {
-      var result = null; 
-      if (config.file) result = { name: name, fd: fdOrCallback, removeCallback: cb };
-      else result = { name: name, removeCallback: fdOrCallback };
-      fn.apply(spawn, [result, tmp]);
-    }
-  });
-else {
-  var result = null;
-  var err = null;
-  result = fnUnderTest(config.options);
-  fn.apply(spawn, [result, tmp]);
-}
-
diff --git a/test/util.js b/test/util.js
new file mode 100644
index 0000000..0d399df
--- /dev/null
+++ b/test/util.js
@@ -0,0 +1,9 @@
+// vim: expandtab:ts=2:sw=2
+
+var
+  fs = require('fs');
+
+module.exports.readJsonConfig = function readJsonConfig(path) {
+  var contents = fs.readFileSync(path);
+  return JSON.parse(contents);
+}