Skip to content

Commit aeebb3d

Browse files
committed
Merge remote-tracking branch 'origin/main' into claude/ios-webview-remote-content-wait-fix
* origin/main: fix(android): retire completed recording evidence after pid reuse (#2487) # Conflicts: # CHANGELOG.md
2 parents 76e5101 + fd4cee8 commit aeebb3d

12 files changed

Lines changed: 598 additions & 16 deletions

CHANGELOG.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,16 @@
22

33
## Unreleased
44

5+
- Fixed: Android `record start` no longer refuses to begin after a reused emulator reassigned the
6+
previous recorder's pid. A completed recording's native marker is retired only once its recorder is
7+
proven gone, but only an absent pid counted as proof — a pid that now names an unrelated process,
8+
or a recorder that exited and waits to be reaped, did not. `record start` then failed every later
9+
attempt with `Android screenrecord completed evidence cannot be safely retired`, and `record stop`
10+
could not return an already-finalized recording. Proven termination now retires the marker and
11+
returns the stored completion; a live or unreadable recorder still blocks, and the unrelated
12+
process is never signalled. A reused pid that runs a replacement `screenrecord` on the same
13+
remote path proves the old recorder gone but not that the path is free, so that marker and
14+
artifact are retained until the replacement ends, and neither is signalled (#2476).
515
- Fixed: a polling `wait` no longer surrenders its whole budget the first time the iOS runner
616
answers `RUNNER_BUSY`. That code means an earlier command exceeded the runner's execution
717
watchdog and its abandoned main-thread work is still draining, which clears on its own, so a
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import { expect, test } from 'vitest';
2+
import {
3+
type AndroidScreenRecordingProcessOwnership,
4+
provesAndroidScreenRecordPathUnclaimed,
5+
provesAndroidScreenRecordTermination,
6+
} from './screen-recording-runtime-host.ts';
7+
8+
type Proof = readonly [
9+
ownership: AndroidScreenRecordingProcessOwnership,
10+
termination: boolean,
11+
pathUnclaimed: boolean,
12+
];
13+
14+
const PROOFS: readonly Proof[] = [
15+
['missing', true, true],
16+
['ownership-lost', true, true],
17+
['foreign-writer', true, false],
18+
['owned-alive', false, false],
19+
['uncertain', false, false],
20+
];
21+
22+
test.each(PROOFS)(
23+
'observation %s proves termination as %s and an unclaimed path as %s',
24+
(ownership, termination, pathUnclaimed) => {
25+
expect(provesAndroidScreenRecordTermination(ownership)).toBe(termination);
26+
expect(provesAndroidScreenRecordPathUnclaimed(ownership)).toBe(pathUnclaimed);
27+
},
28+
);

packages/contracts/src/screen-recording-runtime-host.ts

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,12 +94,57 @@ export type AndroidScreenRecordingProcessIdentity = Readonly<{
9494
startTime: string;
9595
}>;
9696

97+
/**
98+
* `ownership-lost`: the pid is present, yet the identity readable there names something else — a
99+
* reassigned pid, or an exited task whose command line is already gone. `foreign-writer`: the pid
100+
* runs `screenrecord` on the recorded remote path but started at a different time, so a recorder
101+
* that is not ours is writing that artifact. `uncertain` reads nothing conclusive.
102+
*/
97103
export type AndroidScreenRecordingProcessOwnership =
98104
| 'missing'
99105
| 'owned-alive'
100106
| 'ownership-lost'
107+
| 'foreign-writer'
101108
| 'uncertain';
102109

110+
/**
111+
* Whether an observation proves that the process named by the inspected identity is gone. Both
112+
* `ownership-lost` and `foreign-writer` are proof rather than doubt: the recorded process can no
113+
* longer write its artifact.
114+
*/
115+
export function provesAndroidScreenRecordTermination(
116+
ownership: AndroidScreenRecordingProcessOwnership,
117+
): boolean {
118+
switch (ownership) {
119+
case 'missing':
120+
case 'ownership-lost':
121+
case 'foreign-writer':
122+
return true;
123+
case 'owned-alive':
124+
case 'uncertain':
125+
return false;
126+
}
127+
}
128+
129+
/**
130+
* Whether an observation proves that nothing writes the recorded remote path any more, which is
131+
* what removing the artifact requires. A recorder proven gone is not proof of that: a
132+
* `foreign-writer` has claimed the same path.
133+
*/
134+
export function provesAndroidScreenRecordPathUnclaimed(
135+
ownership: AndroidScreenRecordingProcessOwnership,
136+
): boolean {
137+
switch (ownership) {
138+
case 'missing':
139+
case 'ownership-lost':
140+
return true;
141+
case 'foreign-writer':
142+
case 'owned-alive':
143+
case 'uncertain':
144+
return false;
145+
}
146+
}
147+
103148
export type AndroidScreenRecordingStopOutcome =
104149
| 'stopped'
105150
| 'already-missing'

packages/platform-android/src/recording/chunks.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import path from 'node:path';
22
import type { PlatformRuntimeHost } from '@agent-device/contracts/platform-runtime-operations';
3+
import { provesAndroidScreenRecordTermination } from '@agent-device/contracts/screen-recording-runtime-host';
34
import type {
45
ScreenRecordingChunk,
56
ScreenRecordingStartInput,
@@ -235,7 +236,7 @@ async function waitForStopped(
235236
for (let elapsed = 0; elapsed <= GRACEFUL_STOP_TIMEOUT_MS; elapsed += STOP_POLL_INTERVAL_MS) {
236237
const state = await transport.inspect(processIdentity);
237238
if (state === 'missing') return true;
238-
if (state === 'ownership-lost') {
239+
if (provesAndroidScreenRecordTermination(state)) {
239240
throw new Error(
240241
`Android screenrecord ownership could not be confirmed for pid ${processIdentity.pid}`,
241242
);

packages/platform-android/src/recording/launch.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import type { DeviceInfo } from '@agent-device/kernel/device';
22
import type { PlatformRuntimeHost } from '@agent-device/contracts/platform-runtime-operations';
33
import type { ScreenRecordingStartInput } from '@agent-device/contracts/screen-recording-runtime';
4+
import { provesAndroidScreenRecordPathUnclaimed } from '@agent-device/contracts/screen-recording-runtime-host';
45
import {
56
cleanupChunks,
67
AndroidScreenRecordingStartRollbackUnconfirmed,
@@ -167,7 +168,11 @@ async function retireCompletedEvidence(
167168
remotePath: chunk.remotePath,
168169
startTime: chunk.remoteStartTime,
169170
});
170-
if (state !== 'missing')
171+
if (state === 'foreign-writer')
172+
throw new Error(
173+
'Android screenrecord completed evidence names an artifact another recorder is writing; it is retained until that recorder ends',
174+
);
175+
if (!provesAndroidScreenRecordPathUnclaimed(state))
171176
throw new Error('Android screenrecord completed evidence cannot be safely retired');
172177
}
173178
await cleanupChunks(transport, evidence.chunks);

packages/platform-android/src/recording/recovery.test.ts

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,29 @@ test('reattaches an ended pid with an artifact as finishable recovery and report
7272
});
7373
});
7474

75+
test('discloses truncation when recovery proved the recorder gone before record stop', async () => {
76+
let manifest = '';
77+
const observations = ['ownership-lost', 'missing'] as const;
78+
let observation = 0;
79+
const runtime = await start({
80+
writeManifest: async ({ contents }: { contents: string }) => {
81+
manifest = contents;
82+
},
83+
readManifest: async () =>
84+
manifest ? { status: 'read' as const, contents: manifest } : { status: 'missing' as const },
85+
inspect: async () => observations[Math.min(observation++, observations.length - 1)],
86+
stop: async () => 'uncertain' as const,
87+
});
88+
const started = await runtime.screenRecordingStart(recordingInput());
89+
const reattached = await runtime.screenRecordingReattach({ envelope: started.envelope });
90+
expect(reattached.status).toBe('active');
91+
if (reattached.status === 'active')
92+
await expect(reattached.handle.finish()).resolves.toMatchObject({
93+
status: 'completed',
94+
result: { warning: expect.stringContaining('likely after reaching the 180s platform limit') },
95+
});
96+
});
97+
7598
test('returns fenced native completion after a crash between native finalization and daemon terminalization', async () => {
7699
let manifest = '';
77100
const removals: string[] = [];
@@ -135,6 +158,62 @@ test('retains completed evidence while an exact persisted recorder identity rema
135158
expect(JSON.parse(manifest)).toHaveProperty('completion');
136159
});
137160

161+
test.each([
162+
['reassigned to an unrelated process', 'ownership-lost'],
163+
['reused by a replacement recorder on the same path', 'foreign-writer'],
164+
] as const)(
165+
'terminalizes completed evidence whose recorder pid was %s, touching nothing',
166+
async (_name, ownership) => {
167+
let manifest = '';
168+
const removals: string[] = [];
169+
const runtime = await start({
170+
writeManifest: async ({ contents }: { contents: string }) => {
171+
manifest = contents;
172+
},
173+
readManifest: async () =>
174+
manifest ? { status: 'read' as const, contents: manifest } : { status: 'missing' as const },
175+
inspect: async () => ownership,
176+
remove: async (remotePath: string) => {
177+
removals.push(`artifact:${remotePath}`);
178+
return true;
179+
},
180+
removeManifest: async (manifestPath: string) => {
181+
removals.push(`marker:${manifestPath}`);
182+
return true;
183+
},
184+
});
185+
const started = await runtime.screenRecordingStart(recordingInput());
186+
const native = JSON.parse(manifest);
187+
manifest = JSON.stringify({
188+
...native,
189+
completion: {
190+
backend: 'adb screenrecord',
191+
outPath: native.outputPath,
192+
startedAt: native.startedAt,
193+
completedAt: native.startedAt + 1,
194+
scope: native.scope,
195+
showTouches: native.showTouches,
196+
recordOnlySession: native.recordOnlySession,
197+
},
198+
});
199+
200+
await expect(runtime.screenRecordingReattach({ envelope: started.envelope })).resolves.toEqual({
201+
status: 'completed',
202+
result: {
203+
backend: 'adb screenrecord',
204+
outPath: native.outputPath,
205+
startedAt: native.startedAt,
206+
completedAt: native.startedAt + 1,
207+
scope: native.scope,
208+
showTouches: native.showTouches,
209+
recordOnlySession: native.recordOnlySession,
210+
},
211+
});
212+
expect(removals).toEqual([]);
213+
expect(JSON.parse(manifest)).toHaveProperty('completion');
214+
},
215+
);
216+
138217
test('makes matching pending evidence cleanup-eligible and stops discovered exact recorder pids', async () => {
139218
let manifest = '';
140219
const removed: string[] = [];

packages/platform-android/src/recording/recovery.ts

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import type {
44
ScreenRecordingRuntimeOperations,
55
ScreenRecordingStartInput,
66
} from '@agent-device/contracts/screen-recording-runtime';
7+
import { provesAndroidScreenRecordTermination } from '@agent-device/contracts/screen-recording-runtime-host';
78
import { createScreenRecordingLiveHandle } from '@agent-device/capture-kit';
89
import {
910
androidScreenRecordingDescriptorCodec,
@@ -175,7 +176,7 @@ async function reattachEvidence(params: {
175176
evidence,
176177
manifestPath: descriptor.manifestPath,
177178
recording: current,
178-
reachedLimit: running === 'missing',
179+
reachedLimit: provesAndroidScreenRecordTermination(running),
179180
});
180181
nativeCleanupConfirmed = true;
181182
return outcome;
@@ -192,11 +193,13 @@ async function completedEvidenceIsTerminal(transport: Transport, evidence: Nativ
192193
try {
193194
for (const chunk of evidence.chunks) {
194195
if (
195-
(await transport.inspect({
196-
pid: chunk.remotePid,
197-
remotePath: chunk.remotePath,
198-
startTime: chunk.remoteStartTime,
199-
})) !== 'missing'
196+
!provesAndroidScreenRecordTermination(
197+
await transport.inspect({
198+
pid: chunk.remotePid,
199+
remotePath: chunk.remotePath,
200+
startTime: chunk.remoteStartTime,
201+
}),
202+
)
200203
)
201204
return false;
202205
}

packages/platform-android/src/recording/start-reconciliation.test.ts

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,125 @@ test('reconciles coherent completed evidence before output preparation or launch
5656
await started.pendingHandle.transfer().forceCleanup();
5757
});
5858

59+
test('retires completed evidence whose recorder pid was reassigned, signaling nothing', async () => {
60+
let marker = JSON.stringify(completedEvidence());
61+
const calls: string[] = [];
62+
const runtime = await start({
63+
readManifest: async (path: string) =>
64+
path.startsWith('/sdcard') && marker
65+
? { status: 'read' as const, contents: marker }
66+
: { status: 'missing' as const },
67+
inspect: async () => 'ownership-lost' as const,
68+
stop: async ({ pid }: { pid: string }) => {
69+
calls.push(`signal:${pid}`);
70+
return 'already-missing' as const;
71+
},
72+
remove: async (path: string) => {
73+
calls.push(`artifact:${path}`);
74+
return true;
75+
},
76+
removeManifest: async () => {
77+
calls.push('manifest');
78+
marker = '';
79+
return true;
80+
},
81+
outputs: {
82+
prepare: async () => {
83+
calls.push('prepare');
84+
},
85+
},
86+
start: async () => {
87+
calls.push('launch');
88+
return recordingProcess('77');
89+
},
90+
});
91+
const started = await runtime.screenRecordingStart(newInput());
92+
expect(calls).toEqual([
93+
'artifact:/sdcard/agent-device-recording-1.mp4',
94+
'manifest',
95+
'prepare',
96+
'launch',
97+
]);
98+
await started.pendingHandle.transfer().forceCleanup();
99+
});
100+
101+
test.each([
102+
['an unconfirmed recorder identity', 'uncertain'],
103+
['a live recorder', 'owned-alive'],
104+
])('refuses completed evidence named by %s', async (_name, ownership) => {
105+
const marker = JSON.stringify(completedEvidence());
106+
const calls: string[] = [];
107+
const runtime = await start({
108+
readManifest: async (path: string) =>
109+
path.startsWith('/sdcard')
110+
? { status: 'read' as const, contents: marker }
111+
: { status: 'missing' as const },
112+
inspect: async () => ownership,
113+
stop: async ({ pid }: { pid: string }) => {
114+
calls.push(`signal:${pid}`);
115+
return 'already-missing' as const;
116+
},
117+
remove: async () => {
118+
calls.push('artifact');
119+
return true;
120+
},
121+
removeManifest: async () => {
122+
calls.push('manifest');
123+
return true;
124+
},
125+
outputs: {
126+
prepare: async () => {
127+
calls.push('prepare');
128+
},
129+
},
130+
start: async () => {
131+
calls.push('launch');
132+
return recordingProcess('77');
133+
},
134+
});
135+
await expect(runtime.screenRecordingStart(newInput())).rejects.toThrow(
136+
'cannot be safely retired',
137+
);
138+
expect(calls).toEqual([]);
139+
});
140+
141+
test('retains completed evidence and its marker while a replacement recorder writes the same path', async () => {
142+
const marker = JSON.stringify(completedEvidence());
143+
const calls: string[] = [];
144+
const runtime = await start({
145+
readManifest: async (path: string) =>
146+
path.startsWith('/sdcard')
147+
? { status: 'read' as const, contents: marker }
148+
: { status: 'missing' as const },
149+
inspect: async () => 'foreign-writer' as const,
150+
stop: async ({ pid }: { pid: string }) => {
151+
calls.push(`signal:${pid}`);
152+
return 'already-missing' as const;
153+
},
154+
remove: async (path: string) => {
155+
calls.push(`artifact:${path}`);
156+
return true;
157+
},
158+
removeManifest: async () => {
159+
calls.push('manifest');
160+
return true;
161+
},
162+
outputs: {
163+
prepare: async () => {
164+
calls.push('prepare');
165+
},
166+
},
167+
start: async () => {
168+
calls.push('launch');
169+
return recordingProcess('77');
170+
},
171+
});
172+
await expect(runtime.screenRecordingStart(newInput())).rejects.toThrow(
173+
'another recorder is writing',
174+
);
175+
expect(calls).toEqual([]);
176+
});
177+
59178
test('retirement failure blocks launch and can succeed on a later retry', async () => {
60179
let marker = JSON.stringify(completedEvidence());
61180
let allowRemoval = false;

0 commit comments

Comments
 (0)