Summary
On a physical iOS device, waitForRunner can return Runner did not accept connection when xcodebuild exits while the final runner endpoint probe is in flight. The actionable XCTest launch failure remains in runner.log, but it is absent from the returned error.
I observed this with an untrusted Developer App certificate, then reproduced the timing deterministically against current main without a device. In that window, the generic connect error also allows the prepare health path to start another runner even though the launch blocker is deterministic.
Physical-device observation
Environment:
- agent-device
0.20.6
- macOS
26.5.2 (25F84)
- Xcode
26.5 (17F42)
- Node.js
24.16.0
- physical iPhone on iOS
18.7.9
The relevant agent-device invocation was:
agent-device prepare ios-runner \
--platform ios \
--udid <physical-device-udid> \
--timeout 240000
runner.log recorded the terminal cause:
Finished with error: The application could not be launched because the Developer App Certificate is not trusted.
...
Testing failed:
The application could not be launched because the Developer App Certificate is not trusted.
The request diagnostic instead ended with:
Runner did not accept connection
The prepare health path then started a second runner, which failed with the same certificate-trust error before the request again returned the generic connect error.
Trusting the certificate resolved the physical-device blocker. The concern here is not that agent-device should bypass that requirement; it is that the existing actionable Xcode diagnostic was replaced at the command boundary.
Deterministic reproduction on current main
I added a focused regression test to runner-transport.test.ts at current main commit 5b1b1aad2eccd299d266315b438893b5e6ae5fb0.
The test starts with a live-looking runner child, makes the final usbmux probe set child.exitCode = 65 and fail with ECONNREFUSED, and gives testPromise the captured certificate-trust diagnostic. It expects the existing early-exit error and nested Xcode output:
Regression test
test('waitForRunner preserves xcodebuild diagnostics when the runner exits during the final probe', async () => {
const session: RunnerSession = {
sessionId: 'starting-device-session',
device: xctestIosDevice,
deviceId: xctestIosDevice.id,
port: 8100,
xctestrunPath: '/tmp/runner.xctestrun',
jsonPath: '/tmp/runner.json',
testPromise: Promise.resolve({
exitCode: 65,
stdout: '',
stderr:
'The application could not be launched because the Developer App Certificate is not trusted.',
}),
child: { pid: 1234, exitCode: null } as ExecBackgroundResult['child'],
ready: false,
};
mockUsbmuxPostCommand.mockImplementation(async () => {
(session.child as { exitCode: number | null }).exitCode = 65;
throw new Error('ECONNREFUSED');
});
await assert.rejects(
() =>
waitForRunner(
xctestIosDevice,
8100,
{ command: 'uptime' },
'/tmp/runner.log',
100,
session,
),
(error: unknown) => {
const appError = error as AppError;
assert.equal(appError.message, 'Runner did not accept connection (xcodebuild exited early)');
assert.match(
String((appError.details?.xcodebuild as { stderr?: string } | undefined)?.stderr),
/Developer App Certificate is not trusted/,
);
return true;
},
);
});
Command:
pnpm exec vitest run --project unit-core \
src/platforms/apple/core/__tests__/runner-transport.test.ts
Result on unmodified current main:
AssertionError: Expected values to be strictly equal:
+ actual - expected
+ 'Runner did not accept connection'
- 'Runner did not accept connection (xcodebuild exited early)'
Test Files 1 failed (1)
Tests 1 failed | 14 passed (15)
As a mechanism check only, rechecking the runner child after retry completion made this focused suite pass 15/15. I reverted that probe and have not opened a PR; the appropriate precedence and final implementation are for maintainer triage.
Current control flow
ensureRunnerAttemptCanStart checks session.child.exitCode before each route probe. If the child exits during the final probe:
- the route records its endpoint error and returns no response;
- the retry loop exhausts;
waitForRunner leaves the loop;
- the physical-device path calls
buildRunnerConnectError without checking the child again.
That produces the deterministic test result above. The runner error table can classify an early-exit error once it receives one, but this timing window does not build that error.
Expected behavior
If the XCTest process has exited before runner connection returns, preserve the existing Runner did not accept connection (xcodebuild exited early) error and its nested xcodebuild output, reason, and hint. A known terminal launch failure should not be replaced by an endpoint error or trigger another runner-health attempt.
Related work
If this behavior and scope look right, I would be happy to prepare the focused regression and fix after maintainer triage.
Summary
On a physical iOS device,
waitForRunnercan returnRunner did not accept connectionwhenxcodebuildexits while the final runner endpoint probe is in flight. The actionable XCTest launch failure remains inrunner.log, but it is absent from the returned error.I observed this with an untrusted Developer App certificate, then reproduced the timing deterministically against current
mainwithout a device. In that window, the generic connect error also allows the prepare health path to start another runner even though the launch blocker is deterministic.Physical-device observation
Environment:
0.20.626.5.2(25F84)26.5(17F42)24.16.018.7.9The relevant agent-device invocation was:
runner.logrecorded the terminal cause:The request diagnostic instead ended with:
The prepare health path then started a second runner, which failed with the same certificate-trust error before the request again returned the generic connect error.
Trusting the certificate resolved the physical-device blocker. The concern here is not that agent-device should bypass that requirement; it is that the existing actionable Xcode diagnostic was replaced at the command boundary.
Deterministic reproduction on current main
I added a focused regression test to
runner-transport.test.tsat currentmaincommit5b1b1aad2eccd299d266315b438893b5e6ae5fb0.The test starts with a live-looking runner child, makes the final usbmux probe set
child.exitCode = 65and fail withECONNREFUSED, and givestestPromisethe captured certificate-trust diagnostic. It expects the existing early-exit error and nested Xcode output:Regression test
Command:
pnpm exec vitest run --project unit-core \ src/platforms/apple/core/__tests__/runner-transport.test.tsResult on unmodified current
main:As a mechanism check only, rechecking the runner child after retry completion made this focused suite pass 15/15. I reverted that probe and have not opened a PR; the appropriate precedence and final implementation are for maintainer triage.
Current control flow
ensureRunnerAttemptCanStartcheckssession.child.exitCodebefore each route probe. If the child exits during the final probe:waitForRunnerleaves the loop;buildRunnerConnectErrorwithout checking the child again.That produces the deterministic test result above. The runner error table can classify an early-exit error once it receives one, but this timing window does not build that error.
Expected behavior
If the XCTest process has exited before runner connection returns, preserve the existing
Runner did not accept connection (xcodebuild exited early)error and its nestedxcodebuildoutput, reason, and hint. A known terminal launch failure should not be replaced by an endpoint error or trigger another runner-health attempt.Related work
mainstill reproduces this post-probe exit window.xcodebuild exited early,Runner did not accept connection, orDeveloper App Certificate.If this behavior and scope look right, I would be happy to prepare the focused regression and fix after maintainer triage.