Skip to content

Commit 626a501

Browse files
committed
fix: Allocation for emulator reuse
1 parent 08acca0 commit 626a501

File tree

15 files changed

+235
-135
lines changed

15 files changed

+235
-135
lines changed

detox/src/devices/allocation/drivers/android/FreeDeviceFinder.js

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,8 @@
1+
/**
2+
* @typedef {import('../../../common/drivers/android/tools/DeviceHandle')} DeviceHandle
3+
* @typedef {import('../../../common/drivers/android/tools/EmulatorHandle')} EmulatorHandle
4+
*/
5+
16
const log = require('../../../../utils/logger').child({ cat: 'device' });
27

38
const DEVICE_LOOKUP = { event: 'DEVICE_LOOKUP' };
@@ -12,12 +17,17 @@ class FreeDeviceFinder {
1217
this.deviceRegistry = deviceRegistry;
1318
}
1419

15-
async findFreeDevice(deviceQuery) {
16-
const { devices } = await this.adb.devices();
20+
/**
21+
* @param {DeviceHandle[]} candidates
22+
* @param {string} deviceQuery
23+
* @returns {Promise<import('../../../common/drivers/android/tools/EmulatorHandle') | null>}
24+
*/
25+
async findFreeDevice(candidates, deviceQuery) {
1726
const takenDevices = this.deviceRegistry.getTakenDevicesSync();
18-
for (const candidate of devices) {
27+
for (const candidate of candidates) {
1928
if (await this._isDeviceFreeAndMatching(takenDevices, candidate, deviceQuery)) {
20-
return candidate.adbName;
29+
// @ts-ignore
30+
return candidate;
2131
}
2232
}
2333
return null;

detox/src/devices/allocation/drivers/android/FreeDeviceFinder.test.js

Lines changed: 7 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,6 @@ const FreeDeviceFinder = require('./FreeDeviceFinder');
44
const { deviceOffline, emulator5556, ip5557, localhost5555 } = require('./__mocks__/handles');
55

66
describe('FreeDeviceFinder', () => {
7-
const mockAdb = { devices: jest.fn() };
8-
97
/** @type {DeviceList} */
108
let fakeDeviceList;
119
let mockDeviceRegistry;
@@ -17,42 +15,35 @@ describe('FreeDeviceFinder', () => {
1715
mockDeviceRegistry = new DeviceRegistry();
1816
mockDeviceRegistry.getTakenDevicesSync.mockImplementation(() => fakeDeviceList);
1917

18+
const mockAdb = /** @type {any} */ ({});
2019
uut = new FreeDeviceFinder(mockAdb, mockDeviceRegistry);
2120
});
2221

2322
it('should return the only device when it matches, is online and not already taken by other workers', async () => {
24-
mockAdbDevices([emulator5556]);
25-
26-
const result = await uut.findFreeDevice(emulator5556.adbName);
27-
expect(result).toEqual(emulator5556.adbName);
23+
const result = await uut.findFreeDevice([emulator5556], emulator5556.adbName);
24+
expect(result).toEqual(emulator5556);
2825
});
2926

3027
it('should return null when there are no devices', async () => {
31-
mockAdbDevices([]);
32-
33-
const result = await uut.findFreeDevice(emulator5556.adbName);
28+
const result = await uut.findFreeDevice([], emulator5556.adbName);
3429
expect(result).toEqual(null);
3530
});
3631

3732
it('should return null when device is already taken by other workers', async () => {
38-
mockAdbDevices([emulator5556]);
3933
mockAllDevicesTaken();
4034

41-
expect(await uut.findFreeDevice(emulator5556.adbName)).toEqual(null);
35+
expect(await uut.findFreeDevice([emulator5556], emulator5556.adbName)).toEqual(null);
4236
});
4337

4438
it('should return null when device is offline', async () => {
45-
mockAdbDevices([deviceOffline]);
46-
expect(await uut.findFreeDevice(deviceOffline.adbName)).toEqual(null);
39+
expect(await uut.findFreeDevice([deviceOffline], deviceOffline.adbName)).toEqual(null);
4740
});
4841

4942
it('should return first device that matches a regular expression', async () => {
50-
mockAdbDevices([emulator5556, localhost5555, ip5557]);
5143
const localhost = '^localhost:\\d+$';
52-
expect(await uut.findFreeDevice(localhost)).toBe(localhost5555.adbName);
44+
expect(await uut.findFreeDevice([emulator5556, localhost5555, ip5557], localhost)).toBe(localhost5555);
5345
});
5446

55-
const mockAdbDevices = (devices) => mockAdb.devices.mockResolvedValue({ devices });
5647
const mockAllDevicesTaken = () => {
5748
fakeDeviceList.add(emulator5556.adbName, { busy: true });
5849
fakeDeviceList.add(localhost5555.adbName, { busy: true });

detox/src/devices/allocation/drivers/android/attached/AttachedAndroidAllocDriver.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,8 @@ class AttachedAndroidAllocDriver extends AndroidAllocDriver {
2727
*/
2828
async allocate(deviceConfig) {
2929
const adbNamePattern = deviceConfig.device.adbName;
30-
const adbName = await this._deviceRegistry.registerDevice(() => this._freeDeviceFinder.findFreeDevice(adbNamePattern));
30+
const adbName = await this._deviceRegistry.registerDevice(async () =>
31+
(await this._freeDeviceFinder.findFreeDevice(adbNamePattern)).adbName);
3132

3233
return { id: adbName, adbName };
3334
}

detox/src/devices/allocation/drivers/android/emulator/EmulatorAllocDriver.js

Lines changed: 52 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
11
/**
22
* @typedef {import('../../../../common/drivers/android/cookies').AndroidDeviceCookie} AndroidDeviceCookie
33
* @typedef {import('../../../../common/drivers/android/cookies').EmulatorDeviceCookie} EmulatorDeviceCookie
4+
* @typedef {import('../../../../common/drivers/android/tools/DeviceHandle')} DeviceHandle
45
*/
56

67
const _ = require('lodash');
78

89
const log = require('../../../../../utils/logger').child({ cat: 'device,device-allocation' });
10+
const { isPortTaken } = require('../../../../../utils/netUtils');
911
const adbPortRegistry = require('../../../../common/drivers/android/AdbPortRegistry');
1012
const AndroidAllocDriver = require('../AndroidAllocDriver');
1113

@@ -42,7 +44,6 @@ class EmulatorAllocDriver extends AndroidAllocDriver {
4244
this._freePortFinder = freePortFinder;
4345
this._shouldShutdown = detoxConfig.behavior.cleanup.shutdownDevice;
4446
this._fixAvdConfigIniSkinNameIfNeeded = _.memoize(this._fixAvdConfigIniSkinNameIfNeeded.bind(this));
45-
this._nextAdbServerPort = adb.baseServerPort + 1;
4647
}
4748

4849
async init() {
@@ -63,26 +64,34 @@ class EmulatorAllocDriver extends AndroidAllocDriver {
6364
let adbName;
6465

6566
await this._deviceRegistry.registerDevice(async () => {
66-
adbName = await this._freeDeviceFinder.findFreeDevice(avdName);
67+
const candidates = await this._getAllDevices();
68+
const device = await this._freeDeviceFinder.findFreeDevice(candidates, avdName);
6769

68-
if (adbName) {
69-
adbServerPort = adbPortRegistry.getPort(adbName);
70+
if (device) {
71+
adbName = device.adbName;
72+
adbServerPort = device.adbServerPort;
73+
adbPortRegistry.register(adbName, adbServerPort);
7074
} else {
7175
const port = await this._freePortFinder.findFreePort();
7276

7377
adbName = `emulator-${port}`;
74-
adbServerPort = this._nextAdbServerPort++;
75-
76-
await this._emulatorLauncher.launch({
77-
bootArgs: deviceConfig.bootArgs,
78-
gpuMode: deviceConfig.gpuMode,
79-
headless: deviceConfig.headless,
80-
readonly: deviceConfig.readonly,
81-
avdName,
82-
adbName,
83-
port,
84-
adbServerPort,
85-
});
78+
adbServerPort = this._getFreeAdbServerPort(candidates);
79+
adbPortRegistry.register(adbName, adbServerPort);
80+
81+
try {
82+
await this._emulatorLauncher.launch({
83+
bootArgs: deviceConfig.bootArgs,
84+
gpuMode: deviceConfig.gpuMode,
85+
headless: deviceConfig.headless,
86+
readonly: deviceConfig.readonly,
87+
avdName,
88+
adbName,
89+
port,
90+
adbServerPort,
91+
});
92+
} catch (e) {
93+
adbPortRegistry.unregister(adbName);
94+
}
8695
}
8796

8897
return adbName;
@@ -131,7 +140,7 @@ class EmulatorAllocDriver extends AndroidAllocDriver {
131140

132141
async cleanup() {
133142
if (this._shouldShutdown) {
134-
const { devices } = await this._adb.devices();
143+
const devices = await this._getAllDevices();
135144
const actualEmulators = devices.map((device) => device.adbName);
136145
const sessionDevices = await this._deviceRegistry.readSessionDevices();
137146
const emulatorsToShutdown = _.intersection(sessionDevices.getIds(), actualEmulators);
@@ -159,6 +168,32 @@ class EmulatorAllocDriver extends AndroidAllocDriver {
159168
const binaryVersion = _.get(rawBinaryVersion, 'major');
160169
return await patchAvdSkinConfig(avdName, binaryVersion);
161170
}
171+
172+
/**
173+
* @returns {Promise<DeviceHandle[]>}
174+
* @private
175+
*/
176+
async _getAllDevices() {
177+
const adbServers = await this._getRunningAdbServers();
178+
return (await this._adb.devices({}, adbServers)).devices;
179+
}
180+
181+
/**
182+
* @returns {Promise<number[]>}
183+
* @private
184+
*/
185+
async _getRunningAdbServers() {
186+
const ports = [];
187+
for (let port = this._adb.defaultServerPort + 1; await isPortTaken(port); port++) {
188+
ports.push(port);
189+
}
190+
return ports;
191+
}
192+
193+
_getFreeAdbServerPort(currentDevices) {
194+
const maxPortDevice = _.maxBy(currentDevices, 'adbServerPort');
195+
return _.get(maxPortDevice, 'adbServerPort', this._adb.defaultServerPort) + 1;
196+
}
162197
}
163198

164199
module.exports = EmulatorAllocDriver;

detox/src/devices/allocation/drivers/android/emulator/FreeEmulatorFinder.test.js

Lines changed: 5 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,6 @@ const DeviceList = require('../../../DeviceList');
22
const { emulator5556, localhost5555, mockAvdName } = require('../__mocks__/handles');
33

44
describe('FreeEmulatorFinder', () => {
5-
const mockAdb = { devices: jest.fn() };
6-
75
/** @type {DeviceList} */
86
let fakeDeviceList;
97
/** @type {jest.Mocked<import('../../../DeviceRegistry')>} */
@@ -18,24 +16,20 @@ describe('FreeEmulatorFinder', () => {
1816
mockDeviceRegistry.getTakenDevicesSync.mockImplementation(() => fakeDeviceList);
1917

2018
const FreeEmulatorFinder = require('./FreeEmulatorFinder');
19+
const mockAdb = /** @type {any} */ ({});
2120
uut = new FreeEmulatorFinder(mockAdb, mockDeviceRegistry);
2221
});
2322

2423
it('should return device when it is an emulator and avdName matches', async () => {
25-
mockAdbDevices([emulator5556]);
26-
const result = await uut.findFreeDevice(mockAvdName);
27-
expect(result).toBe(emulator5556.adbName);
24+
const result = await uut.findFreeDevice([emulator5556], mockAvdName);
25+
expect(result).toBe(emulator5556);
2826
});
2927

3028
it('should return null when avdName does not match', async () => {
31-
mockAdbDevices([emulator5556]);
32-
expect(await uut.findFreeDevice('wrongAvdName')).toBe(null);
29+
expect(await uut.findFreeDevice([emulator5556], 'wrongAvdName')).toBe(null);
3330
});
3431

3532
it('should return null when not an emulator', async () => {
36-
mockAdbDevices([localhost5555]);
37-
expect(await uut.findFreeDevice(mockAvdName)).toBe(null);
33+
expect(await uut.findFreeDevice([localhost5555], mockAvdName)).toBe(null);
3834
});
39-
40-
const mockAdbDevices = (devices) => mockAdb.devices.mockResolvedValue({ devices });
4135
});
Lines changed: 2 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
const net = require('net');
1+
const { isPortTaken } = require('../../../../../utils/netUtils');
22

33
class FreePortFinder {
44
constructor({ min = 10000, max = 20000 } = {}) {
@@ -14,24 +14,10 @@ class FreePortFinder {
1414
const max = this._max;
1515
port = Math.random() * (max - min) + min;
1616
port = port & 0xFFFFFFFE; // Should always be even
17-
} while (await this.isPortTaken(port));
17+
} while (await isPortTaken(port));
1818

1919
return port;
2020
}
21-
22-
async isPortTaken(port) {
23-
return new Promise((resolve, reject) => {
24-
const tester = net.createServer()
25-
.once('error', /** @param {*} err */ (err) => {
26-
/* istanbul ignore next */
27-
return err && err.code === 'EADDRINUSE' ? resolve(true) : reject(err);
28-
})
29-
.once('listening', () => {
30-
tester.once('close', () => resolve(false)).close();
31-
})
32-
.listen(port);
33-
});
34-
}
3521
}
3622

3723
module.exports = FreePortFinder;
Lines changed: 7 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,39 +1,21 @@
1-
const net = require('net');
2-
3-
const FreePortFinder = require('./FreePortFinder');
4-
51
describe('FreePortFinder', () => {
62
let finder;
7-
let server;
3+
let isPortTaken;
84

95
beforeEach(() => {
10-
finder = new FreePortFinder();
11-
});
6+
jest.mock('../../../../../utils/netUtils');
7+
isPortTaken = require('../../../../../utils/netUtils').isPortTaken;
8+
isPortTaken.mockResolvedValue(false);
129

13-
afterEach(done => {
14-
if (server) {
15-
server.close(done);
16-
} else {
17-
done();
18-
}
10+
const FreePortFinder = require('./FreePortFinder');
11+
finder = new FreePortFinder();
1912
});
2013

2114
test('should find a free port', async () => {
2215
const port = await finder.findFreePort();
2316
expect(port).toBeGreaterThanOrEqual(10000);
2417
expect(port).toBeLessThanOrEqual(20000);
2518
expect(port % 2).toBe(0);
26-
await expect(finder.isPortTaken(port)).resolves.toBe(false);
27-
});
28-
29-
test('should identify a taken port', async () => {
30-
server = net.createServer();
31-
const portTaken = await new Promise(resolve => {
32-
server.listen(0, () => { // 0 means random available port
33-
resolve(server.address().port);
34-
});
35-
});
36-
37-
await expect(finder.isPortTaken(portTaken)).resolves.toBe(true);
19+
expect(isPortTaken).toHaveBeenCalled();
3820
});
3921
});

detox/src/devices/allocation/drivers/android/emulator/launchEmulatorProcess.js

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,6 @@ const fs = require('fs');
99
const _ = require('lodash');
1010

1111
const unitLogger = require('../../../../../utils/logger').child({ cat: 'device' });
12-
const adbPortRegistry = require('../../../../common/drivers/android/AdbPortRegistry');
1312

1413
/**
1514
* @param { EmulatorExec } emulatorExec - Instance for executing emulator commands
@@ -48,10 +47,6 @@ function launchEmulatorProcess(emulatorExec, adb, emulatorLaunchCommand, adbServ
4847
});
4948
childProcessPromise.childProcess.unref();
5049

51-
if (adbServerPort) {
52-
adbPortRegistry.register(emulatorLaunchCommand.adbName, adbServerPort);
53-
}
54-
5550
log = log.child({ child_pid: childProcessPromise.childProcess.pid });
5651

5752
// Create a deferred promise that resolves when the device is ready

detox/src/devices/common/drivers/android/AdbPortRegistry.js

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -13,17 +13,17 @@ class AdbPortRegistry {
1313

1414
/**
1515
* @param { string } adbName
16-
* @returns { number | undefined }
1716
*/
18-
getPort(adbName) {
19-
return this._registry.get(adbName);
17+
unregister(adbName) {
18+
this._registry.delete(adbName);
2019
}
2120

2221
/**
23-
* @returns { number[] }
22+
* @param { string } adbName
23+
* @returns { number | undefined }
2424
*/
25-
getAllPorts() {
26-
return this._registry.values().toArray();
25+
getPort(adbName) {
26+
return this._registry.get(adbName);
2727
}
2828
}
2929

0 commit comments

Comments
 (0)