#4977·Detox

Instrumentation._onTerminated is an async EventEmitter listener — a rejecting termination callback kills the Jest process

Author: davidmtzencisoCreated Sep 14, 2026Updated Sep 14, 2026

Description

Instrumentation._onTerminated is registered directly as a 'close' listener on the instrumentation child process. Because it is an async function, the promise it returns is discarded by the EventEmitter — so any rejection inside it becomes an unhandled rejection, which under Node's default --unhandled-rejections=throw terminates the whole test process.

In practice this kills the Jest run before Jest can print any results: no failure block, no summary. A suite that was going to report real failures instead reports nothing at all, which makes unrelated test problems undiagnosable.

Root cause

src/devices/common/drivers/android/tools/Instrumentation.js:

javascript
// L26 — async function used as an EventEmitter listener
this.instrumentationProcess.childProcess.on('close', this._onTerminated);

// L51
async _onTerminated() {
  if (this.instrumentationProcess) {
    await this._killProcess();
    await this.userTerminationFn();   // ← any rejection here is unhandled
  }
}

The installed termination callback comes from src/devices/runtime/drivers/android/AndroidDriver.js (L344-347):

javascript
this.instrumentation.setTerminationFn(async () => {
  await this._terminateInstrumentation();
  await this.adb.reverseRemove(adbName, serverPort);   // ← throws
});

adb reverse --remove tcp:<port> exits non-zero with listener 'tcp:<port>' not found when the listener is already gone, and ADB.reverseRemove propagates that as a rejection.

There is also a race that makes the stale listener likely rather than exotic: terminate() awaits interruptProcess(...), and the 'close' event can fire during that await, so reverseRemove is reached twice for the same port. The second call is the one that throws.

Specs that call device.launchApp({ newInstance: true }) repeatedly hit it most often, since each relaunch terminates the previous instrumentation.

Actual behaviour

Error: Command failed: "…/platform-tools/adb" -s emulator-5554 reverse --remove tcp:51881
adb: error: listener 'tcp:51881' not found

    at genericNodeError (node:internal/errors:983:15)
    at ChildProcess.exithandler (node:child_process:417:12)
    at ChildProcess.emit (node:events:519:28)
    …
Node.js v22.23.2

The process exits here. Jest never prints Test Suites: / Tests:.

Expected behaviour

A failure while cleaning up a port forward during teardown should not terminate the test process. At minimum, a rejection from the user termination callback should be caught and logged rather than escaping an EventEmitter listener.

Environment

  • Detox 20.50.1 (also present in 20.51.4 — the three files on this path are byte-identical between the two)
  • Jest 30
  • Node 22
  • Android emulator, self-hosted Linux CI runner

Suggested fix

Guard the listener boundary, so no installed userTerminationFn can ever kill the process:

javascript
async _onTerminated() {
  if (this.instrumentationProcess) {
    await this._killProcess();
    try {
      await this.userTerminationFn();
    } catch (e) {
      this.logger.warn(`Instrumentation termination callback failed: ${e.message}`);
    }
  }
}

Making reverseRemove tolerant of a missing listener would also fix this particular symptom, but the listener boundary seems the more general place — an async EventEmitter listener cannot propagate safely regardless of which callback is installed.

Happy to open a PR if that shape is agreeable.

Workaround

Patching the above via patch-package resolves it; teardown then logs a warning and the suite reports normally.