From 6603afdbff5def788c27fcde65908f82779f77ba Mon Sep 17 00:00:00 2001 From: theluckystrike <51033404+theluckystrike@users.noreply.github.com> Date: Wed, 7 Oct 2026 22:36:55 +0700 Subject: [PATCH] fix(driver): fail commands instead of hanging when the Dart VM connection closes rpc-websockets 10.0.1 keeps a pending call until a reply or its timeout arrives, and executeSocketCommand sets no timeout. When the Dart VM socket closes during a command (app crash or relaunch), the call never settles and the Appium command hangs until newCommandTimeout. IsolateSocket now rejects pending calls on `close`. rpc-websockets emits `close` only after it set `ready` to false, so calls made after the drop are already refused with "socket not ready" and only calls from the dead socket are in the queue. The connect wrapper turns a rejection into an undefined result. executeElementCommand now throws a clear error for an empty reply instead of a TypeError on `data.isError`. The wrapper also logged `JSON.stringify(e)`, which is `{}` for an Error. It now logs the Error itself, so the server log names the cause the new message points to. Fixes #733 I ran these in driver/ on this commit (main base e13a971), Node 22.23.2. ``` $ npm test main lib + new tests tests 7 pass 5 fail 2 rejects a pending command when the Dart VM connection closes test timed out after 5000ms fails an element command instead of hanging when the Dart VM ... test timed out after 5000ms this commit tests 7 pass 7 fail 0 $ npm run lint exit 0 $ npm run format:check All matched files use the correct format. ``` End to end I ran the call the Appium router makes, `FlutterDriver.executeCommand('execute', 'flutter:waitForAbsent', [finder])`, against a fake Dart VM service. It drops the socket mid-command and never replies, the way a crashing app does. ``` main info Connection to ws://127.0.0.1:53066/ws closed executeCommand('execute', 'flutter:waitForAbsent') -> STILL PENDING after 15000 ms (hang) this commit info Connection to ws://127.0.0.1:53104/ws closed ERR! Error: The Dart VM connection was closed before it replied executeCommand('execute', 'flutter:waitForAbsent') -> rejected: Cannot execute command waitForAbsent, no response from the Dart VM. Check the server log for the connection error [4 ms] ``` I didn't run the device functional workflow (driver-function.yml) because it needs an iOS simulator with a built Flutter app. --- driver/lib/sessions/isolate_socket.ts | 14 ++++++ driver/lib/sessions/observatory.ts | 7 ++- driver/test/isolate-socket.test.mjs | 63 +++++++++++++++++++++++++++ 3 files changed, 83 insertions(+), 1 deletion(-) create mode 100644 driver/test/isolate-socket.test.mjs 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']); +});