Skip to content

Commit 3e6c0ec

Browse files
authored
fix: stabilize stuck cleanup (wix#2721)
1 parent cfd7642 commit 3e6c0ec

File tree

7 files changed

+197
-47
lines changed

7 files changed

+197
-47
lines changed

detox/src/client/AsyncWebSocket.js

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ const log = require('../utils/logger').child({ __filename });
44
const Deferred = require('../utils/Deferred');
55
const DetoxInternalError = require('../errors/DetoxInternalError');
66
const DetoxRuntimeError = require('../errors/DetoxRuntimeError');
7+
const InflightRequest = require('./InflightRequest');
78

89
const EVENTS = {
910
OPEN: Object.freeze({ event: 'WS_OPEN' }),
@@ -13,6 +14,10 @@ const EVENTS = {
1314
LATE_RESPONSE: Object.freeze({ event: 'WS_LATE_RESPONSE' }),
1415
};
1516

17+
const DEFAULT_SEND_OPTIONS = {
18+
timeout: 0,
19+
};
20+
1621
class AsyncWebSocket {
1722
constructor(url) {
1823
this._log = log.child({ url });
@@ -73,7 +78,7 @@ class AsyncWebSocket {
7378
return closing.promise;
7479
}
7580

76-
async send(message) {
81+
async send(message, options = DEFAULT_SEND_OPTIONS) {
7782
if (!this.isOpen) {
7883
throw new DetoxRuntimeError({
7984
message: 'Cannot send a message over the closed web socket. See the payload below:',
@@ -87,9 +92,7 @@ class AsyncWebSocket {
8792
}
8893

8994
const messageId = message.messageId;
90-
const inFlight = this.inFlightPromises[messageId] = new Deferred();
91-
inFlight.message = message;
92-
95+
const inFlight = this.inFlightPromises[messageId] = new InflightRequest(message).withTimeout(options.timeout);
9396
const messageAsString = JSON.stringify(message);
9497
this._log.trace(EVENTS.SEND, messageAsString);
9598
this._ws.send(messageAsString);
@@ -107,6 +110,8 @@ class AsyncWebSocket {
107110

108111
resetInFlightPromises() {
109112
for (const messageId of _.keys(this.inFlightPromises)) {
113+
const inFlight = this.inFlightPromises[messageId];
114+
inFlight.clearTimeout();
110115
delete this.inFlightPromises[messageId];
111116
this._abortedMessageIds.add(+messageId);
112117
}
@@ -122,8 +127,8 @@ class AsyncWebSocket {
122127
const inFlightPromises = _.values(this.inFlightPromises);
123128

124129
this.resetInFlightPromises();
125-
for (const deferred of inFlightPromises) {
126-
deferred.reject(error);
130+
for (const inflight of inFlightPromises) {
131+
inflight.reject(error);
127132
}
128133

129134
if (!hasPendingActions) {
@@ -220,7 +225,7 @@ class AsyncWebSocket {
220225
}
221226
} catch (error) {
222227
this.rejectAll(new DetoxRuntimeError({
223-
message: 'Unexpected error on an attempt to handle a message over the web socket.',
228+
message: 'Unexpected error on an attempt to handle the response received over the web socket.',
224229
hint: 'Examine the inner error:\n\n' + DetoxRuntimeError.format(error) + '\n\nThe payload was:',
225230
debugInfo: data,
226231
}));

detox/src/client/AsyncWebSocket.test.js

Lines changed: 50 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
jest.useFakeTimers('modern');
2+
13
const _ = require('lodash');
24
const permaproxy = require('funpermaproxy');
35
const config = require('../configuration/configurations.mock').validSession;
@@ -42,6 +44,10 @@ describe('AsyncWebSocket', () => {
4244
log = require('../utils/logger');
4345
});
4446

47+
afterEach(() => {
48+
jest.clearAllTimers();
49+
});
50+
4551
describe('.open()', () => {
4652
it(`should normally resolve`, async () => {
4753
await expect(connect()).resolves.not.toThrowError();
@@ -112,6 +118,16 @@ describe('AsyncWebSocket', () => {
112118
expect(await response1).toEqual(expected1);
113119
});
114120

121+
it(`should not set expiration timers by default`, async () => {
122+
aws.send(generateRequest());
123+
expect(jest.getTimerCount()).toBe(0);
124+
});
125+
126+
it(`should set an expiration timer if sent with timeout > 0`, async () => {
127+
aws.send(generateRequest(), { timeout: 100 });
128+
expect(jest.getTimerCount()).toBe(1);
129+
});
130+
115131
it(`should reject all messages in the flight if there's an error`, async () => {
116132
const sendPromise1 = aws.send(generateRequest());
117133
const sendPromise2 = aws.send(generateRequest());
@@ -153,6 +169,34 @@ describe('AsyncWebSocket', () => {
153169
const error = log.error.mock.calls[0][1];
154170
expect(error).toMatchSnapshot();
155171
});
172+
173+
it(`should fail if the message timeout has expired`, async () => {
174+
const sendPromise = aws.send(generateRequest(), { timeout: 5000 });
175+
expect(jest.getTimerCount()).toBe(1);
176+
jest.advanceTimersByTime(5000);
177+
await expect(sendPromise).rejects.toThrowErrorMatchingSnapshot();
178+
expect(jest.getTimerCount()).toBe(0);
179+
});
180+
181+
it(`should cancel the expiration timer if the request has been answered`, async () => {
182+
aws.send(generateRequest(100), { timeout: 5000 });
183+
expect(jest.getTimerCount()).toBe(1);
184+
socket.mockMessage({ messageId: 100, type: 'response' });
185+
expect(jest.getTimerCount()).toBe(0);
186+
});
187+
188+
it(`should cancel the expiration timer if all in-flights have been reset`, async () => {
189+
aws.send(generateRequest(100), { timeout: 5000 });
190+
await aws.resetInFlightPromises();
191+
expect(jest.getTimerCount()).toBe(0);
192+
});
193+
194+
it(`should cancel the expiration timer if all in-flights have been rejected`, async () => {
195+
const promise = aws.send(generateRequest(100), { timeout: 5000 });
196+
await aws.rejectAll(new Error('Just because'));
197+
expect(jest.getTimerCount()).toBe(0);
198+
await expect(promise).rejects.toThrow('Just because');
199+
});
156200
});
157201
});
158202

@@ -253,9 +297,9 @@ describe('AsyncWebSocket', () => {
253297
const message1 = aws.send(generateRequest());
254298
const message2 = aws.send(generateRequest());
255299

256-
aws.rejectAll(anError());
257-
await expect(message1).rejects.toEqual(anError());
258-
await expect(message2).rejects.toEqual(anError());
300+
aws.rejectAll(anError('TestError'));
301+
await expect(message1).rejects.toThrow(/TestError/);
302+
await expect(message2).rejects.toThrow(/TestError/);
259303
});
260304
});
261305

@@ -312,7 +356,7 @@ describe('AsyncWebSocket', () => {
312356
}
313357

314358
function generateRequest(messageId) {
315-
return {message: 'a message', messageId};
359+
return {type: 'invoke', message: 'a message', messageId};
316360
}
317361

318362
function generateResponse(message, messageId) {
@@ -323,8 +367,8 @@ describe('AsyncWebSocket', () => {
323367
};
324368
}
325369

326-
function anError() {
327-
const err = new Error('TestError');
370+
function anError(msg = 'TestError') {
371+
const err = new Error(msg);
328372
delete err.stack;
329373
return err;
330374
}

detox/src/client/Client.js

Lines changed: 17 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ class Client {
2323
this._onBeforeAppCrash = this._onBeforeAppCrash.bind(this);
2424
this._onAppDisconnected = this._onAppDisconnected.bind(this);
2525
this._onUnhandledServerError = this._onUnhandledServerError.bind(this);
26+
this._logError = this._logError.bind(this);
2627

2728
this._sessionId = sessionId;
2829
this._slowInvocationTimeout = debugSynchronization;
@@ -73,13 +74,13 @@ class Client {
7374

7475
try {
7576
if (this.isConnected) {
76-
await this.sendAction(new actions.Cleanup(this._successfulTestRun));
77+
await this.sendAction(new actions.Cleanup(this._successfulTestRun)).catch(this._logError);
7778

7879
this._whenAppIsConnected = this._invalidState('while cleaning up')
7980
this._whenAppIsReady = this._whenAppIsConnected;
8081
}
8182
} finally {
82-
await this._asyncWebSocket.close();
83+
await this._asyncWebSocket.close().catch(this._logError);
8384
}
8485

8586
delete this.terminateApp; // property injection
@@ -127,38 +128,38 @@ class Client {
127128
}
128129

129130
async sendAction(action) {
130-
const options = this._inferSendOptions(action);
131+
const { queryStatus, ...options } = this._inferSendOptions(action);
131132

132-
return await ((options.queryStatus)
133-
? this._sendMonitoredAction(action)
134-
: this._doSendAction(action))
133+
return await (queryStatus
134+
? this._sendMonitoredAction(action, options)
135+
: this._doSendAction(action, options))
135136
}
136137

137138
_inferSendOptions(action) {
138139
if ( action instanceof actions.CurrentStatus
139140
|| action instanceof actions.Login
140141
|| action instanceof actions.Cleanup
141142
) {
142-
return { queryStatus: false };
143+
return { queryStatus: false, timeout: 5000 };
143144
}
144145

145-
return { queryStatus: true };
146+
return { queryStatus: true, timeout: 0 };
146147
}
147148

148-
async _sendMonitoredAction(action) {
149+
async _sendMonitoredAction(action, options) {
149150
try {
150151
this._scheduleSlowInvocationQuery();
151-
return await this._doSendAction(action);
152+
return await this._doSendAction(action, options);
152153
} finally {
153154
this._unscheduleSlowInvocationQuery();
154155
}
155156
}
156157

157-
async _doSendAction(action) {
158+
async _doSendAction(action, options) {
158159
const errorWithUserStack = createErrorWithUserStack();
159160

160161
try {
161-
const parsedResponse = await this._asyncWebSocket.send(action);
162+
const parsedResponse = await this._asyncWebSocket.send(action, options);
162163
if (parsedResponse && parsedResponse.type === 'serverError') {
163164
throw deserializeError(parsedResponse.params.error);
164165
}
@@ -355,6 +356,10 @@ class Client {
355356
new DetoxInternalError(`Detected an attempt to interact with Detox Client ${state}.`)
356357
);
357358
}
359+
360+
_logError(e) {
361+
log.error({ event: 'ERROR' }, DetoxRuntimeError.format(e));
362+
}
358363
}
359364

360365
module.exports = Client;

detox/src/client/Client.test.js

Lines changed: 42 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,10 @@ describe('Client', () => {
3939
});
4040
});
4141

42+
mockAws.close.mockImplementation(async () => {
43+
44+
});
45+
4246
mockAws.mockBusy = () => {
4347
const deferred = new Deferred();
4448
mockAws.send.mockImplementationOnce(() => deferred.promise);
@@ -130,7 +134,7 @@ describe('Client', () => {
130134
mockAws.mockResponse('loginSuccess', {});
131135
expect(mockAws.send).not.toHaveBeenCalled();
132136
await client.connect();
133-
expect(mockAws.send).toHaveBeenCalledWith(new actions.Login(validSession.sessionId));
137+
expect(mockAws.send).toHaveBeenCalledWith(new actions.Login(validSession.sessionId), SEND_OPTIONS.TIMED);
134138
});
135139

136140
it('should not consider itself connected to the app if "loginSuccess" params.appConnected = false', async () => {
@@ -160,14 +164,14 @@ describe('Client', () => {
160164

161165
it('should schedule "currentStatus" query when it takes too long', async () => {
162166
const { action } = await simulateInFlightAction();
163-
expect(mockAws.send).toHaveBeenCalledWith(action);
167+
expect(mockAws.send).toHaveBeenCalledWith(action, SEND_OPTIONS.DEFAULT);
164168
expect(mockAws.send).toHaveBeenCalledTimes(2); // action + login
165169

166170
mockAws.mockBusy(); // for the current status query
167171
jest.advanceTimersByTime(validSession.debugSynchronization);
168172
await fastForwardAllPromises();
169173

170-
expect(mockAws.send).toHaveBeenCalledWith(new actions.CurrentStatus());
174+
expect(mockAws.send).toHaveBeenCalledWith(new actions.CurrentStatus(), SEND_OPTIONS.TIMED);
171175
expect(jest.getTimerCount()).toBe(0); // should not spam with "currentStatus" queries
172176
});
173177

@@ -258,7 +262,7 @@ describe('Client', () => {
258262
mockAws.mockResponse('whateverDone');
259263
const action = anAction();
260264
await client.sendAction(action);
261-
expect(mockAws.send).toHaveBeenCalledWith(action);
265+
expect(mockAws.send).toHaveBeenCalledWith(action, SEND_OPTIONS.DEFAULT);
262266
});
263267

264268
it('should pass the parsed response to action.handle()', async () => {
@@ -297,7 +301,6 @@ describe('Client', () => {
297301
['waitForActive', 'waitForActiveDone', actions.WaitForActive],
298302
['waitUntilReady', 'ready', actions.Ready],
299303
['currentStatus', 'currentStatusResult', actions.CurrentStatus, {}, { status: 'App is idle' }],
300-
['cleanup', 'cleanupDone', actions.Cleanup, true],
301304
])('.%s', (methodName, expectedResponseType, Action, params, expectedResponseParams) => {
302305
beforeEach(async () => {
303306
await client.connect();
@@ -308,7 +311,7 @@ describe('Client', () => {
308311
await client[methodName](params);
309312

310313
const action = new Action(params);
311-
expect(mockAws.send).toHaveBeenCalledWith(action);
314+
expect(mockAws.send).toHaveBeenCalledWith(action, { timeout: expect.any(Number) });
312315
});
313316

314317
it(`should throw on a wrong response from device`, async () => {
@@ -362,7 +365,7 @@ describe('Client', () => {
362365
await client.connect();
363366
mockAws.mockResponse('cleanupDone');
364367
await client.cleanup();
365-
expect(mockAws.send).toHaveBeenCalledWith(new actions.Cleanup(true));
368+
expect(mockAws.send).toHaveBeenCalledWith(new actions.Cleanup(true), SEND_OPTIONS.TIMED);
366369
});
367370

368371
it('should send cleanup action (stopRunner=false) to the app if there were failed invocations', async () => {
@@ -371,7 +374,7 @@ describe('Client', () => {
371374
await expect(client.execute(anInvocation)).rejects.toThrowError(/Test Failed.*SomeDetails/);
372375
mockAws.mockResponse('cleanupDone');
373376
await client.cleanup();
374-
expect(mockAws.send).toHaveBeenCalledWith(new actions.Cleanup(false));
377+
expect(mockAws.send).toHaveBeenCalledWith(new actions.Cleanup(false), SEND_OPTIONS.TIMED);
375378
});
376379

377380
it('should close the websocket upon "cleanupDone" from the app', async () => {
@@ -381,11 +384,32 @@ describe('Client', () => {
381384
expect(mockAws.close).toHaveBeenCalled();
382385
});
383386

384-
it('should close the websocket even on error', async () => {
387+
it('should close the websocket even if getting "cleanupDone" fails', async () => {
388+
await client.connect();
389+
mockAws.mockResponse('serverError');
390+
await client.cleanup();
391+
expect(mockAws.close).toHaveBeenCalled();
392+
});
393+
394+
it('should close the websocket even on an inner error', async () => {
395+
await client.connect();
396+
mockAws.mockSyncError('MyError');
397+
await client.cleanup();
398+
expect(mockAws.close).toHaveBeenCalled();
399+
expect(log.error).toHaveBeenCalledWith({ event: 'ERROR' }, expect.stringContaining('MyError'));
400+
});
401+
402+
it('should not bail even if the world is crashing, instead it should log errors and exit calmly', async () => {
385403
await client.connect();
386-
mockAws.mockSyncError('UnexpectedError');
387-
await expect(client.cleanup()).rejects.toThrowError('UnexpectedError');
404+
405+
mockAws.send.mockRejectedValue('MyError1');
406+
mockAws.close.mockRejectedValue('MyError2');
407+
408+
await client.cleanup();
409+
388410
expect(mockAws.close).toHaveBeenCalled();
411+
expect(log.error).toHaveBeenCalledWith({ event: 'ERROR' }, expect.stringContaining('MyError1'));
412+
expect(log.error).toHaveBeenCalledWith({ event: 'ERROR' }, expect.stringContaining('MyError2'));
389413
});
390414

391415
it('should delete the injected .terminateApp method', async () => {
@@ -543,7 +567,7 @@ describe('Client', () => {
543567
mockAws.mockEventCallback('appConnected');
544568
mockAws.mockResponse('ready');
545569
await fastForwardAllPromises();
546-
expect(mockAws.send).toHaveBeenCalledWith(new actions.Ready());
570+
expect(mockAws.send).toHaveBeenCalledWith(new actions.Ready(), SEND_OPTIONS.DEFAULT);
547571
expect(isReady).toBe(true);
548572
});
549573

@@ -558,7 +582,7 @@ describe('Client', () => {
558582
await client.connect();
559583
await fastForwardAllPromises();
560584
expect(isReady).toBe(true);
561-
expect(mockAws.send).not.toHaveBeenCalledWith(new actions.Ready());
585+
expect(mockAws.send).not.toHaveBeenCalledWith(new actions.Ready(), expect.anything());
562586
});
563587
})
564588

@@ -692,4 +716,9 @@ describe('Client', () => {
692716
await Promise.resolve();
693717
}
694718
}
719+
720+
const SEND_OPTIONS = {
721+
DEFAULT: { timeout: 0 },
722+
TIMED: { timeout: 5000 },
723+
};
695724
});

0 commit comments

Comments
 (0)