diff --git a/driver/lib/sessions/isolate_socket.ts b/driver/lib/sessions/isolate_socket.ts index 77f850ff..b1268f04 100644 --- a/driver/lib/sessions/isolate_socket.ts +++ b/driver/lib/sessions/isolate_socket.ts @@ -23,6 +23,10 @@ export class IsolateSocket extends Client { // @ts-ignore - runtime call is valid; types are provided by the package super(address as any); + // rpc-websockets keeps a pending call until a reply or its timeout arrives, and + // executeSocketCommand sets no timeout. Reject pending calls when the Dart VM connection + // closes (e.g. the app crashed or relaunched) so the command fails instead of hanging. + this.on(`close`, () => this.rejectPendingCalls()); } public async executeSocketCommand(args: ExecuteArgs) { @@ -35,4 +39,14 @@ export class IsolateSocket extends Client { response: any; }>; } + + private rejectPendingCalls() { + const queue: Record void]; timeout?: NodeJS.Timeout}> = + (this as any).queue ?? {}; + for (const [id, {promise, timeout}] of Object.entries(queue)) { + clearTimeout(timeout); + delete queue[id]; + promise[1](new Error(`The Dart VM connection was closed before it replied`)); + } + } } diff --git a/driver/lib/sessions/observatory.ts b/driver/lib/sessions/observatory.ts index a7552d68..f80a526f 100644 --- a/driver/lib/sessions/observatory.ts +++ b/driver/lib/sessions/observatory.ts @@ -70,7 +70,7 @@ export async function connectSocket( // `await` is needed so that rejected promise will be thrown and caught return await originalSocketCall.apply(socket, args); } catch (e) { - this.log.errorWithException(new Error(JSON.stringify(e))); + this.log.errorWithException(e instanceof Error ? e : new Error(JSON.stringify(e))); } }; this.log.info(`Connecting to Dart Observatory: ${dartObservatoryURL}`); @@ -183,6 +183,11 @@ export async function executeElementCommand( this.log.debug(`>>> ${JSON.stringify(serializedCommand)}`); const data = await (this.socket as IsolateSocket).executeSocketCommand(serializedCommand); this.log.debug(`<<< ${JSON.stringify(data)} | previous command ${command}`); + if (!data) { + throw new Error( + `Cannot execute command ${command}, no response from the Dart VM. Check the server log for the connection error`, + ); + } if (data.isError) { throw new Error(`Cannot execute command ${command}, server response ${JSON.stringify(data, null, 2)}`); } diff --git a/driver/test/isolate-socket.test.mjs b/driver/test/isolate-socket.test.mjs new file mode 100644 index 00000000..5ed5d7a4 --- /dev/null +++ b/driver/test/isolate-socket.test.mjs @@ -0,0 +1,63 @@ +import assert from 'node:assert/strict'; +import {after, before, it} from 'node:test'; + +import {Server} from 'rpc-websockets'; + +import {IsolateSocket} from '../build/lib/sessions/isolate_socket.js'; +import {connectSocket, executeElementCommand} from '../build/lib/sessions/observatory.js'; + +const noopLog = {info() {}, debug() {}, warn() {}, error() {}, errorWithException() {}}; +const sockets = []; +let server; +let url; + +// A fake Dart VM service that drops the connection while a Flutter Driver command is pending, +// like an app that crashes or relaunches in the middle of `waitForAbsent`. +before(async () => { + server = new Server({host: '127.0.0.1', port: 0}); + await new Promise((resolve) => server.on('listening', resolve)); + url = `ws://127.0.0.1:${server.wss.address().port}/ws`; + server.register('getIsolate', () => ({extensionRPCs: ['ext.flutter.driver']}), '/ws'); + server.register( + 'ext.flutter.driver', + () => { + for (const client of server.wss.clients) { + client.terminate(); + } + return new Promise(() => {}); + }, + '/ws', + ); +}); + +after(async () => { + for (const socket of sockets) { + socket.reconnect = false; + socket.close(); + } + await server.close(); +}); + +it('rejects a pending command when the Dart VM connection closes', {timeout: 5000}, async () => { + const socket = new IsolateSocket(url); + sockets.push(socket); + await new Promise((resolve) => socket.once('open', resolve)); + + await assert.rejects(socket.executeSocketCommand({command: 'waitForAbsent'}), /connection was closed/); +}); + +it('fails an element command instead of hanging when the Dart VM connection closes', {timeout: 5000}, async () => { + const logged = []; + const driver = {log: {...noopLog, errorWithException: (err) => logged.push(err.message)}}; + driver.socket = await connectSocket.call(driver, url, {isolateId: 'isolates/1'}); + sockets.push(driver.socket); + const finder = Buffer.from( + JSON.stringify({finderType: 'ByValueKey', keyValueString: 'my_key', keyValueType: 'String'}), + ).toString('base64url'); + + await assert.rejects( + executeElementCommand.call(driver, 'waitForAbsent', finder), + /Cannot execute command waitForAbsent, no response from the Dart VM/, + ); + assert.deepEqual(logged, ['The Dart VM connection was closed before it replied']); +});