Skip to content
Merged
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
29 changes: 29 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,35 @@
- Fixed (android): a chunked `record stop` (recordings over 170 s) no longer warns that screenrecord
stopped before record stop at the 180 s limit. Rotation always ends every earlier chunk before
stop, so the warning now fires only when the last chunk's recorder had already exited.
- Fixed (android): a snapshot helper that could not prove it released device automation no longer
refuses the next command on the strength of an `adb` call. The old code read the outcome of
`am force-stop` as the fact it was supposed to measure, and on a loaded host that round trip can
outlive its budget while the device is healthy — the shape of #2553 — so the helper process was
already gone and the command still failed with `Android automation helper is still holding device
automation ownership`. Ownership is read off the device now: the probe asks the device shell for
`pidof com.callstack.agentdevice.snapshothelper` and tells it to echo a marker when nothing matched.
A process id is `occupied`, the bare marker is `released`, and everything else is `unknown` —
`error: closed`, `cannot connect to daemon`, `device offline`, or an adb client killed by a signal
before it wrote anything. A release is therefore claimed only by an answer the transport cannot
produce about itself, and no exit status is trusted: `adb shell` answers 0 for a device command that
failed, and an adb that dies by signal leaves the executor inventing an exit code it never saw. A
refusal requires two reads that both name the process, which keeps a helper still inside Android's
exit path from costing a command. Scripts that match the failure reason see
`android_snapshot_helper_runtime_occupied`, which replaces
`android_snapshot_helper_retirement_unconfirmed`.
- Changed (android): a snapshot helper session that reaches ready settles a release the previous
teardown could not prove. `am instrument` force-stops whatever is already instrumenting the helper
package, so a session that reported itself ready is the only helper process the device has left,
and the unproven release went away with the process that owed it; the next command no longer
force-stops the session it has just started because `pidof` happens to be unreadable. A helper start
that fails is also retried after a backoff scaled to how long it spent failing (10 s to 60 s)
instead of on every command, which had roughly doubled command time on hosts where the helper never
starts. And the wait for a started helper to announce itself no longer uses a fixed 10 s: it takes
half of the helper-command budget the capture was built with, 15 s today, which is what had been
pushing devices slower than a capture off the persistent path. On a host where the helper took 12 s
to announce itself, the command used to answer with the one-shot transport and now answers from the
session. The CLI's `--timeout` reaches that wait as its deadline aborting it, not as the number.

- Fixed: an iOS snapshot whose XCTest query-sweep tier cannot read the screen no longer ends the
runner process. On a live React Native feed (Bluesky Home, images re-rendering) the AX server
rejects each of the sweep's 19 element-type queries with `kAXErrorIllegalArgument`, and XCTest
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
// Fill reads the live hierarchy four times per attempt (one pre-action target read plus the
// 0/150/350 ms settling samples). Android permits ONE UiAutomation owner, so a command-scoped
// capture stops the automation-helper session after every one of those reads and the next read
// pays a fresh `am instrument` start. These tests pin who owns the helper session across the
// samples — not what the samples conclude, which fill-diagnostics/input-actions-fill own.
// 0/150/350 ms settling samples). `am instrument` force-stops whatever is already instrumenting the
// helper package, so a command-scoped capture stops the automation-helper session after every one of
// those reads and the next read pays a fresh `am instrument` start. These tests pin who owns the
// helper session across the samples — not what the samples conclude, which
// fill-diagnostics/input-actions-fill own.

import { afterEach, beforeEach, test } from 'vitest';
import './test-utils/android-host-test-setup.ts';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,10 @@ import { beforeEach, test } from 'vitest';
import { captureAndroidSnapshotWithHelper } from '../snapshot-helper-capture.ts';
import { resetAndroidSnapshotHelperRetirements } from '../snapshot-helper-retirement.ts';
import type { AndroidAdbExecutor } from '../snapshot-helper-types.ts';
import {
androidHelperRuntimeProbeResult,
isAndroidHelperRuntimeProbe,
} from './snapshot-helper-session.fixtures.ts';

beforeEach(() => {
resetAndroidSnapshotHelperRetirements();
Expand Down Expand Up @@ -31,14 +35,15 @@ test('one-shot capture that resolves during cancellation retires before rejectin
if (options?.signal?.aborted) onAbort();
});
}
if (isAndroidHelperRuntimeProbe(args)) return androidHelperRuntimeProbeResult();
assert.deepEqual(args, [
'shell',
'am',
'force-stop',
'com.callstack.agentdevice.snapshothelper',
]);
assert.notEqual(options?.signal, controller.signal);
assert.equal(options?.signal?.aborted, false);
// The retirement stop must not inherit the aborted command signal; it bounds itself.
assert.ok(!options?.signal?.aborted);
events.push('retirement-started');
await retirementCanFinish;
events.push('retirement-finished');
Expand Down Expand Up @@ -70,22 +75,24 @@ test('one-shot capture that resolves during cancellation retires before rejectin
]);
});

test('uncertain one-shot retirement is quarantined until the next capture recovers it', async () => {
test('canceled one-shot capture reports the cancellation and the next capture recovers the device', async () => {
const controller = new AbortController();
const cancellation = new Error('wait deadline exceeded');
const events: string[] = [];
let forceStopCount = 0;
let stopCount = 0;
const adb: AndroidAdbExecutor = async (args, options) => {
if (args.join(' ').includes('am force-stop')) {
forceStopCount += 1;
events.push(`force-stop-${forceStopCount}`);
return {
exitCode: forceStopCount === 1 ? 1 : 0,
stdout: '',
stderr: forceStopCount === 1 ? 'runtime still busy' : '',
};
stopCount += 1;
events.push(`force-stop-${stopCount}`);
return { exitCode: 0, stdout: '', stderr: '' };
}
if (isAndroidHelperRuntimeProbe(args)) {
// The first read happens while Android still runs the helper; the next says it is gone.
events.push('pidof');
return androidHelperRuntimeProbeResult(stopCount === 1 ? 'occupied' : 'released');
}
events.push(`instrument-${forceStopCount}`);
if (forceStopCount === 0) {
events.push(`instrument-${stopCount}`);
if (stopCount === 0) {
return await new Promise((_resolve, reject) => {
const onAbort = () => reject(options?.signal?.reason);
options?.signal?.addEventListener('abort', onAbort, { once: true });
Expand All @@ -103,21 +110,24 @@ test('uncertain one-shot retirement is quarantined until the next capture recove
deviceKey: 'android:emulator-5554',
signal: controller.signal,
});
controller.abort(new Error('wait deadline exceeded'));
controller.abort(cancellation);

await assert.rejects(
canceledCapture,
(error: unknown) =>
(error as { details?: { reason?: string } }).details?.reason ===
'android_snapshot_helper_retirement_unconfirmed',
);
// The ownership question never replaces the reason this command stopped.
await assert.rejects(canceledCapture, cancellation);
const recovered = await captureAndroidSnapshotWithHelper({
adb,
deviceKey: 'android:emulator-5554',
});

assert.match(recovered.xml, /recovered/);
assert.deepEqual(events, ['instrument-0', 'force-stop-1', 'force-stop-2', 'instrument-2']);
assert.deepEqual(events, [
'instrument-0',
'force-stop-1',
'pidof',
'force-stop-2',
'pidof',
'instrument-2',
]);
});

function helperOutput(xml: string): string {
Expand Down
Loading
Loading