Skip to content

Commit f4a514b

Browse files
committed
fix(android): read a release only from an answer, not from an adb that complained
1 parent 2f77271 commit f4a514b

6 files changed

Lines changed: 100 additions & 56 deletions

CHANGELOG.md

Lines changed: 19 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -8,23 +8,26 @@
88
outlive its budget while the device is healthy — the shape of #2553 — so the helper process was
99
already gone and the command still failed with `Android automation helper is still holding device
1010
automation ownership`. Ownership is read off the device now: `adb shell pidof
11-
com.callstack.agentdevice.snapshothelper` says `occupied` only while it names a process, `released`
12-
when the device answers that nothing is running, and `unknown` when adb could not carry the call,
13-
so a `device offline` stderr no longer counts as a release either. A refusal requires two reads that
14-
both name the process, which keeps a helper still inside Android's exit path from costing a
15-
command. Scripts that match the failure reason see `android_snapshot_helper_runtime_occupied`,
16-
which replaces `android_snapshot_helper_retirement_unconfirmed`.
11+
com.callstack.agentdevice.snapshothelper` says `occupied` while it names a process, and says
12+
`released` only on the shell's own no-process answer — a non-zero exit with nothing on either
13+
stream. Anything else, including `error: closed`, `cannot connect to daemon` and `device offline`,
14+
is `unknown`: those describe the transport, not who holds the runtime, and a release is never
15+
cleared on a description of the transport. A refusal requires two reads that both name the process,
16+
which keeps a helper still inside Android's exit path from costing a command. Scripts that match the
17+
failure reason see `android_snapshot_helper_runtime_occupied`, which replaces
18+
`android_snapshot_helper_retirement_unconfirmed`.
1719
- Changed (android): a snapshot helper session that reaches ready settles a release the previous
18-
teardown could not prove, because Android hands UiAutomation to one connection at a time and that
19-
helper owns it now; the next command no longer force-stops the session it has just started while
20-
`pidof` happens to be unreadable. A helper start that fails is also retried after a backoff scaled
21-
to how long it spent failing (10 s to 60 s) instead of on every command, which had roughly doubled
22-
command time on hosts where the helper never starts, and the wait for a started helper to announce
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.
20+
teardown could not prove. `am instrument` force-stops whatever is already instrumenting the helper
21+
package, so a session that reported itself ready is the only helper process the device has left,
22+
and the unproven release went away with the process that owed it; the next command no longer
23+
force-stops the session it has just started because `pidof` happens to be unreadable. A helper start
24+
that fails is also retried after a backoff scaled to how long it spent failing (10 s to 60 s)
25+
instead of on every command, which had roughly doubled command time on hosts where the helper never
26+
starts. And the wait for a started helper to announce itself no longer uses a fixed 10 s: it takes
27+
half of the helper-command budget the capture was built with, 15 s today, which is what had been
28+
pushing devices slower than a capture off the persistent path. On a host where the helper took 12 s
29+
to announce itself, the command used to answer with the one-shot transport and now answers from the
30+
session. The CLI's `--timeout` reaches that wait as its deadline aborting it, not as the number.
2831

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

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

Lines changed: 32 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import {
1010
retireCanceledAndroidSnapshotHelperCapture,
1111
settleAndroidSnapshotHelperSessionCleanup,
1212
} from '../snapshot-helper-retirement.ts';
13-
import type { AndroidAdbProcess } from '../adb-executor.ts';
13+
import type { AndroidAdbExecutorResult, AndroidAdbProcess } from '../adb-executor.ts';
1414
import type { AndroidAdbExecutor } from '../snapshot-helper-types.ts';
1515
import {
1616
androidHelperRuntimeProbeResult,
@@ -126,15 +126,37 @@ test('a device that cannot be read leaves the retirement pending without failing
126126
]);
127127
});
128128

129-
test('a shell that has no pidof still answers for its own processes', async () => {
130-
// An older device image reports the missing command on stderr and exits non-zero. That is the
131-
// device answering, not a transport fault, and reading it as `unknown` would keep every later
132-
// acquire force-stopping a runtime that was never held.
133-
const adb: AndroidAdbExecutor = async () => ({
134-
exitCode: 1,
135-
stdout: '',
136-
stderr: '/system/bin/sh: pidof: not found',
137-
});
129+
test('a read that names no process is released only when the shell itself said so', async () => {
130+
// `pidof` answers "no such process" with a non-zero exit and nothing on either stream. Every other
131+
// shape is adb or the shell describing itself, and a description of the transport cannot clear a
132+
// pending release. Enumerating the ways a transport fails is not a fix either: that list is long,
133+
// version-dependent, and includes plain `error: closed` and `cannot connect to daemon`.
134+
const nonAnswers: AndroidAdbExecutorResult[] = [
135+
{ exitCode: 1, stdout: '', stderr: 'error: closed' },
136+
{ exitCode: 1, stdout: '', stderr: 'error: device offline' },
137+
{ exitCode: 1, stdout: '', stderr: 'adb: cannot connect to daemon' },
138+
{ exitCode: 1, stdout: '', stderr: 'failed to get feature set: device offline' },
139+
{ exitCode: 1, stdout: '/system/bin/sh: pidof: not found', stderr: '' },
140+
{ exitCode: 0, stdout: '', stderr: '' },
141+
];
142+
143+
for (const answer of nonAnswers) {
144+
resetAndroidSnapshotHelperRetirements();
145+
const adb: AndroidAdbExecutor = async () => answer;
146+
147+
const release = await recordAndroidSnapshotHelperRelease({
148+
deviceKey: DEVICE_KEY,
149+
packageName: PACKAGE_NAME,
150+
adb,
151+
cause: new Error('quit timed out'),
152+
});
153+
154+
assert.equal(release, 'unknown', `answered ${JSON.stringify(answer)}`);
155+
}
156+
});
157+
158+
test('a device that answers with nothing at all is read as released', async () => {
159+
const adb: AndroidAdbExecutor = async () => ({ exitCode: 1, stdout: '', stderr: '' });
138160

139161
const release = await recordAndroidSnapshotHelperRelease({
140162
deviceKey: DEVICE_KEY,

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

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -137,13 +137,13 @@ test('a generous caller budget buys a slow start, and never more than the caller
137137
test('a session that reaches ready settles a release the device could not confirm', async () => {
138138
const calls: string[][] = [];
139139
const spawnArgs: string[][] = [];
140-
// The device cannot be read at all, and the first command's session stalls, so that command's
141-
// teardown records a release nothing could prove.
140+
// The device answers every process read with an adb error no classifier lists, and the first
141+
// command's session stalls, so that command's teardown records a release nothing could prove.
142142
const provider = createSessionProvider({
143143
calls,
144144
spawnArgs,
145145
stalledSnapshots: 1,
146-
runtimeRelease: 'unreadable',
146+
runtimeRelease: 'closed',
147147
});
148148

149149
const stalled = await captureAndroidSnapshotWithHelperSession({
@@ -163,8 +163,9 @@ test('a session that reaches ready settles a release the device could not confir
163163
assert.equal(started?.metadata.sessionReused, false);
164164
const forceStopsWhilePending = calls.filter(isAndroidHelperRuntimeForceStop).length;
165165

166-
// Android hands UiAutomation to one connection, so the helper that just reported itself ready owns
167-
// the runtime and the unreadable device has nothing left to hold the next command with.
166+
// `am instrument` force-stops whatever is already instrumenting the helper package, so the helper
167+
// that just reported itself ready is the only one the device has, and the pending release went
168+
// away with the process that owed it.
168169
const reused = await captureAndroidSnapshotWithHelperSession({
169170
adb: provider.exec,
170171
adbProvider: provider,

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

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -164,10 +164,14 @@ export type SessionProviderOptions = {
164164
runtimeRelease?: FakeAndroidHelperRuntimeRelease;
165165
};
166166

167-
/** What a fake device says about the helper process, including a device that cannot be read. */
167+
/**
168+
* What a fake device answers about the helper process. `unreadable` is a transport fault adb's own
169+
* failure classifier recognises and `closed` is one it does not; the probe has to fail closed on both.
170+
*/
168171
export type FakeAndroidHelperRuntimeRelease =
169172
| Exclude<AndroidSnapshotHelperRuntimeRelease, 'unknown'>
170-
| 'unreadable';
173+
| 'unreadable'
174+
| 'closed';
171175

172176
export function createSessionProvider(options: SessionProviderOptions): AndroidAdbProvider {
173177
bindAndroidAdbTestHost();
@@ -297,9 +301,10 @@ export function androidHelperRuntimeProbeResult(
297301
release: FakeAndroidHelperRuntimeRelease = 'released',
298302
): AndroidAdbExecutorResult {
299303
// A host whose adb cannot carry the call answers the way the executor really answers it: a non-zero
300-
// exit with a transport fault on stderr, which is a different shape from a device that says "no
301-
// such process" only by what it prints.
304+
// exit, empty stdout and a fault on stderr. The shell's own "no such process" is that same shape
305+
// with nothing at all on stderr, which is the only non-pid answer that means released.
302306
if (release === 'unreadable') return { exitCode: 1, stdout: '', stderr: 'error: device offline' };
307+
if (release === 'closed') return { exitCode: 1, stdout: '', stderr: 'error: closed' };
303308
return release === 'occupied'
304309
? { exitCode: 0, stdout: '4211\n', stderr: '' }
305310
: { exitCode: 1, stdout: '', stderr: '' };

packages/platform-android/src/snapshot-helper-retirement.ts

Lines changed: 21 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@ import { AppError } from '@agent-device/kernel/errors';
22
import type { DeviceInfo } from '@agent-device/kernel/device';
33
import { emitDiagnostic } from '@agent-device/host-kit/diagnostics';
44
import { sleep } from '@agent-device/host-kit/retry';
5-
import { classifyAndroidAdbFailure } from './adb-failure.ts';
65
import { findPidToken } from './perf-native-process.ts';
76
import type { AndroidAdbProcess } from './adb-executor.ts';
87
import type { AndroidAdbExecutor } from './snapshot-helper-types.ts';
@@ -179,13 +178,19 @@ async function readAndroidSnapshotHelperRuntimeRelease(params: {
179178
allowFailure: true,
180179
timeoutMs: ANDROID_SNAPSHOT_HELPER_DEVICE_RETIREMENT_TIMEOUT_MS,
181180
});
181+
const stdout = result.stdout.trim();
182+
const stderr = result.stderr.trim();
182183
// A process id for the helper package is the device naming whoever owns the runtime.
183-
if (findPidToken(result.stdout)) return 'occupied';
184-
// `pidof` prints nothing for "no such process", and adb prints nothing useful on stdout when the
185-
// call never reached a device. On the transport this probe exists for, that shape is common: a
186-
// stderr the adb failure classifier recognises as a device or transport fault is no answer at
187-
// all, while an unclassified one (an older shell without `pidof`) is the device's own.
188-
return classifyAndroidAdbFailure(result.stderr, result.stdout) ? 'unknown' : 'released';
184+
if (findPidToken(stdout)) return 'occupied';
185+
// `pidof` answers "no process" by exiting non-zero with nothing on either stream. Every other
186+
// shape — a line of stderr, a zero exit that names nobody, output without a pid — is adb or the
187+
// shell describing itself, and a transport describing itself says nothing about the runtime.
188+
// Enumerating adb's failure texts is not an option either: the list is long, version-dependent
189+
// and includes plain `error: closed`, and every missed entry would clear a pending release that
190+
// was never proven.
191+
return result.exitCode !== 0 && stdout.length === 0 && stderr.length === 0
192+
? 'released'
193+
: 'unknown';
189194
} catch {
190195
return 'unknown';
191196
}
@@ -306,11 +311,15 @@ export async function stopAndroidSnapshotHelperHostProcess(params: {
306311
}
307312

308313
/**
309-
* Settles a release the last teardown could not prove, from the other end of the device. Android
310-
* hands UiAutomation to one connection at a time, so a helper that has just reported itself ready
311-
* owns it now and whatever held it before no longer does. The session lifecycle calls this on the
312-
* way to ready: an unreadable `pidof` must not leave a pending entry that force-stops a live helper
313-
* on the next acquire.
314+
* Settles a release the last teardown could not prove, from the other end of the device. The fact
315+
* that settles it is Android's, not ours: `am instrument` for a package that is already instrumenting
316+
* force-stops that process first, so a helper that reached ready — which it reports straight after
317+
* binding its session socket, before it asks for UiAutomation — is the only helper process the device
318+
* still has. Whatever the old process held is gone with it. The session lifecycle calls this on the
319+
* way to ready; an unreadable `pidof` must not leave a pending entry that force-stops a live helper
320+
* on the next acquire. Should the helper ever start sharing its package with another instrumentation
321+
* target, or report readiness after acquiring UiAutomation instead of before, this settles on a
322+
* process that may not be the only one, and the pending entry has to stay.
314323
*/
315324
export function settleAndroidSnapshotHelperRetirement(deviceKey: string): void {
316325
pendingRetirements.delete(deviceKey);

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

Lines changed: 13 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -282,18 +282,20 @@ async function startAndroidSnapshotHelperSession(params: {
282282
};
283283
try {
284284
// 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.
285+
// falls back to pays too, so the wait gets a share of the helper-command budget rather than a
286+
// smaller guess. The caller's own deadline reaches it as an abort on `options.signal`, which is
287+
// what bounds this below the budget when the command itself is short.
287288
await waitForAndroidSnapshotHelperSessionReady(
288289
childProcess,
289290
params.startBudgetMs,
290291
params.options.signal,
291292
);
292293
sessions.set(params.deviceKey, session);
293294
failedStarts.delete(params.identity);
294-
// This helper holds the device's one UiAutomation connection now, so a release the previous
295-
// teardown could not prove is settled by the device itself. Leaving it pending would have the
296-
// next acquire force-stop the session that just started.
295+
// `am instrument` force-stops whatever is already instrumenting this package, so a helper that
296+
// reported itself ready is the only helper process the device has left, and the release the
297+
// previous teardown could not prove went away with the process that owed it. Leaving the entry
298+
// pending would have the next acquire force-stop the session that just started.
297299
settleAndroidSnapshotHelperRetirement(params.deviceKey);
298300
emitDiagnostic({
299301
phase: 'android_snapshot_helper_session_ready',
@@ -373,10 +375,12 @@ function resolvePersistentSessionCaptureOptions(
373375
}
374376

375377
/**
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.
378+
* What a start gets out of the helper-command budget it was built with: half of it, so a helper that
379+
* announces itself later than a session capture takes is not pushed off the persistent path by a
380+
* capture-sized guess, while the one-shot transport that answers a failed start keeps the other half.
381+
* Never less than one session command is worth, never more than the budget. Production builds that
382+
* budget from `ANDROID_SNAPSHOT_HELPER_COMMAND_TIMEOUT_MS` (30 s today, so 15 s here) rather than
383+
* from the CLI's `--timeout`, whose deadline reaches this wait as an abort instead.
380384
*/
381385
export function resolveAndroidSnapshotHelperStartBudgetMs(commandTimeoutMs: number): number {
382386
return Math.min(

0 commit comments

Comments
 (0)