Skip to content

Commit ef15786

Browse files
committed
perf: adapt ios runner uptime preflight
1 parent ed5ad2b commit ef15786

4 files changed

Lines changed: 312 additions & 19 deletions

File tree

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

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,11 @@
11
import { beforeEach, test, vi } from 'vitest';
22
import assert from 'node:assert/strict';
3+
import fs from 'node:fs';
4+
import os from 'node:os';
5+
import path from 'node:path';
36
import { IOS_SIMULATOR } from '../../../__tests__/test-utils/index.ts';
47
import { AppError } from '../../../utils/errors.ts';
8+
import { flushDiagnosticsToSessionFile, withDiagnosticsScope } from '../../../utils/diagnostics.ts';
59
import type { RunnerSession } from '../runner-session-types.ts';
610

711
const {
@@ -131,6 +135,28 @@ test('mutating commands restart stale sessions when readiness preflight times ou
131135
assert.equal(mockExecuteRunnerCommandWithSession.mock.calls[1]?.[1], freshSession);
132136
});
133137

138+
test('mutating commands emit readiness recovery diagnostics after failed preflight restart succeeds', async () => {
139+
const staleSession = makeRunnerSession({ port: 8100, ready: true });
140+
const freshSession = makeRunnerSession({ port: 8101, ready: false });
141+
142+
mockEnsureRunnerSession.mockResolvedValueOnce(staleSession).mockResolvedValueOnce(freshSession);
143+
mockExecuteRunnerCommandWithSession
144+
.mockRejectedValueOnce(
145+
new AppError('COMMAND_FAILED', 'fetch failed', {
146+
runnerReadinessPreflightFailed: true,
147+
}),
148+
)
149+
.mockResolvedValueOnce({ message: 'tapped' });
150+
151+
const diagnostics = await captureDiagnostics(async () => {
152+
const result = await runIosRunnerCommand(IOS_SIMULATOR, { command: 'tap', x: 120, y: 240 });
153+
assert.deepEqual(result, { message: 'tapped' });
154+
});
155+
156+
assert.match(diagnostics, /ios_runner_readiness_preflight_recovered/);
157+
assert.match(diagnostics, /"recovery":"session_restarted"/);
158+
});
159+
134160
test('mutating commands do not restart or replay after command send failure', async () => {
135161
const session = makeRunnerSession({ port: 8100, ready: true });
136162

@@ -175,6 +201,35 @@ test('mutating commands recover cached responses before invalidating after comma
175201
assert.equal(statusCommand.statusCommandId, sentCommand.commandId);
176202
});
177203

204+
test('mutating commands run status recovery after transport failure when readiness preflight was skipped', async () => {
205+
const session = makeRunnerSession({ port: 8100, ready: true });
206+
207+
mockEnsureRunnerSession.mockResolvedValueOnce(session);
208+
mockExecuteRunnerCommandWithSession
209+
.mockRejectedValueOnce(
210+
new AppError('COMMAND_FAILED', 'fetch failed', {
211+
runnerReadinessPreflightSkipped: true,
212+
runnerReadinessPreflightSkipReason: 'recent_successful_response',
213+
}),
214+
)
215+
.mockResolvedValueOnce({
216+
lifecycleState: 'completed',
217+
lifecycleResponseJson: JSON.stringify({ ok: true, data: { message: 'tapped' } }),
218+
});
219+
220+
const diagnostics = await captureDiagnostics(async () => {
221+
const result = await runIosRunnerCommand(IOS_SIMULATOR, { command: 'tap', x: 120, y: 240 });
222+
assert.deepEqual(result, { message: 'tapped' });
223+
});
224+
225+
assert.equal(mockInvalidateRunnerSession.mock.calls.length, 0);
226+
assert.equal(mockExecuteRunnerCommandWithSession.mock.calls.length, 2);
227+
assert.equal(mockExecuteRunnerCommandWithSession.mock.calls[1]?.[2].command, 'status');
228+
assert.match(diagnostics, /ios_runner_command_status_recovery/);
229+
assert.match(diagnostics, /"readinessPreflightSkipped":true/);
230+
assert.match(diagnostics, /"readinessPreflightSkipReason":"recent_successful_response"/);
231+
});
232+
178233
test('mutating commands keep invalidating when status cannot find the command', async () => {
179234
const session = makeRunnerSession({ port: 8100, ready: true });
180235

@@ -347,3 +402,21 @@ function makeRunnerSession(overrides: Partial<RunnerSession> = {}): RunnerSessio
347402
...overrides,
348403
} as RunnerSession;
349404
}
405+
406+
async function captureDiagnostics(callback: () => Promise<void>): Promise<string> {
407+
const previousHome = process.env.HOME;
408+
process.env.HOME = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-runner-diag-'));
409+
try {
410+
return await withDiagnosticsScope(
411+
{ session: 'runner-client-test', requestId: 'request-1', command: 'tap' },
412+
async () => {
413+
await callback();
414+
const diagnosticsPath = flushDiagnosticsToSessionFile({ force: true });
415+
assert.ok(diagnosticsPath);
416+
return fs.readFileSync(diagnosticsPath, 'utf8');
417+
},
418+
);
419+
} finally {
420+
process.env.HOME = previousHome;
421+
}
422+
}

src/platforms/ios/__tests__/runner-session.test.ts

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,12 @@
11
import assert from 'node:assert/strict';
22
import { EventEmitter } from 'node:events';
3+
import fs from 'node:fs';
4+
import os from 'node:os';
5+
import path from 'node:path';
36
import { beforeEach, test, vi } from 'vitest';
47
import { IOS_SIMULATOR } from '../../../__tests__/test-utils/index.ts';
58
import { AppError } from '../../../utils/errors.ts';
9+
import { flushDiagnosticsToSessionFile, withDiagnosticsScope } from '../../../utils/diagnostics.ts';
610
import type { RunnerSession } from '../runner-session-types.ts';
711

812
const {
@@ -196,6 +200,26 @@ test('runner session probes readiness before mutating commands', async () => {
196200
});
197201
});
198202

203+
test('runner session emits explicit diagnostics when readiness preflight is used', async () => {
204+
const session = makeRunnerSession({ ready: false });
205+
mockWaitForRunner.mockResolvedValueOnce(runnerResponse({ uptimeMs: 42 }));
206+
mockSendRunnerCommandOnce.mockResolvedValueOnce(runnerResponse({ tapped: true }));
207+
208+
const diagnostics = await captureDiagnostics(async () => {
209+
await executeRunnerCommandWithSession(
210+
IOS_SIMULATOR,
211+
session,
212+
{ command: 'tap', x: 120, y: 240, appBundleId: 'com.example.demo' },
213+
'/tmp/runner.log',
214+
30_000,
215+
);
216+
});
217+
218+
assert.match(diagnostics, /ios_runner_readiness_preflight_used/);
219+
assert.match(diagnostics, /"reason":"startup"/);
220+
assert.match(diagnostics, /ios_runner_readiness_preflight/);
221+
});
222+
199223
test('runner session skips readiness preflight for tap commands after a recent successful response', async () => {
200224
const session = makeRunnerSession({ ready: true, lastSuccessfulRunnerResponseAtMs: Date.now() });
201225
mockSendRunnerCommandOnce.mockResolvedValueOnce(runnerResponse({ tapped: true }));
@@ -213,6 +237,47 @@ test('runner session skips readiness preflight for tap commands after a recent s
213237
assert.equal(mockSendRunnerCommandOnce.mock.calls.length, 1);
214238
});
215239

240+
test('runner session emits explicit diagnostics when readiness preflight is skipped', async () => {
241+
const session = makeRunnerSession({ ready: true, lastSuccessfulRunnerResponseAtMs: Date.now() });
242+
mockSendRunnerCommandOnce.mockResolvedValueOnce(runnerResponse({ tapped: true }));
243+
244+
const diagnostics = await captureDiagnostics(async () => {
245+
await executeRunnerCommandWithSession(
246+
IOS_SIMULATOR,
247+
session,
248+
{ command: 'tap', x: 120, y: 240, appBundleId: 'com.example.demo' },
249+
'/tmp/runner.log',
250+
30_000,
251+
);
252+
});
253+
254+
assert.match(diagnostics, /ios_runner_readiness_preflight_skipped/);
255+
assert.match(diagnostics, /"reason":"recent_successful_response"/);
256+
assert.doesNotMatch(diagnostics, /ios_runner_readiness_preflight_used/);
257+
});
258+
259+
test('runner session marks transport failures after skipped readiness preflight', async () => {
260+
const session = makeRunnerSession({ ready: true, lastSuccessfulRunnerResponseAtMs: Date.now() });
261+
mockSendRunnerCommandOnce.mockRejectedValueOnce(new Error('fetch failed'));
262+
263+
await assert.rejects(
264+
() =>
265+
executeRunnerCommandWithSession(
266+
IOS_SIMULATOR,
267+
session,
268+
{ command: 'tap', x: 120, y: 240, appBundleId: 'com.example.demo' },
269+
'/tmp/runner.log',
270+
30_000,
271+
),
272+
(error: unknown) => {
273+
assert.ok(error instanceof AppError);
274+
assert.equal(error.details?.runnerReadinessPreflightSkipped, true);
275+
assert.equal(error.details?.runnerReadinessPreflightSkipReason, 'recent_successful_response');
276+
return true;
277+
},
278+
);
279+
});
280+
216281
test('runner session skips readiness preflight for selector taps after a recent successful response', async () => {
217282
const session = makeRunnerSession({ ready: true, lastSuccessfulRunnerResponseAtMs: Date.now() });
218283
mockSendRunnerCommandOnce.mockResolvedValueOnce(runnerResponse({ tapped: true }));
@@ -489,6 +554,24 @@ function runnerError(error: { code: string; message: string }): Response {
489554
return new Response(JSON.stringify({ ok: false, error }));
490555
}
491556

557+
async function captureDiagnostics(callback: () => Promise<void>): Promise<string> {
558+
const previousHome = process.env.HOME;
559+
process.env.HOME = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-runner-diag-'));
560+
try {
561+
return await withDiagnosticsScope(
562+
{ session: 'runner-session-test', requestId: 'request-1', command: 'tap' },
563+
async () => {
564+
await callback();
565+
const diagnosticsPath = flushDiagnosticsToSessionFile({ force: true });
566+
assert.ok(diagnosticsPath);
567+
return fs.readFileSync(diagnosticsPath, 'utf8');
568+
},
569+
);
570+
} finally {
571+
process.env.HOME = previousHome;
572+
}
573+
}
574+
492575
function assertRunnerCommand(
493576
actual: unknown,
494577
expected: Record<string, unknown>,

src/platforms/ios/runner-client.ts

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -175,14 +175,25 @@ async function executeRunnerCommand(
175175
);
176176
session = await ensureRunnerSession(device, { ...options, cleanStaleBundles: true });
177177
try {
178-
return await executeRunnerCommandWithSession(
178+
const recovered = await executeRunnerCommandWithSession(
179179
device,
180180
session,
181181
command,
182182
options.logPath,
183183
RUNNER_STARTUP_TIMEOUT_MS,
184184
signal,
185185
);
186+
emitDiagnostic({
187+
level: 'debug',
188+
phase: 'ios_runner_readiness_preflight_recovered',
189+
data: {
190+
command: command.command,
191+
commandId: command.commandId,
192+
recovery: 'session_restarted',
193+
sessionId: session.sessionId,
194+
},
195+
});
196+
return recovered;
186197
} catch (retryErr) {
187198
const retryAppErr =
188199
retryErr instanceof AppError
@@ -249,6 +260,14 @@ async function tryRecoverRunnerCommandAfterTransportError(
249260
command: command.command,
250261
commandId: command.commandId,
251262
error: error instanceof Error ? error.message : String(error),
263+
readinessPreflightSkipped: readBooleanDetail(
264+
transportError,
265+
'runnerReadinessPreflightSkipped',
266+
),
267+
readinessPreflightSkipReason: readStringDetail(
268+
transportError,
269+
'runnerReadinessPreflightSkipReason',
270+
),
252271
},
253272
});
254273
return undefined;
@@ -262,6 +281,14 @@ async function tryRecoverRunnerCommandAfterTransportError(
262281
command: command.command,
263282
commandId: command.commandId,
264283
lifecycleState,
284+
readinessPreflightSkipped: readBooleanDetail(
285+
transportError,
286+
'runnerReadinessPreflightSkipped',
287+
),
288+
readinessPreflightSkipReason: readStringDetail(
289+
transportError,
290+
'runnerReadinessPreflightSkipReason',
291+
),
265292
},
266293
});
267294
return handleRunnerCommandStatusRecovery(
@@ -423,6 +450,16 @@ function isRunnerReadinessPreflightTimeout(error: AppError): boolean {
423450
return message.includes('timeout') || message.includes('timed out');
424451
}
425452

453+
function readBooleanDetail(error: AppError, key: string): boolean | undefined {
454+
const value = error.details?.[key];
455+
return typeof value === 'boolean' ? value : undefined;
456+
}
457+
458+
function readStringDetail(error: AppError, key: string): string | undefined {
459+
const value = error.details?.[key];
460+
return typeof value === 'string' ? value : undefined;
461+
}
462+
426463
export {
427464
resolveRunnerDestination,
428465
resolveRunnerBuildDestination,

0 commit comments

Comments
 (0)