Skip to content

Commit 7f035f3

Browse files
authored
fix: reduce ios runner invalidation after status recovery (#666)
1 parent 7e30a83 commit 7f035f3

2 files changed

Lines changed: 364 additions & 38 deletions

File tree

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

Lines changed: 156 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,15 +7,27 @@ import type { RunnerSession } from '../runner-session-types.ts';
77
const {
88
mockEnsureRunnerSession,
99
mockExecuteRunnerCommandWithSession,
10+
mockEmitDiagnostic,
1011
mockInvalidateRunnerSession,
1112
mockStopRunnerSession,
1213
} = vi.hoisted(() => ({
1314
mockEnsureRunnerSession: vi.fn(),
1415
mockExecuteRunnerCommandWithSession: vi.fn(),
16+
mockEmitDiagnostic: vi.fn(),
1517
mockInvalidateRunnerSession: vi.fn(),
1618
mockStopRunnerSession: vi.fn(),
1719
}));
1820

21+
vi.mock('../../../utils/diagnostics.ts', async () => {
22+
const actual = await vi.importActual<typeof import('../../../utils/diagnostics.ts')>(
23+
'../../../utils/diagnostics.ts',
24+
);
25+
return {
26+
...actual,
27+
emitDiagnostic: mockEmitDiagnostic,
28+
};
29+
});
30+
1931
vi.mock('../runner-session.ts', async () => {
2032
const actual =
2133
await vi.importActual<typeof import('../runner-session.ts')>('../runner-session.ts');
@@ -151,6 +163,11 @@ test('mutating commands do not restart or replay after command send failure', as
151163
]);
152164
assert.equal(mockStopRunnerSession.mock.calls.length, 0);
153165
assert.equal(mockExecuteRunnerCommandWithSession.mock.calls.length, 2);
166+
assertDiagnosticDecision({
167+
decision: 'retained',
168+
reason: 'unknown_lifecycle_state',
169+
lifecycleState: 'notAccepted',
170+
});
154171
});
155172

156173
test('mutating commands recover cached responses before invalidating after command send failure', async () => {
@@ -168,6 +185,11 @@ test('mutating commands recover cached responses before invalidating after comma
168185

169186
assert.deepEqual(result, { message: 'tapped' });
170187
assert.equal(mockInvalidateRunnerSession.mock.calls.length, 0);
188+
assertDiagnosticDecision({
189+
decision: 'skipped',
190+
reason: 'completed_with_retained_response',
191+
lifecycleState: 'completed',
192+
});
171193
assert.equal(mockExecuteRunnerCommandWithSession.mock.calls.length, 2);
172194
const sentCommand = mockExecuteRunnerCommandWithSession.mock.calls[0]?.[2];
173195
const statusCommand = mockExecuteRunnerCommandWithSession.mock.calls[1]?.[2];
@@ -193,6 +215,65 @@ test('mutating commands keep invalidating when status cannot find the command',
193215
[session, 'transport_error_after_command_send'],
194216
]);
195217
assert.equal(mockExecuteRunnerCommandWithSession.mock.calls.length, 2);
218+
assertDiagnosticDecision({
219+
decision: 'retained',
220+
reason: 'unknown_lifecycle_state',
221+
lifecycleState: 'notAccepted',
222+
});
223+
});
224+
225+
test('mutating commands keep invalidating when status recovery probe fails', async () => {
226+
const session = makeRunnerSession({ port: 8100, ready: true });
227+
228+
mockEnsureRunnerSession.mockResolvedValueOnce(session);
229+
mockExecuteRunnerCommandWithSession
230+
.mockRejectedValueOnce(new AppError('COMMAND_FAILED', 'fetch failed'))
231+
.mockRejectedValueOnce(new AppError('COMMAND_FAILED', 'status probe failed'));
232+
233+
await assert.rejects(() =>
234+
runIosRunnerCommand(IOS_SIMULATOR, { command: 'tap', x: 120, y: 240 }),
235+
);
236+
237+
assert.deepEqual(mockInvalidateRunnerSession.mock.calls, [
238+
[session, 'transport_error_after_command_send'],
239+
]);
240+
assert.equal(mockExecuteRunnerCommandWithSession.mock.calls.length, 2);
241+
assertDiagnosticDecision({
242+
decision: 'retained',
243+
reason: 'status_probe_failed',
244+
});
245+
});
246+
247+
test('mutating commands keep invalidating when status reports an unknown lifecycle state', async () => {
248+
const session = makeRunnerSession({ port: 8100, ready: true });
249+
250+
mockEnsureRunnerSession.mockResolvedValueOnce(session);
251+
mockExecuteRunnerCommandWithSession
252+
.mockRejectedValueOnce(new AppError('COMMAND_FAILED', 'fetch failed'))
253+
.mockResolvedValueOnce({
254+
lifecycleState: 'paused',
255+
});
256+
257+
await assert.rejects(
258+
() => runIosRunnerCommand(IOS_SIMULATOR, { command: 'tap', x: 120, y: 240 }),
259+
(error: unknown) => {
260+
assert.ok(error instanceof AppError);
261+
assert.match(error.message, /lifecycle status was "paused"/);
262+
assert.equal(error.details?.recovery, 'lifecycle_state_not_recoverable');
263+
assert.match(String(error.details?.hint), /conservative invalidation path/);
264+
return true;
265+
},
266+
);
267+
268+
assert.deepEqual(mockInvalidateRunnerSession.mock.calls, [
269+
[session, 'transport_error_after_command_send'],
270+
]);
271+
assert.equal(mockExecuteRunnerCommandWithSession.mock.calls.length, 2);
272+
assertDiagnosticDecision({
273+
decision: 'retained',
274+
reason: 'unknown_lifecycle_state',
275+
lifecycleState: 'paused',
276+
});
196277
});
197278

198279
test('read-only commands retry when completed status has no retained response', async () => {
@@ -211,6 +292,11 @@ test('read-only commands retry when completed status has no retained response',
211292
assert.equal(mockExecuteRunnerCommandWithSession.mock.calls.length, 3);
212293
assert.equal(mockExecuteRunnerCommandWithSession.mock.calls[1]?.[2].command, 'status');
213294
assert.equal(mockExecuteRunnerCommandWithSession.mock.calls[2]?.[2].command, 'snapshot');
295+
assertDiagnosticDecision({
296+
decision: 'skipped',
297+
reason: 'read_only_completed_without_retained_response',
298+
lifecycleState: 'completed',
299+
});
214300
});
215301

216302
test('read-only commands retry when status shows in-flight work', async () => {
@@ -245,6 +331,7 @@ test('mutating commands report recovery guidance when completed status has no re
245331
assert.ok(error instanceof AppError);
246332
assert.match(error.message, /"tap" completed after the transport response was lost/);
247333
assert.equal(error.details?.recovery, 'completed_without_retained_response');
334+
assert.match(String(error.details?.hint), /kept the session open/);
248335
assert.match(String(error.details?.hint), /will not replay/);
249336
assert.match(String(error.details?.hint), /snapshot -i/);
250337
assert.equal(error.details?.transportError, 'fetch failed');
@@ -254,6 +341,11 @@ test('mutating commands report recovery guidance when completed status has no re
254341

255342
assert.equal(mockInvalidateRunnerSession.mock.calls.length, 0);
256343
assert.equal(mockExecuteRunnerCommandWithSession.mock.calls.length, 2);
344+
assertDiagnosticDecision({
345+
decision: 'skipped',
346+
reason: 'completed_without_retained_response',
347+
lifecycleState: 'completed',
348+
});
257349
});
258350

259351
test('mutating commands preserve runner failure details from status recovery', async () => {
@@ -284,6 +376,41 @@ test('mutating commands preserve runner failure details from status recovery', a
284376

285377
assert.equal(mockInvalidateRunnerSession.mock.calls.length, 0);
286378
assert.equal(mockExecuteRunnerCommandWithSession.mock.calls.length, 2);
379+
assertDiagnosticDecision({
380+
decision: 'skipped',
381+
reason: 'runner_reported_failure',
382+
lifecycleState: 'failed',
383+
});
384+
});
385+
386+
test('mutating commands use recovery guidance when failed status has no runner hint', async () => {
387+
const session = makeRunnerSession({ port: 8100, ready: true });
388+
389+
mockEnsureRunnerSession.mockResolvedValueOnce(session);
390+
mockExecuteRunnerCommandWithSession
391+
.mockRejectedValueOnce(new AppError('COMMAND_FAILED', 'fetch failed'))
392+
.mockResolvedValueOnce({
393+
lifecycleState: 'failed',
394+
lifecycleErrorMessage: 'Runner command failed after dispatch',
395+
});
396+
397+
await assert.rejects(
398+
() => runIosRunnerCommand(IOS_SIMULATOR, { command: 'tap', x: 120, y: 240 }),
399+
(error: unknown) => {
400+
assert.ok(error instanceof AppError);
401+
assert.equal(error.message, 'Runner command failed after dispatch');
402+
assert.match(String(error.details?.hint), /kept the session open/);
403+
assert.match(String(error.details?.hint), /did not replay/);
404+
return true;
405+
},
406+
);
407+
408+
assert.equal(mockInvalidateRunnerSession.mock.calls.length, 0);
409+
assertDiagnosticDecision({
410+
decision: 'skipped',
411+
reason: 'runner_reported_failure',
412+
lifecycleState: 'failed',
413+
});
287414
});
288415

289416
test('mutating commands report wait-and-inspect guidance when status shows in-flight work', async () => {
@@ -300,7 +427,7 @@ test('mutating commands report wait-and-inspect guidance when status shows in-fl
300427
assert.ok(error instanceof AppError);
301428
assert.match(error.message, /"tap" is still started/);
302429
assert.equal(error.details?.recovery, 'command_still_in_flight');
303-
assert.match(String(error.details?.hint), /may still finish/);
430+
assert.match(String(error.details?.hint), /kept the session open/);
304431
assert.match(String(error.details?.hint), /snapshot -i/);
305432
assert.equal(error.details?.transportError, 'fetch failed');
306433
return true;
@@ -309,6 +436,11 @@ test('mutating commands report wait-and-inspect guidance when status shows in-fl
309436

310437
assert.equal(mockInvalidateRunnerSession.mock.calls.length, 0);
311438
assert.equal(mockExecuteRunnerCommandWithSession.mock.calls.length, 2);
439+
assertDiagnosticDecision({
440+
decision: 'skipped',
441+
reason: 'command_still_in_flight',
442+
lifecycleState: 'started',
443+
});
312444
});
313445

314446
test('mutating commands invalidate the retry session without replaying again', async () => {
@@ -331,8 +463,31 @@ test('mutating commands invalidate the retry session without replaying again', a
331463
[freshSession, 'transport_error_after_retry_command_send'],
332464
]);
333465
assert.equal(mockExecuteRunnerCommandWithSession.mock.calls.length, 3);
466+
assertDiagnosticDecision({
467+
decision: 'retained',
468+
reason: 'unknown_lifecycle_state',
469+
lifecycleState: 'notAccepted',
470+
});
334471
});
335472

473+
function assertDiagnosticDecision(expected: {
474+
decision: 'skipped' | 'retained';
475+
reason: string;
476+
lifecycleState?: string;
477+
}): void {
478+
assert.ok(
479+
mockEmitDiagnostic.mock.calls.some(([event]) => {
480+
return (
481+
event.phase === 'ios_runner_command_invalidation_decision' &&
482+
event.data?.decision === expected.decision &&
483+
event.data?.reason === expected.reason &&
484+
event.data?.lifecycleState === expected.lifecycleState
485+
);
486+
}),
487+
`missing invalidation decision diagnostic ${JSON.stringify(expected)}`,
488+
);
489+
}
490+
336491
function makeRunnerSession(overrides: Partial<RunnerSession> = {}): RunnerSession {
337492
return {
338493
sessionId: `session-${overrides.port ?? 8100}`,

0 commit comments

Comments
 (0)