Skip to content

Commit 1adbc57

Browse files
committed
fix(cloud): cancel a screen read that its caller gave up on (#2509)
A hosted page-source read that outran its request was dropped by the client while the driver kept walking the UI tree. Nothing at the wire said the answer was no longer wanted, so the read stayed in flight and held the session it was blocking. On a screen that never goes still -- a looping video, a live ticker, continuous animation -- every later command then queued behind a capture nobody was waiting for, which is what makes one stuck `snapshot -i` look like a frozen session. Bind the read to the request that asked for it, on both Android and iOS, and say what a source read that runs out of budget was waiting for. The timeout keeps its `webdriver_request_timeout` reason and gains a hint naming a screen that never goes still, so the rented minutes end with a cause rather than a silent hang. Closes #2509
1 parent b7c82ea commit 1adbc57

9 files changed

Lines changed: 288 additions & 24 deletions

File tree

packages/provider-webdriver/src/webdriver-client.test.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,29 @@ test('installApp aborts an in-flight provider request when its binding is cancel
106106
}
107107
});
108108

109+
// #2509: a page-source read that outlived its request was dropped by the client
110+
// while Appium kept walking the tree server-side. Providers serialize commands
111+
// per session, so every later request queued behind an orphan nobody was
112+
// waiting for. Cancellation must reach `/source` the way it reaches `/install_app`.
113+
test('source aborts an in-flight provider request when its binding is cancelled', async () => {
114+
const controller = new AbortController();
115+
let requestSignal: AbortSignal | undefined;
116+
const client = await connectedClient(
117+
async (_input, init) =>
118+
await new Promise<Response>((_resolve, reject) => {
119+
requestSignal = init?.signal ?? undefined;
120+
init?.signal?.addEventListener('abort', () => reject(init.signal?.reason as Error));
121+
}),
122+
);
123+
124+
const pending = client.source({ signal: controller.signal });
125+
await Promise.resolve();
126+
controller.abort(new Error('request cancelled'));
127+
128+
await assert.rejects(pending, /request cancelled/);
129+
assert.equal(requestSignal?.aborted, true);
130+
});
131+
109132
// #1774: `POST /session` is the one non-idempotent request, and a retry after a
110133
// failed create is a second billed device session. A 5xx that the transport
111134
// would retry on any other route must reach the caller unretried here.

packages/provider-webdriver/src/webdriver-client.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -244,8 +244,14 @@ export class WebDriverClient {
244244
await this.sessionRequest('POST', '/orientation', { orientation });
245245
}
246246

247-
async source(): Promise<string> {
248-
const value = await this.sessionRequest('GET', '/source');
247+
/**
248+
* The driver's whole page source. This is the provider's most expensive call and
249+
* the only one whose duration the device's own UI decides — a screen that never
250+
* goes idle has no reason to settle — so a caller that can be cancelled should
251+
* hand its `signal` down rather than abandon a read still running server-side.
252+
*/
253+
async source(overrides?: WebDriverRequestOverrides): Promise<string> {
254+
const value = await this.sessionRequest('GET', '/source', undefined, overrides);
249255
if (typeof value !== 'string') {
250256
throw new AppError('COMMAND_FAILED', 'WebDriver source response was not a string', {
251257
valueType: typeof value,

packages/provider-webdriver/src/webdriver-interactor.test.ts

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { AppError } from '@agent-device/kernel/errors';
55
import { createCloudWebDriverCapabilities } from './capabilities.ts';
66
import type { WebDriverClient, W3CActionSequence } from './webdriver-client.ts';
77
import { createWebDriverInteractor } from './webdriver-interactor.ts';
8+
import { isWebDriverRequestTimeout } from './webdriver-transport.ts';
89

910
// #1658: `fill` used to send its keys in the request right after the tap. A
1011
// WebView input takes first responder asynchronously, so on a web login form
@@ -291,6 +292,82 @@ test('Android WebDriver interactor keeps legacy-derived source facts at its call
291292
assert.equal(source.mock.calls.length, 1);
292293
});
293294

295+
// #2509: the interactor took a request-bound signal and named it away. A capture
296+
// that ran past its budget could therefore never be cancelled: the client gave up
297+
// while the provider kept walking the tree, and being per-session-serial it made
298+
// every later command queue behind an orphan nobody was waiting for.
299+
test('Android snapshot binds the provider source read to its request signal', async () => {
300+
const controller = new AbortController();
301+
const forwarded: Array<{ signal?: AbortSignal } | undefined> = [];
302+
const interactor = createWebDriverInteractor({
303+
client: {
304+
source: async (overrides?: { signal?: AbortSignal }) => {
305+
forwarded.push(overrides);
306+
return ANDROID_ONBOARDING_SOURCE;
307+
},
308+
} as unknown as WebDriverClient,
309+
backend: 'android',
310+
capabilities: createCloudWebDriverCapabilities({ provider: 'test', platform: 'android' }),
311+
});
312+
313+
await interactor.snapshot({ signal: controller.signal });
314+
315+
assert.deepEqual(forwarded, [{ signal: controller.signal }]);
316+
});
317+
318+
// The iOS acquisition adapter reads the same route, so it needs the same binding.
319+
test('iOS snapshot binds the provider source read to its request signal', async () => {
320+
const controller = new AbortController();
321+
const forwarded: Array<{ signal?: AbortSignal } | undefined> = [];
322+
const interactor = createWebDriverInteractor({
323+
client: {
324+
source: async (overrides?: { signal?: AbortSignal }) => {
325+
forwarded.push(overrides);
326+
return '<AppiumAUT><XCUIElementTypeApplication x="0" y="0" width="390" height="844" /></AppiumAUT>';
327+
},
328+
} as unknown as WebDriverClient,
329+
backend: 'xctest',
330+
capabilities: createCloudWebDriverCapabilities({ provider: 'test', platform: 'ios' }),
331+
targetId: 'ios-1',
332+
});
333+
334+
await interactor.snapshot({ signal: controller.signal });
335+
336+
assert.deepEqual(forwarded, [{ signal: controller.signal }]);
337+
});
338+
339+
// #2509 asked for an error that names the problem: a screen that never goes idle
340+
// (looping video, live marquee) keeps the provider's tree walk from settling, and
341+
// on rented hardware every second of it is billed. The reason code stays the
342+
// transport's; what the capture adds is what it means.
343+
test('a source capture that runs out of budget keeps the timeout reason and names the cause', async () => {
344+
const interactor = createWebDriverInteractor({
345+
client: { source: async () => throwWebDriverSourceTimeout() } as unknown as WebDriverClient,
346+
backend: 'android',
347+
capabilities: createCloudWebDriverCapabilities({ provider: 'test', platform: 'android' }),
348+
});
349+
350+
await assert.rejects(interactor.snapshot(), (error: unknown) => {
351+
assert.ok(error instanceof AppError);
352+
assert.equal(error.details?.reason, 'webdriver_request_timeout');
353+
assert.equal(isWebDriverRequestTimeout(error), true);
354+
assert.match(String(error.details?.hint), /never goes idle/);
355+
return true;
356+
});
357+
});
358+
359+
function throwWebDriverSourceTimeout(): never {
360+
throw new AppError('COMMAND_FAILED', 'WebDriver GET /source timed out after 30000ms.', {
361+
reason: 'webdriver_request_timeout',
362+
method: 'GET',
363+
path: '/source',
364+
timeoutMs: 30_000,
365+
});
366+
}
367+
368+
const ANDROID_ONBOARDING_SOURCE =
369+
'<hierarchy rotation="0"><android.widget.Button content-desc="Continue" bounds="[0,0][100,40]" displayed="true" enabled="true" /></hierarchy>';
370+
294371
async function runFill(world: ReturnType<typeof createTextEntryWorld>) {
295372
vi.useFakeTimers();
296373
try {

packages/provider-webdriver/src/webdriver-interactor.ts

Lines changed: 47 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import {
2222
type CloudWebDriverProviderCapabilities,
2323
} from './capabilities.ts';
2424
import type { W3CPointerAction, WebDriverClient, WebDriverWindowRect } from './webdriver-client.ts';
25+
import { isWebDriverRequestTimeout } from './webdriver-transport.ts';
2526
import { touchPointer } from './webdriver-gestures.ts';
2627
import {
2728
scrollFrameFromAndroidWebDriverSource,
@@ -303,20 +304,39 @@ class WebDriverInteractor implements Interactor {
303304
await this.client.screenshot(outPath);
304305
}
305306

306-
async snapshot(_options?: SnapshotOptions) {
307+
async snapshot(options?: SnapshotOptions) {
307308
this.requireSupport('snapshot');
309+
return await this.captureSource(options?.signal);
310+
}
311+
312+
/**
313+
* One page-source read, bound to the request that asked for it. Providers answer
314+
* commands one at a time, so a read the client merely gave up on is not gone: the
315+
* driver keeps walking the tree and every later command queues behind it (#2509).
316+
*/
317+
private async captureSource(signal?: AbortSignal) {
318+
const source = await this.readSource(signal);
308319
if (this.backend === 'xctest') {
309-
const { captureWebDriverIosSnapshot } = await import('./webdriver-ios-snapshot.ts');
310-
return await captureWebDriverIosSnapshot(this.client, this.targetId);
320+
const { acquireWebDriverIosSnapshot } = await import('./webdriver-ios-snapshot.ts');
321+
return acquireWebDriverIosSnapshot(source, this.targetId);
311322
}
312323
const { parseWebDriverSourceFacts } = await import('./webdriver-source.ts');
313324
return {
314325
backend: 'android' as const,
315326
producer: 'appium-source' as const,
316-
nodes: parseWebDriverSourceFacts(await this.client.source(), 'android').nodes,
327+
nodes: parseWebDriverSourceFacts(source, 'android').nodes,
317328
};
318329
}
319330

331+
private async readSource(signal?: AbortSignal): Promise<string> {
332+
try {
333+
return await this.client.source(signal === undefined ? {} : { signal });
334+
} catch (error) {
335+
if (!isWebDriverRequestTimeout(error)) throw error;
336+
throw webDriverSourceTimeoutError(error);
337+
}
338+
}
339+
320340
async back(_mode?: BackMode): Promise<void> {
321341
this.requireSupport('back');
322342
await this.client.back();
@@ -573,3 +593,26 @@ function webDriverOperationForGesture(plan: GesturePlan): CloudWebDriverOperatio
573593
return 'rotateGesture';
574594
}
575595
}
596+
597+
/**
598+
* A source read that ran out of budget is the one WebDriver timeout a caller can
599+
* do something about, and #2509 showed it reading as an unexplained hang: the
600+
* driver answers this call by walking the live UI tree, so a screen that never
601+
* goes idle — looping video, live ticker, continuous animation — gives the walk no
602+
* reason to settle. The transport's reason code is kept as-is; what the capture
603+
* adds is what the wait was waiting for.
604+
*/
605+
function webDriverSourceTimeoutError(error: AppError): AppError {
606+
return new AppError(
607+
'COMMAND_FAILED',
608+
'The cloud driver did not finish reading the screen in its budget.',
609+
{
610+
...error.details,
611+
hint:
612+
'A screen that never goes idle (looping video, live ticker, continuous animation) ' +
613+
'gives the driver no moment to read the UI tree. Retry with a larger --timeout, ' +
614+
'or drive the screen from refs an earlier snapshot already captured.',
615+
},
616+
error,
617+
);
618+
}

packages/provider-webdriver/src/webdriver-ios-snapshot.test.ts

Lines changed: 4 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,6 @@
11
import assert from 'node:assert/strict';
2-
import { test, vi } from 'vitest';
3-
import {
4-
captureWebDriverIosSnapshot,
5-
acquireWebDriverIosSnapshot,
6-
} from './webdriver-ios-snapshot.ts';
2+
import { test } from 'vitest';
3+
import { acquireWebDriverIosSnapshot } from './webdriver-ios-snapshot.ts';
74

85
const SOURCE = `<AppiumAUT>
96
<XCUIElementTypeApplication type="XCUIElementTypeApplication" name="Example" label="Example" enabled="true" visible="true" x="0" y="0" width="390" height="844">
@@ -13,9 +10,8 @@ const SOURCE = `<AppiumAUT>
1310
</XCUIElementTypeApplication>
1411
</AppiumAUT>`;
1512

16-
test('Appium iOS adapter returns provider facts with explicit unavailable residue', async () => {
17-
const source = vi.fn(async () => SOURCE);
18-
const result = await captureWebDriverIosSnapshot({ source }, 'cloud-ios-1');
13+
test('Appium iOS adapter returns provider facts with explicit unavailable residue', () => {
14+
const result = acquireWebDriverIosSnapshot(SOURCE, 'cloud-ios-1');
1915

2016
assert.equal(result.stage, 'acquired');
2117
assert.equal(result.acquisition.producer, 'appium-source');
@@ -34,7 +30,6 @@ test('Appium iOS adapter returns provider facts with explicit unavailable residu
3430
{ kind: 'unavailable-fact', fact: 'acquisition-depth' },
3531
{ kind: 'unavailable-fact', fact: 'truncation' },
3632
]);
37-
assert.equal(source.mock.calls.length, 1);
3833
});
3934

4035
test('Appium iOS adapter preserves provider-reported node facts for the host presenter', () => {

packages/provider-webdriver/src/webdriver-ios-snapshot.ts

Lines changed: 1 addition & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -3,18 +3,11 @@ import {
33
resolveIosViewportEvidenceFromRoots,
44
} from '@agent-device/capture-kit/ios-snapshot-acquisition';
55
import type { SnapshotRuntimeAcquiredResult } from '@agent-device/contracts/interactor-types';
6-
import type { WebDriverClient } from './webdriver-client.ts';
76
import { parseWebDriverSourceFacts } from './webdriver-source.ts';
87

98
const APPIUM_PRODUCER = 'appium-source' as const;
109

11-
export async function captureWebDriverIosSnapshot(
12-
client: Pick<WebDriverClient, 'source'>,
13-
targetId?: string,
14-
): Promise<SnapshotRuntimeAcquiredResult> {
15-
return acquireWebDriverIosSnapshot(await client.source(), targetId);
16-
}
17-
10+
/** Turns one already-read Appium page source into acquired iOS facts. */
1811
export function acquireWebDriverIosSnapshot(
1912
source: string,
2013
targetId?: string,

0 commit comments

Comments
 (0)