Skip to content

Commit 16b8c7d

Browse files
committed
perf: recover ios runner responses by status
1 parent 3f65124 commit 16b8c7d

2 files changed

Lines changed: 223 additions & 7 deletions

File tree

src/platforms/ios/__tests__/runner-command-retry.test.ts

Lines changed: 67 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -135,9 +135,9 @@ test('mutating commands do not restart or replay after command send failure', as
135135
const session = makeRunnerSession({ port: 8100, ready: true });
136136

137137
mockEnsureRunnerSession.mockResolvedValueOnce(session);
138-
mockExecuteRunnerCommandWithSession.mockRejectedValueOnce(
139-
new AppError('COMMAND_FAILED', 'fetch failed'),
140-
);
138+
mockExecuteRunnerCommandWithSession
139+
.mockRejectedValueOnce(new AppError('COMMAND_FAILED', 'fetch failed'))
140+
.mockResolvedValueOnce({ lifecycleState: 'notAccepted' });
141141

142142
await assert.rejects(() =>
143143
runIosRunnerCommand(IOS_SIMULATOR, { command: 'tap', x: 120, y: 240 }),
@@ -150,7 +150,67 @@ test('mutating commands do not restart or replay after command send failure', as
150150
'transport_error_after_command_send',
151151
]);
152152
assert.equal(mockStopRunnerSession.mock.calls.length, 0);
153-
assert.equal(mockExecuteRunnerCommandWithSession.mock.calls.length, 1);
153+
assert.equal(mockExecuteRunnerCommandWithSession.mock.calls.length, 2);
154+
});
155+
156+
test('mutating commands recover cached responses before invalidating after command send failure', async () => {
157+
const session = makeRunnerSession({ port: 8100, ready: true });
158+
159+
mockEnsureRunnerSession.mockResolvedValueOnce(session);
160+
mockExecuteRunnerCommandWithSession
161+
.mockRejectedValueOnce(new AppError('COMMAND_FAILED', 'fetch failed'))
162+
.mockResolvedValueOnce({
163+
lifecycleState: 'completed',
164+
lifecycleResponseJson: JSON.stringify({ ok: true, data: { message: 'tapped' } }),
165+
});
166+
167+
const result = await runIosRunnerCommand(IOS_SIMULATOR, { command: 'tap', x: 120, y: 240 });
168+
169+
assert.deepEqual(result, { message: 'tapped' });
170+
assert.equal(mockInvalidateRunnerSession.mock.calls.length, 0);
171+
assert.equal(mockExecuteRunnerCommandWithSession.mock.calls.length, 2);
172+
const sentCommand = mockExecuteRunnerCommandWithSession.mock.calls[0]?.[2];
173+
const statusCommand = mockExecuteRunnerCommandWithSession.mock.calls[1]?.[2];
174+
assert.equal(statusCommand.command, 'status');
175+
assert.equal(statusCommand.statusCommandId, sentCommand.commandId);
176+
});
177+
178+
test('mutating commands keep invalidating when status cannot find the command', async () => {
179+
const session = makeRunnerSession({ port: 8100, ready: true });
180+
181+
mockEnsureRunnerSession.mockResolvedValueOnce(session);
182+
mockExecuteRunnerCommandWithSession
183+
.mockRejectedValueOnce(new AppError('COMMAND_FAILED', 'fetch failed'))
184+
.mockResolvedValueOnce({
185+
lifecycleState: 'notAccepted',
186+
});
187+
188+
await assert.rejects(() =>
189+
runIosRunnerCommand(IOS_SIMULATOR, { command: 'tap', x: 120, y: 240 }),
190+
);
191+
192+
assert.deepEqual(mockInvalidateRunnerSession.mock.calls, [
193+
[session, 'transport_error_after_command_send'],
194+
]);
195+
assert.equal(mockExecuteRunnerCommandWithSession.mock.calls.length, 2);
196+
});
197+
198+
test('read-only commands retry when completed status has no retained response', async () => {
199+
const session = makeRunnerSession({ port: 8100, ready: true });
200+
201+
mockEnsureRunnerSession.mockResolvedValue(session);
202+
mockExecuteRunnerCommandWithSession
203+
.mockRejectedValueOnce(new AppError('COMMAND_FAILED', 'fetch failed'))
204+
.mockResolvedValueOnce({ lifecycleState: 'completed' })
205+
.mockResolvedValueOnce({ nodes: [], truncated: false });
206+
207+
const result = await runIosRunnerCommand(IOS_SIMULATOR, { command: 'snapshot' });
208+
209+
assert.deepEqual(result, { nodes: [], truncated: false });
210+
assert.equal(mockInvalidateRunnerSession.mock.calls.length, 0);
211+
assert.equal(mockExecuteRunnerCommandWithSession.mock.calls.length, 3);
212+
assert.equal(mockExecuteRunnerCommandWithSession.mock.calls[1]?.[2].command, 'status');
213+
assert.equal(mockExecuteRunnerCommandWithSession.mock.calls[2]?.[2].command, 'snapshot');
154214
});
155215

156216
test('mutating commands invalidate the retry session without replaying again', async () => {
@@ -160,7 +220,8 @@ test('mutating commands invalidate the retry session without replaying again', a
160220
mockEnsureRunnerSession.mockResolvedValueOnce(staleSession).mockResolvedValueOnce(freshSession);
161221
mockExecuteRunnerCommandWithSession
162222
.mockRejectedValueOnce(new AppError('COMMAND_FAILED', 'Runner did not accept connection'))
163-
.mockRejectedValueOnce(new AppError('COMMAND_FAILED', 'fetch failed'));
223+
.mockRejectedValueOnce(new AppError('COMMAND_FAILED', 'fetch failed'))
224+
.mockResolvedValueOnce({ lifecycleState: 'notAccepted' });
164225

165226
await assert.rejects(() =>
166227
runIosRunnerCommand(IOS_SIMULATOR, { command: 'tap', x: 120, y: 240 }),
@@ -171,7 +232,7 @@ test('mutating commands invalidate the retry session without replaying again', a
171232
[staleSession, 'runner_connect_failed_before_command_send'],
172233
[freshSession, 'transport_error_after_retry_command_send'],
173234
]);
174-
assert.equal(mockExecuteRunnerCommandWithSession.mock.calls.length, 2);
235+
assert.equal(mockExecuteRunnerCommandWithSession.mock.calls.length, 3);
175236
});
176237

177238
function makeRunnerSession(overrides: Partial<RunnerSession> = {}): RunnerSession {

src/platforms/ios/runner-client.ts

Lines changed: 156 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { AppError } from '../../utils/errors.ts';
1+
import { AppError, toAppErrorCode } from '../../utils/errors.ts';
22
import { withRetry } from '../../utils/retry.ts';
33
import type { DeviceInfo } from '../../utils/device.ts';
44
import { emitDiagnostic } from '../../utils/diagnostics.ts';
@@ -35,6 +35,13 @@ export {
3535
type RunnerCommand,
3636
} from './runner-contract.ts';
3737

38+
type LifecycleResponsePayload = {
39+
ok?: unknown;
40+
data?: unknown;
41+
};
42+
43+
const RUNNER_STATUS_RECOVERY_TIMEOUT_MS = 3_000;
44+
3845
// --- Runner command execution ---
3946

4047
export async function runIosRunnerCommand(
@@ -146,6 +153,15 @@ async function executeRunnerCommand(
146153
? retryErr
147154
: new AppError('COMMAND_FAILED', String(retryErr));
148155
if (isRetryableRunnerError(retryAppErr)) {
156+
const recovered = await tryRecoverRunnerCommandAfterTransportError(
157+
device,
158+
session,
159+
command,
160+
retryAppErr,
161+
options,
162+
signal,
163+
);
164+
if (recovered) return recovered;
149165
await invalidateRunnerSession(session, 'transport_error_after_retry_command_send');
150166
}
151167
throw retryErr;
@@ -173,6 +189,15 @@ async function executeRunnerCommand(
173189
? retryErr
174190
: new AppError('COMMAND_FAILED', String(retryErr));
175191
if (isRetryableRunnerError(retryAppErr)) {
192+
const recovered = await tryRecoverRunnerCommandAfterTransportError(
193+
device,
194+
session,
195+
command,
196+
retryAppErr,
197+
options,
198+
signal,
199+
);
200+
if (recovered) return recovered;
176201
await invalidateRunnerSession(session, 'transport_error_after_retry_command_send');
177202
}
178203
throw retryErr;
@@ -182,12 +207,142 @@ async function executeRunnerCommand(
182207
await stopIosRunnerSession(device.id);
183208
}
184209
if (session && isRetryableRunnerError(appErr)) {
210+
const recovered = await tryRecoverRunnerCommandAfterTransportError(
211+
device,
212+
session,
213+
command,
214+
appErr,
215+
options,
216+
signal,
217+
);
218+
if (recovered) return recovered;
185219
await invalidateRunnerSession(session, 'transport_error_after_command_send');
186220
}
187221
throw err;
188222
}
189223
}
190224

225+
async function tryRecoverRunnerCommandAfterTransportError(
226+
device: DeviceInfo,
227+
session: RunnerSession,
228+
command: RunnerCommand,
229+
transportError: AppError,
230+
options: AppleRunnerCommandOptions,
231+
signal?: AbortSignal,
232+
): Promise<Record<string, unknown> | undefined> {
233+
if (command.command === 'status' || !command.commandId?.trim()) return undefined;
234+
let status: Record<string, unknown>;
235+
try {
236+
status = await executeRunnerCommandWithSession(
237+
device,
238+
session,
239+
{ command: 'status', statusCommandId: command.commandId },
240+
options.logPath,
241+
RUNNER_STATUS_RECOVERY_TIMEOUT_MS,
242+
signal,
243+
);
244+
} catch (error) {
245+
emitDiagnostic({
246+
level: 'debug',
247+
phase: 'ios_runner_command_status_recovery_failed',
248+
data: {
249+
command: command.command,
250+
commandId: command.commandId,
251+
error: error instanceof Error ? error.message : String(error),
252+
},
253+
});
254+
return undefined;
255+
}
256+
257+
const lifecycleState = typeof status.lifecycleState === 'string' ? status.lifecycleState : '';
258+
emitDiagnostic({
259+
level: 'debug',
260+
phase: 'ios_runner_command_status_recovery',
261+
data: {
262+
command: command.command,
263+
commandId: command.commandId,
264+
lifecycleState,
265+
},
266+
});
267+
268+
if (lifecycleState === 'completed') {
269+
const recovered = parseLifecycleResponseJson(status.lifecycleResponseJson);
270+
if (recovered) return recovered;
271+
if (isReadOnlyRunnerCommand(command.command)) {
272+
throw transportError;
273+
}
274+
throw new AppError(
275+
'COMMAND_FAILED',
276+
'Runner command completed, but its response was not retained for recovery.',
277+
{
278+
command: command.command,
279+
commandId: command.commandId,
280+
lifecycleState,
281+
logPath: options.logPath,
282+
transportError: transportError.message,
283+
},
284+
transportError,
285+
);
286+
}
287+
288+
if (lifecycleState === 'failed') {
289+
const errorCode =
290+
typeof status.lifecycleErrorCode === 'string' ? status.lifecycleErrorCode : undefined;
291+
const errorMessage =
292+
typeof status.lifecycleErrorMessage === 'string'
293+
? status.lifecycleErrorMessage
294+
: 'Runner command failed';
295+
const hint =
296+
typeof status.lifecycleErrorHint === 'string' ? status.lifecycleErrorHint : undefined;
297+
throw new AppError(
298+
toAppErrorCode(errorCode),
299+
errorMessage,
300+
{
301+
command: command.command,
302+
commandId: command.commandId,
303+
lifecycleState,
304+
hint,
305+
logPath: options.logPath,
306+
transportError: transportError.message,
307+
},
308+
transportError,
309+
);
310+
}
311+
312+
if (lifecycleState === 'accepted' || lifecycleState === 'started') {
313+
throw new AppError(
314+
'COMMAND_FAILED',
315+
`Runner command is ${lifecycleState}, but its response was lost.`,
316+
{
317+
command: command.command,
318+
commandId: command.commandId,
319+
lifecycleState,
320+
logPath: options.logPath,
321+
transportError: transportError.message,
322+
},
323+
transportError,
324+
);
325+
}
326+
327+
return undefined;
328+
}
329+
330+
function parseLifecycleResponseJson(value: unknown): Record<string, unknown> | undefined {
331+
if (typeof value !== 'string' || value.trim().length === 0) return undefined;
332+
let parsed: LifecycleResponsePayload;
333+
try {
334+
const raw: unknown = JSON.parse(value);
335+
parsed = raw && typeof raw === 'object' ? (raw as LifecycleResponsePayload) : {};
336+
} catch {
337+
return undefined;
338+
}
339+
if (!parsed.ok) return undefined;
340+
if (parsed.data && typeof parsed.data === 'object' && !Array.isArray(parsed.data)) {
341+
return parsed.data as Record<string, unknown>;
342+
}
343+
return {};
344+
}
345+
191346
function isRunnerReadinessPreflightError(error: AppError): boolean {
192347
return error.details?.runnerReadinessPreflightFailed === true;
193348
}

0 commit comments

Comments
 (0)