Instrumentation._onTerminated is an async EventEmitter listener — a rejecting termination callback kills the Jest process
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:
// 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):
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.2The 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:
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.
Source: wix/Detox