Skip to content

fix(apple): name an Xcode too old for its host macOS as a runner startup failure - #3739

Open
thymikee wants to merge 4 commits into
mainfrom
maint/3449-simulator-service-hub-bless
Open

thymikee wants to merge 4 commits into
mainfrom
maint/3449-simulator-service-hub-bless

Conversation

@thymikee

@thymikee thymikee commented Oct 16, 2026 •

Copy link
Copy Markdown
Member

Summary

An Xcode older than the macOS it runs on cannot load its own DTServiceHub: AMFI refuses its version-restriction entitlement. Every xcodebuild test-without-building then writes DTServiceHubClient failed to bless service hub for simulator … into runner.log and the runner never serves a command, while the caller saw only Runner did not accept connection — a failure the recovery table orders a session restart, a resend, and a clean rebuild of the very binary this macOS refuses to load.

runner.log now gets its own startup classification: details.reason: "simulator_service_hub_unavailable", terminal on every recovery axis (connectRetry, retryable, restartBeforeSend, restartAfterReadinessPreflight, artifactSuspect all false), with hint naming the fix on the daemon host. logPath and diagnosticId survive normalizeError.

Runner did not accept connection: this Xcode cannot start its simulator service on this macOS
reason: simulator_service_hub_unavailable
hint:   … (`xcode-select -s /Applications/Xcode.app`, or DEVELOPER_DIR=… for the daemon process) …

It also stops waiting. Classifying at the deadline would still have made the reporter wait out the connect retries they measured at ~90 s, so the connect loop reads the launch tail on each attempt while the session has yet to answer and ends itself: the same terminal error the deadline path produces, thrown before the next connect attempt is dialed. Bounded and cancellable — one 64 KB tail read, the same one an error would already quote, taken through the session's own reader from this generation's offset, no subprocess. The refusal is not recoverable within a launch, so the first attempt that sees it is the last attempt. Scope is a session that has never answered, so a runner that already served commands is never re-judged by its launch log, and a refusal from an older runner generation stays outside the window.

Per the maintainer's decision, the cause is classified from runner.log only — no DTServiceHub preflight or entitlement probe, which would depend on Xcode's internal layout. Recovery keys on the typed reason; the classifier row is the sole text matcher, over the launch output. Both connect-stage failures (early exit, and a child still alive at the deadline) now resolve their cause through one classifier, so message, reason, hint and logPath cannot disagree. Out of scope: agent-device-client dropping developerDir under daemonBaseUrl (intended for remote daemons).

Along the way: the documented reason vocabulary was missing simulator_set_destination_not_found at both exhaustive surfaces (this docs page and the versioned CLI help), so it is added there, and test/repo-guards/runner-startup-reason-docs.test.ts checks both against RUNNER_STARTUP_FAILURE_REASONS so the published vocabulary cannot drift again.

Fixes #3449

Validation

Head 196030ce0. pnpm check:affected --run --head 196030ce0 — all runnable checks passed, exactCommit=true (receipt .tmp/validation/run-hUNKIa/receipt.json): format, lint, typecheck, layering, fallow, build, vitest-related, docs-links and docs-prose ran green; unit and provider-integration were deduplicated into the related-test run; coverage, the native/device/replay lanes and provider integration stay with GitHub.

Regression evidence — pnpm exec vitest run --project apple-runner over the owning test files, production modules reverted to base (file copies, not stashes):

base head
4 failed | 61 passed — both bless causes unnamed, the recovery axes not terminal, no fixture records the simulator_service_hub_unavailable reason 68 passed
1 failed on the fail-fast test alone: refusals dialed for the whole 5 s budget, fetch called every attempt that test plus the other six pass, with fetch never called and the failure arriving with most of the budget unspent
1 failed on the docs guard alone: installation.md must document the simulator_set_destination_not_found reason 1 passed
— 907 passed (whole apple-runner project, head)

The fail-fast test is a bless refusal already in the launch log ends the connect loop on its first attempt: a 5 s connect budget, the signature present from the start, and assertions that no connect attempt is dialed and that the failure arrives in under half the budget. Two neighbouring tests hold the boundary the fast path needs — an older generation's refusal and a session that already answered both keep ordinary retry behaviour.

Two GitHub checks are red for a reason this PR does not touch: declarations.ts:558 calls linux.gap(...), a builder #3717 removed, so the command-coverage manifest fails to load on main itself — reproducible at 0149438fa in run 38942449476, which breaks Android Smoke Tests and Repo Guards for every PR until #3740 lands. This change touches no coverage declaration.

No live repro: this needs an Xcode older than the host macOS supports, which no device here has. The fixture is a reported-log-excerpt from the two reporters' runner.logs; its two matched phrases are quoted verbatim. No device-facing dispatch route changed, so no device run is claimed.

🤖 Generated with Claude Code

@cubic-dev-ai cubic-dev-ai Bot left a comment •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All reported issues were addressed across 8 files

Reply with feedback, questions, or to request a fix.

View guided diff | Turn on auto-fix | Re-trigger cubic

Comment thread website/docs/docs/installation.md Outdated
Comment thread packages/platform-apple/src/runner/runner-startup-transport.ts
@github-actions

github-actions Bot commented Oct 16, 2026 •

Copy link
Copy Markdown
Contributor
PR Preview Action v1.8.1

QR code for preview link

🚀 View preview at
https://callstack.github.io/agent-device/pr-preview/pr-3739/

Built to branch gh-pages at 2026-10-16 20:26 UTC.
Preview will be ready when the GitHub Pages deployment is complete.

@github-actions

github-actions Bot commented Oct 16, 2026 •

Copy link
Copy Markdown
Contributor

Size Report

Metric Base Current Diff
Installed (including dependencies) 5.32 MB 5.32 MB +2.0 kB
Package (unpacked) 5.32 MB 5.32 MB +2.0 kB
Package (download) 1.60 MB 1.60 MB +684 B

Startup median (7 runs, lower is better):

Scenario Base Current Diff
CLI --version 29.1 ms 28.6 ms -0.5 ms
CLI --help 78.2 ms 78.4 ms +0.3 ms

thymikee added a commit that referenced this pull request Oct 16, 2026
The refusal names its cause only in runner.log, and the classifier read that log
after the connect loop had spent its whole deadline, so the reporter's ~90 seconds
of futile retries survived the first fix. Read the launch tail at each attempt
while the session has yet to answer and stop there; the tail is bounded to what an
error would quote, read from this generation's offset, and costs no subprocess.

Also completes the documented reason vocabulary, which was missing
simulator_set_destination_not_found at both prose surfaces, and guards it: the
union in runner-error-classification.ts is now checked against both by
test/repo-guards/runner-startup-reason-docs.test.ts.

Refs #3449, #3739 review round 1.

Co-Authored-By: Claude <noreply@anthropic.com>
@thymikee

Copy link
Copy Markdown
Member Author

The code needs one change before merge. I reviewed 196030c.

The connect loop decides on its own that a launch is terminal, using isSimulatorServiceHubLaunchOutput (https://github.com/callstack/agent-device/blob/196030c/packages/platform-apple/src/runner/runner-error-classification.ts#L228). That function copies the line matching from matchesRunnerToolTextLine (https://github.com/callstack/agent-device/blob/196030c/packages/platform-apple/src/runner/runner-error-classification.ts#L627) and ignores row order. In RUNNER_ERROR_RULES, simulator_set_destination_not_found (L449) comes before the hub row (L466). On a scoped-set simulator whose log tail holds both signatures, the pre-check fires, but buildRunnerConnectError classifies the error as simulator_set_destination_not_found. That cause has connectRetry:true, so each remaining attempt re-throws without dialing until the deadline. This breaks the comment's own rule that the loop check must stay no broader than the row. It also makes the "sole text matcher" claim in the PR body untrue. The rule should be: the launch-log verdict is whatever classifyRunnerStartupFailure returns over the tail, and nothing else. Please delete isSimulatorServiceHubLaunchOutput. In ensureRunnerAttemptCanStart, call resolveRunnerLaunchLogCause (it spawns no process, and the Xcode version is memoized). Throw only when the resulting cause is terminal on connectRetry. That one site then covers every row that names a launch-log cause.

Each connect attempt of every starting iOS runner session now reads a 64 KB tail of runner.log (https://github.com/callstack/agent-device/blob/196030c/packages/platform-apple/src/runner/runner-startup-transport.ts#L251). The only CI lane that drives this route on a real simulator is "fixture-backed iOS simulator E2E smoke" (job 117115377520). It failed on linux.gap before it reached the runner, and the PR body claims no device run. Nothing yet shows that a healthy cold boot still connects and serves its first command through the changed loop. After #3740 lands, please rerun that smoke job and show it green. Or attach a local run: a cold open, then snapshot -i and press on a simulator, with runner.log showing the session moved from starting to ready on that launch. You do not need to reproduce the old-Xcode failure, since no such host exists.

Could this get simpler? Drop isSimulatorServiceHubLaunchOutput and simulatorServiceHubFailureMessage. Give the buildFailure row a declared message suffix, and let resolveRunnerLaunchLogCause return the cause of any row that carries one, with no reason-by-reason if chain. ensureRunnerAttemptCanStart would then call it on the tail and throw when shouldRetryRunnerConnectError is false for the built error. The transport would know no hub specifics, and the set-destination cause would get the same fast path. Is there a reason this would not work? I see nothing that must change first.

The three automated review threads are all fixed at this head, so they can be resolved: the docs lists (#3739 (comment)), the per-attempt tail check (#3739 (comment), which the first finding above supersedes), and the narrowed provenance header (#3739 (comment)).

The six failing CI jobs all fail on "linux.gap is not a function" or TS2339 at test/integration/command-coverage/declarations.ts:558. This diff does not touch that file, main fails the same way, and #3740 fixes it. So they are likely unrelated. The iOS simulator smoke job exercises the changed route but failed before reaching it, so CI gives no evidence either way on this change.

I did not run the apple-runner vitest project, so the base/head regression table in the PR body is unverified. I also had no host with an Xcode older than its macOS, so the bless fixture rests on the reporter excerpts quoted in #3449. I did not trace the daemon restart policy beyond the shouldRestartRunnerBeforeCommandSend axis.

Before merge, route the connect-loop fast path through classifyRunnerStartupFailure, then show a green iOS simulator smoke after #3740 lands.

thymikee and others added 3 commits October 16, 2026 21:09
…tup failure

An Xcode older than the macOS it sits on cannot load its own DTServiceHub:
AMFI refuses its version-restriction entitlement, so every
`xcodebuild test-without-building` writes "DTServiceHubClient failed to bless
service hub" into runner.log and the runner never serves a command. Callers saw
only `Runner did not accept connection`, ordered by a classifier that had no
name for this cause, so the request restarted the session and rebuilt a binary
this macOS refuses to load (#3449).

Classify the bless refusal from the launch output into the typed reason
`simulator_service_hub_unavailable`, terminal on every recovery axis, with a
hint to select a supporting Xcode on the daemon host (`xcode-select -s` or
DEVELOPER_DIR). Both connect-stage failures now read the launch log through one
classifier, so the message, reason, hint and logPath cannot disagree.
The refusal names its cause only in runner.log, and the classifier read that log
after the connect loop had spent its whole deadline, so the reporter's ~90 seconds
of futile retries survived the first fix. Read the launch tail at each attempt
while the session has yet to answer and stop there; the tail is bounded to what an
error would quote, read from this generation's offset, and costs no subprocess.

Also completes the documented reason vocabulary, which was missing
simulator_set_destination_not_found at both prose surfaces, and guards it: the
union in runner-error-classification.ts is now checked against both by
test/repo-guards/runner-startup-reason-docs.test.ts.

Refs #3449, #3739 review round 1.

Co-Authored-By: Claude <noreply@anthropic.com>
The connect loop decided a boot was terminal with its own bless-signature check, a copy of the
row's line matching that ignored row order. On a scoped-set launch whose tail held both signatures
the loop stopped while the built error carried `simulator_set_destination_not_found`, whose verdict
was retryable, so each remaining attempt re-threw without dialing until the deadline.

Now the verdict is whatever `classifyRunnerStartupFailure` returns over the tail: a row declares the
cause it names in the message, the classifier returns it, and the loop throws the built error only
when `shouldRetryRunnerConnectError` says a retry cannot help. The scoped-set destination cause gets
the same fast path, the transport knows no hub specifics, and the two message helpers and the
signature predicate are gone.

Refs #3449, #3739 review round 2.

Co-Authored-By: Claude <noreply@anthropic.com>
@thymikee
thymikee force-pushed the maint/3449-simulator-service-hub-bless branch from 196030c to bd0cae4 Compare October 16, 2026 20:03

@cubic-dev-ai cubic-dev-ai Bot left a comment •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All reported issues were addressed across 6 files (changes from recent commits).

You’re at about 91% of the monthly reviewed-line limit. You may want to disable incremental reviews to conserve quota. Reviews will continue until that limit is exceeded. If you need help avoiding interruptions, please contact contact@cubic.dev.

Reply with feedback, questions, or to request a fix.

View guided diff | Turn on auto-fix | Re-trigger cubic

Comment thread packages/platform-apple/src/runner/runner-device-set.ts Outdated
…ce-set

Refs #3739 review round 2 (Cubic r4298841230).

Co-Authored-By: Claude <noreply@anthropic.com>

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

iOS: an Xcode too old for the host macOS surfaces as 'Runner did not accept connection'

1 participant