Skip to content

Commit 2f77271

Browse files
committed
fix(android): give a helper start a share of the caller's own timeout
1 parent 75f6269 commit 2f77271

3 files changed

Lines changed: 42 additions & 8 deletions

File tree

CHANGELOG.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,11 @@
2020
`pidof` happens to be unreadable. A helper start that fails is also retried after a backoff scaled
2121
to how long it spent failing (10 s to 60 s) instead of on every command, which had roughly doubled
2222
command time on hosts where the helper never starts, and the wait for a started helper to announce
23-
itself now uses the caller's own helper-command budget, so `--timeout` reaches it.
23+
itself now takes a share of the caller's own helper-command budget — half of `--timeout`, never
24+
less than one session command is worth — so a device that needs longer than a capture to bring the
25+
helper up stays on the persistent path when the caller budgeted for it. On a host where the helper
26+
took 12 s to announce itself, `--timeout 60000` used to answer with the one-shot transport and now
27+
answers from the session.
2428

2529
- Fixed: an iOS snapshot whose XCTest query-sweep tier cannot read the screen no longer ends the
2630
runner process. On a live React Native feed (Bluesky Home, images re-rendering) the AX server

packages/platform-android/src/__tests__/snapshot-helper-session-lifecycle.test.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { afterEach, beforeEach, test } from 'vitest';
33
import { captureAndroidSnapshotWithHelperSession } from '../snapshot-helper-session.ts';
44
import {
55
resetAndroidSnapshotHelperSessions,
6+
resolveAndroidSnapshotHelperStartBudgetMs,
67
stopAndroidSnapshotHelperSession,
78
} from '../snapshot-helper-session-lifecycle.ts';
89
import { recoverAndroidSnapshotHelperRetirement } from '../snapshot-helper-retirement.ts';
@@ -123,6 +124,16 @@ test('a session start waits only as long as the caller budgeted for one helper c
123124
);
124125
});
125126

127+
test('a generous caller budget buys a slow start, and never more than the caller allowed', () => {
128+
// A capture-sized guess is what pushed the slow hosts of #2553 off the persistent path even when
129+
// `--timeout` left plenty of room for the same start in the one-shot transport.
130+
assert.equal(resolveAndroidSnapshotHelperStartBudgetMs(60_000), 30_000);
131+
assert.equal(resolveAndroidSnapshotHelperStartBudgetMs(30_000), 15_000);
132+
// A short budget buys nothing extra, and a tiny one is not answered with a longer wait.
133+
assert.equal(resolveAndroidSnapshotHelperStartBudgetMs(6_000), 5_000);
134+
assert.equal(resolveAndroidSnapshotHelperStartBudgetMs(1_000), 1_000);
135+
});
136+
126137
test('a session that reaches ready settles a release the device could not confirm', async () => {
127138
const calls: string[][] = [];
128139
const spawnArgs: string[][] = [];

packages/platform-android/src/snapshot-helper-session-lifecycle.ts

Lines changed: 26 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -120,15 +120,15 @@ export async function acquireAndroidSnapshotHelperSession(
120120
if (!isAndroidSnapshotHelperSessionEnabled() || !options.adbProvider?.spawn) {
121121
return undefined;
122122
}
123-
const resolved = resolvePersistentSessionCaptureOptions(
124-
resolveAndroidSnapshotHelperCaptureOptions(options),
125-
);
123+
const callerResolved = resolveAndroidSnapshotHelperCaptureOptions(options);
124+
const resolved = resolvePersistentSessionCaptureOptions(callerResolved);
126125
const identity = createSessionIdentity(deviceKey, resolved, options);
127126
const session = await resolveAndroidSnapshotHelperSession({
128127
deviceKey,
129128
identity,
130129
options,
131130
resolved,
131+
startBudgetMs: resolveAndroidSnapshotHelperStartBudgetMs(callerResolved.commandTimeoutMs),
132132
});
133133
return session ? { session, resolved, deviceKey } : undefined;
134134
}
@@ -148,6 +148,7 @@ async function resolveAndroidSnapshotHelperSession(params: {
148148
identity: string;
149149
options: AndroidSnapshotHelperCaptureOptions;
150150
resolved: AndroidSnapshotHelperResolvedCaptureOptions;
151+
startBudgetMs: number;
151152
}): Promise<AndroidSnapshotHelperSession | undefined> {
152153
if (isAndroidSnapshotHelperStartBackedOff(params.identity)) return undefined;
153154
await retireUnusableAndroidSnapshotHelperSession(params.deviceKey, params.identity);
@@ -178,6 +179,7 @@ async function tryStartAndroidSnapshotHelperSession(params: {
178179
identity: string;
179180
options: AndroidSnapshotHelperCaptureOptions;
180181
resolved: AndroidSnapshotHelperResolvedCaptureOptions;
182+
startBudgetMs: number;
181183
}): Promise<AndroidSnapshotHelperSession | undefined> {
182184
const startedAtMs = Date.now();
183185
try {
@@ -240,6 +242,7 @@ async function startAndroidSnapshotHelperSession(params: {
240242
identity: string;
241243
options: AndroidSnapshotHelperCaptureOptions;
242244
resolved: AndroidSnapshotHelperResolvedCaptureOptions;
245+
startBudgetMs: number;
243246
}): Promise<AndroidSnapshotHelperSession> {
244247
const port = await allocateAndroidSnapshotHelperSessionPort();
245248
await params.options.adb(['forward', `tcp:${port}`, `tcp:${port}`], {
@@ -278,12 +281,12 @@ async function startAndroidSnapshotHelperSession(params: {
278281
capturedCount: 0,
279282
};
280283
try {
281-
// Starting the session gets the budget the caller already allowed one helper command, which is
282-
// how `--timeout` reaches it. A fixed guess below that pushed a slow device out of the persistent
283-
// path while the one-shot transport it fell back to had room for the same start.
284+
// A helper that announces itself late is a slow `am instrument`, which the one-shot transport it
285+
// falls back to pays too. The start gets its share of the caller's command budget instead of a
286+
// fixed guess, so `--timeout` decides whether the persistent path is affordable at all.
284287
await waitForAndroidSnapshotHelperSessionReady(
285288
childProcess,
286-
params.resolved.commandTimeoutMs,
289+
params.startBudgetMs,
287290
params.options.signal,
288291
);
289292
sessions.set(params.deviceKey, session);
@@ -369,6 +372,22 @@ function resolvePersistentSessionCaptureOptions(
369372
};
370373
}
371374

375+
/**
376+
* What a start gets out of the budget the caller allowed one helper command: half of it, so a helper
377+
* that announces itself later than a session capture takes is not pushed off the persistent path by
378+
* a capture-sized guess, while the one-shot transport that answers a failed start keeps the other
379+
* half. Never less than one session command is worth, never more than the caller allowed.
380+
*/
381+
export function resolveAndroidSnapshotHelperStartBudgetMs(commandTimeoutMs: number): number {
382+
return Math.min(
383+
commandTimeoutMs,
384+
Math.max(
385+
Math.floor(commandTimeoutMs / 2),
386+
SESSION_CAPTURE_TIMEOUT_MS + SESSION_REQUEST_OVERHEAD_MS,
387+
),
388+
);
389+
}
390+
372391
function isAndroidSnapshotHelperSessionEnabled(): boolean {
373392
const value = requireAndroidAdbHost().environment.AGENT_DEVICE_ANDROID_SNAPSHOT_HELPER_SESSION;
374393
return value === undefined || !/^(0|false|no|off)$/i.test(value);

0 commit comments

Comments
 (0)