Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions driver/lib/sessions/isolate_socket.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -35,4 +39,14 @@ export class IsolateSocket extends Client {
response: any;
}>;
}

private rejectPendingCalls() {
const queue: Record<string, {promise: [unknown, (err: Error) => 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`));

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1] Handle disconnect rejection during initial VM discovery

This also rejects the initial getVM call in connectSocket when no isolateId is supplied. The socket.call wrapper catches that rejection and returns undefined, after which the async onOpenListener reads vm.isolates outside its try/catch. I reproduced an unhandled TypeError (Cannot read properties of undefined, reading 'isolates') by terminating the connection while getVM was pending; the connection promise remained unsettled. Please handle this setup failure and settle the connection promise rather than allowing the event listener to reject unhandled, and add a test for a disconnect during getVM.

}
}
}
7 changes: 6 additions & 1 deletion driver/lib/sessions/observatory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}`);
Expand Down Expand Up @@ -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) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Propagate disconnect failures to commands that bypass executeElementCommand

This guard only covers commands routed through executeElementCommand. enterText, requestData, setFrameSync, and other handlers call executeSocketCommand directly, so the socket.call wrapper still swallows the newly rejected pending call and resolves with undefined. Using a local RPC server that terminated the connection during ext.flutter.driver, I reproduced execute('flutter:enterText', ['hello']) resolving successfully with undefined instead of rejecting. Please propagate the disconnect failure through those paths too, with a regression test for a direct command handler.

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)}`);
}
Expand Down
63 changes: 63 additions & 0 deletions driver/test/isolate-socket.test.mjs
Original file line number Diff line number Diff line change
@@ -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']);
});
Loading