Skip to content

fix(ios): stop a warm runner from silently rebooting a shut-down simulator - #3357

Open
thymikee wants to merge 15 commits into
mainfrom
t3/3321-finish-and-pr
Open

thymikee wants to merge 15 commits into
mainfrom
t3/3321-finish-and-pr

Conversation

@thymikee

@thymikee thymikee commented Oct 9, 2026 •

Copy link
Copy Markdown
Member

Summary

After close retains the iOS runner, an external xcrun simctl shutdown made the retained xcodebuild reboot the simulator, which then stayed on for 20 s or more (#3321). Now the daemon:

  • holds one idle TCP connection to the retained runner as the push signal; nothing samples a quiet runner.
  • classifies loss from the simulator's listed state, then its boot identity against the retention window's start, and kills the runner outright so the dying xcodebuild cannot reboot the device.
  • classifies any listener that answers after refused attaches, so a runner generation Xcode restarts after a reboot is covered at its connect.
  • reports a typed reason (runner_destination_lost, runner_unreachable or runner_destination_unverified) as an open warning.

Retention enters through one retainRunnerForReuse; a failed ensureRunnerSession restores it.

This supersedes fix/ios-warm-runner-reboot-3321, published without a PR and now deleted. Its reconnect-after-refusal classification, window-start carry and close-seam stop test are covered here.

Closes #3321

Validation

Tested at dd9c0ae57: pnpm check:affected --run exits 0.

Live (iPhone 17, iOS 27.0, Xcode 27.1 beta): 7 of 7 runs with 6 to 14 s between close and simctl shutdown left the simulator Shutdown with the runner gone within 1 s, and the next open warned with the typed reason. Transcript: PR comment.

Known residual: when the shutdown follows close by 3 s or less (4 of 4 runs), the open's runner prewarm starts a new runner and boots the device. That is the prewarm loop, tracked in #3359 and not fixed here.

🤖 Generated with Claude Code

thymikee and others added 5 commits October 9, 2026 21:01
…lator

A runner retained after `close` owns its Simulator destination: when the
Simulator is shut down externally, the retained xcodebuild's destination
machinery silently reboots it and the device stays powered on until that
runner dies (#3321).

While a runner is warm-retained, hold one idle TCP connection to the
runner's listener as a push signal. When an established watch connection
closes during retention, read the device's boot identity once (bounded
recheck, never a sample): a boot newer than the retention window proves
Xcode rebooted the destination, so stop the retained runner — which
powers the rebooted device back off — and record the typed
`runner_destination_lost` reason the next `open` reports as a response
warning. A same-boot close re-arms on the restarted generation; a
never-established connection retries on a bounded backoff before being
called `runner_unreachable`. Healthy warm reuse is untouched: killing a
retained runner whose device was not rebooted leaves the device on
(negative control), and the idle-stop policy still stands behind it.

Mechanism evidence and per-claim measurements: PR body, issue #3321.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…ined runner outright (#3321)

Live runs showed three gaps: attach retries reset the window start, so a
reboot looked older than the window; a shut-down device keeps its old
launchd_sim listed for seconds, so the boot witness alone read a crash;
and a graceful stop let the dying xcodebuild reboot the device again.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
… and a verdict (#3321)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
@github-actions

github-actions Bot commented Oct 9, 2026 •

Copy link
Copy Markdown
PR Preview Action v1.8.1

QR code for preview link

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

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

@github-actions

github-actions Bot commented Oct 9, 2026 •

Copy link
Copy Markdown

Size Report

Metric Base Current Diff
Installed (including dependencies) 5.14 MB 5.14 MB +6.8 kB
Package (unpacked) 5.14 MB 5.14 MB +6.8 kB
Package (download) 1.55 MB 1.55 MB +2.1 kB

Startup median (7 runs, lower is better):

Scenario Base Current Diff
CLI --version 26.4 ms 26.2 ms -0.2 ms
CLI --help 81.2 ms 81.9 ms +0.7 ms

@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 13 files

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-destination-watch.ts Outdated
Comment thread packages/platform-apple/src/runner/__tests__/runner-destination-watch.test.ts Outdated
Comment thread packages/platform-apple/src/runner/runner-session.ts Outdated
Comment thread packages/platform-apple/src/runner/runner-destination-watch.ts Outdated
Comment thread packages/platform-apple/src/runner/runner-session.ts
Comment thread packages/platform-apple/src/runner/runner-destination-watch.ts
Comment thread src/platform-runtime-warm-runner-notice.ts
Comment thread packages/platform-apple/src/runner/__tests__/runner-destination-watch.test.ts Outdated
Comment thread website/docs/docs/sessions.md
…ment generations (#3321)

Review round on #3357. Retention (idle stop, destination watch, retention fact) now
enters through one retainRunnerForReuse and a failed ensureRunnerSession restores it,
a re-attach that connects classifies the replacement generation, an unreadable device
state counts as loss, the notice read waits for an in-flight decision on an open
window, and a stale handler no longer deletes a newer watch.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
@thymikee thymikee added the ready-for-human Valid work that needs human implementation, judgment, or maintainer merge label Oct 9, 2026
…ner and releases its lease (#3321)

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

@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 7 files (changes from recent commits).

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-session.ts Outdated
Comment thread packages/platform-apple/src/runner/runner-session.ts Outdated
…ouched it (#3321)

A failed start that released a retained runner could re-arm retention over a
runner a concurrent start was already using, because the restore keyed on session
identity. Retention state changes now advance a per-device epoch and a restore is
valid only at the epoch it released at. retainRunnerForReuse checks the session
before it touches any retention state.

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

@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 2 files (changes from recent commits).

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/__tests__/runner-close-finalization.test.ts Outdated
Comment thread packages/platform-apple/src/runner/runner-session.ts
…eparately (#3321)

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

Copy link
Copy Markdown
Member Author

I found one problem to fix in fbbf157, and the rest is minor. Checks are green: 21 checks, none failing. There are no conflicts. The new observeSimulatorState in runner-host.ts repeats getSimulatorState from simulator.ts almost line for line, and a third copy lives in simulator-state.ts. Any fix to the Simulator state probe now has to land in three places, and this copy is the one the reboot guard relies on. The rule: the Apple package has one Simulator-state probe. Could you export getSimulatorState from core/simulator.ts with a timeoutMs option, bind observeSimulatorState to it with the same dynamic import the next line already uses for simulator-boot.ts, and delete the inline copy? That is the only change needed before merge.

Not blocking, and you can take or leave these: in runner-destination-watch.ts, resolveConfirmDelayMs, resolveRecheckDelayMs and resolveAttachRetryBudgetMs have identical bodies (and copy resolveRunnerIdleStopMs) and add three undocumented AGENT_DEVICE_IOS_RUNNER_DESTINATION_* env vars that only tests set, so one shared reader or injected delays would do; the re-arm branch at line 172 keeps the old port, socket and replacement flag, which is safe today because cancel always detaches first, but it would be sturdier to detach and re-attach when the port or session differs; and the warning at session-open-execution.ts says "This open started a fresh runner", which completeOpenCommand never checks, and the notice is keyed per device and never expires, so it could show on a later open from another session.

Could this be much smaller? If any loss of the retained runner's listener during retention (an established socket that closes, or a refused first attach) simply stopped the runner, the boot-identity classification, same-boot re-arm, replacement-generation retry loop, confirm/recheck/retry timers, their env vars and the observeSimulatorBootTimeMs host port could all go, roughly 250-300 production lines. By the module's own comment, killing a runner whose device was not rebooted cannot power anything on. The only cost is that a runner that crashed during retention loses warm reuse, and the next open cold-starts. The epoch restore and the open notice would stay. The 688 net lines and +6.3 kB unpacked are above the thresholds in docs/agents/pull-requests.md. This needs an explicit decision that a retained runner that crashes and is restarted by Xcode does not need to survive retention, and #3359 (prewarm admission after a retaining close) could then share the same stop path.

On the open review threads, the cubic-dev-ai thread on runnerRetentionEpochs growth does not apply: the map grows by one integer per device, so it is bounded by device count, and reclaiming entries would reopen the restore race. You can resolve it.

On evidence, the live 5/5 simulator runs appear only in the PR body and I saw no artifacts, and no live run is stated for the runner_unreachable and replacement-generation paths. I did not run the tests. I judged the regression from the pre-change code. I also did not trace whether every open route reaches completeOpenCommand, and a failed open leaves the notice unconsumed.

… that replaces rather than amends (#3321)

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

@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.

4 issues found across 5 files (changes from recent commits).

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="packages/platform-apple/src/runner/runner-destination-watch.ts">

<violation number="1" location="packages/platform-apple/src/runner/runner-destination-watch.ts:113">
P3: These overrides can lengthen delays too: `readDurationEnvMs` accepts any finite non-negative value. Remove the shortening-only claim or cap overrides at their defaults.</violation>

<violation number="2" location="packages/platform-apple/src/runner/runner-destination-watch.ts:138">
P3: The doc says non-whole values fall back to the default, but `Math.floor` accepts fractional input and floors it. Either reject non-integers or fix the comment to say fractions are floored.</violation>
</file>

<file name="packages/platform-apple/src/core/simulator.ts">

<violation number="1" location="packages/platform-apple/src/core/simulator.ts:208">
P2: The doc comment says `null` means the listing is unreadable, but a timed-out `simctl` listing rejects instead. Document the throw here so callers do not assume `null`. Also note that the 2 s probe in runner-host.ts can fire on a healthy device, since this file's own comment cites ~0.7 s per spawn.</violation>

<violation number="2" location="packages/platform-apple/src/core/simulator.ts:209">
P3: This export creates a second `getSimulatorState` that duplicates the one in `src/simulator-state.ts`: same `simctl list devices -j` call, same null-on-failure, same parse. Consolidate on one implementation, or justify the second provider path in a comment, so the two cannot drift.</violation>
</file>

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/core/simulator.ts Outdated
Comment thread packages/platform-apple/src/runner/runner-destination-watch.ts Outdated
Comment thread packages/platform-apple/src/core/simulator.ts
Comment thread packages/platform-apple/src/runner/runner-destination-watch.ts Outdated
@thymikee

Copy link
Copy Markdown
Member Author

Thanks for the review. State at dd52602c3: pnpm check:affected --run exits 0.

Required change: done. getSimulatorState in core/simulator.ts is exported with { signal?, timeoutMs? }, and observeSimulatorState binds to it through a dynamic import (the inline copy in runner-host.ts is deleted; the binding keeps its catch so a failed probe reads as null, because the watcher must not throw). I did not collapse simulator-state.ts: its simctl arguments come from scopeSimctlArgsForDevice, which is the same simulator-set scoping buildSimctlArgsForDevice applies, but it executes through the injected AppleToolHost (readiness and shutdown runtimes), a different execution seam from the global runXcrun. snapshot-target.ts runs the same list devices -j to read the device's runtime key, not its state, so it is a different question.

Non-blocking, all taken:

  • The three env readers are one readDurationEnvMs, and the module comment documents the three AGENT_DEVICE_IOS_RUNNER_DESTINATION_* variables as test-only shorteners.
  • attachRunnerDestinationWatch now replaces an existing watch instead of amending it in place. I did not rely on "cancel always detaches first": that detach is an asynchronous import, so the invariant was not provable at the call site.
  • The warning no longer says "This open started a fresh runner"; it reads "left by a previous close was stopped at ", which stays true whenever it is read. completeOpenCommand is reached from both open paths in session-open.ts and session-open-execution.ts; I did not enumerate routes that bypass it, and a bypassing route would leave the notice for a later open.
  • The runnerRetentionEpochs thread was already resolved.

Scope question, which needs a maintainer decision: keep the classification, or simplify to "any lost listener stops the runner"?

What simplifying removes: boot-identity classification, same-boot re-arm, replacement-generation classification, confirm and recheck delays, and the observeSimulatorBootTimeMs port, roughly 250 to 300 production lines. The only behaviour lost on the happy path is that a runner that crashes during retention and that Xcode restarts loses warm reuse.

Why I recommend keeping it, from the live runs:

  1. A refused attach is not a loss signal. Right after close the listener refused my first 3 attaches for about 3 s in several healthy runs, then accepted. "Refused first attach stops the runner" would kill healthy warm runners. A retry budget has to stay.
  2. An established close alone cannot be classified without a device read. Xcode reboots a shut-down simulator within 1 to 2 s, so by the time a state read happens the device is often Booted again, and only the boot identity says it is a new boot. A state-only check would re-arm onto the rebooted device.
  3. Without the classification at connect, a shutdown during the first seconds after close lets Xcode reboot the device and restart the runner onto the same port. That connection never closes, so nothing would stop it.
  4. Each piece maps to a live failure I hit and fixed on this branch: window start reset on retry, old launchd_sim still listed after shutdown, and the graceful stop letting the dying xcodebuild reboot the device.

If you prefer the smaller version: stop on any established close, keep the retry budget, drop the boot port and re-arm, and use one runner_lost reason. It would reopen points 2 and 3. #3359 (prewarm starting a runner after a retaining close) would not get simpler, because it is an admission problem and not a stop-path problem; both would share only invalidateRunnerSession.

Evidence. The live runs are author-run, and I did not keep artifacts from the earlier ones. Here is a fresh run at dd52602c3 (iPhone 17, iOS 27.0, Xcode 27.1 beta; device state polled every 0.5 s after simctl shutdown):

head dd52602c3, iPhone 17 iOS 27.0, Xcode 27.1 beta
## cycle 1 (close -> shutdown gap 6s)
Opened: com.apple.Preferences
Page: com.apple.Preferences
Closed: live3321
simctl shutdown at 03:00:59Z
+0s (Shutdown) xcodebuild=0
## cycle 2 (close -> shutdown gap 10s)
Opened: com.apple.Preferences
Warning: The warm iOS runner left by a previous close was stopped at 2026-10-10T03:01:03.030Z: the simulator was shut down externally while it was retained, so the runner was stopped before it could boot the simulator again. reason=runner_destination_lost
Page: com.apple.Preferences
Closed: live3321
simctl shutdown at 03:02:34Z
+1s (Shutdown) xcodebuild=0
## cycle 3 (close -> shutdown gap 3s)
Opened: com.apple.Preferences
Warning: The warm iOS runner left by a previous close was stopped at 2026-10-10T03:02:37.787Z: the simulator was shut down externally while it was retained, so the runner was stopped before it could boot the simulator again. reason=runner_destination_lost
Page: com.apple.Preferences
Closed: live3321
simctl shutdown at 03:03:58Z
+1s  xcodebuild=0
+2s (Booted) xcodebuild=0
+11s (Booted) xcodebuild=1
+27s (Booted) xcodebuild=0
+28s (Booted) xcodebuild=1
## final open (reports the notice from cycle 3)
Opened: com.apple.Preferences

Cycles 1 and 2 (6 s and 10 s gaps) end Shutdown with the runner gone within 1 s, and cycle 2's open reports the notice from cycle 1. Cycle 3 (3 s gap) is the #3359 residual: the device ends Booted with a daemon-started runner. That makes the window wider than the "under 1 s" I first reported, and I updated #3359. Not covered by a live run: runner_unreachable after the final design, and the classification at connect after refusals; both are covered by unit tests (runner-destination-watch.test.ts) and by the earlier live runs that hit them before the last two fixes.

thymikee and others added 2 commits October 10, 2026 05:06
…cept, and why two probe seams exist (#3321)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…eachable, not as a shutdown (#3321)

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

@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 3 files (changes from recent commits).

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-destination-watch.ts Outdated
@thymikee

Copy link
Copy Markdown
Member Author

Follow-up to the probe thread: a state listing that times out or is unreadable (null) used to stop the runner as runner_destination_lost, which would tell the next open the simulator was shut down externally when nothing observed that. In e2fb955 it stops the runner as runner_unreachable instead, so the stop is the same and the claim is only what is known. A listed state other than Booted is still runner_destination_lost. Pinned by 'an unreadable device state stops the runner without claiming a shutdown'. The probe budget was already raised to 5 s in 97f47a2; before that it was 2 s, as it had been since fbbf157.

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

Copy link
Copy Markdown
Member Author

Commit note for whoever reads history or squashes: 97f47a283 is titled docs(ios) but also moves SIMULATOR_STATE_PROBE_TIMEOUT_MS in runner-host.ts from 2 s to 5 s, a timing change on the reboot-guard path (a stop can wait up to 3 s longer on an unanswered state probe). I am not rewriting pushed history. If this squashes, the squash title should be the fix(ios) one. Last edit on this PR is 791bdd62f; I am not pushing anything else unless a reviewer asks.

…nd cause text (#3321)

runner_unreachable meant both 'the runner stopped answering' and 'our own state
probe could not be read'. The probe verdict is now runner_destination_unverified,
and the open warning's cause text is a total map over the reasons.

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

Copy link
Copy Markdown
Member Author

In 9c71e66 runner_unreachable means only that the runner stopped answering. A state probe that times out or is unreadable now stops the runner as its own typed reason, runner_destination_unverified. The open warning's cause text is a total map over the three reasons, so it says its connection closed and the simulator's state could not be read, not that the runner stopped answering. cli-help.ts and sessions.md name all three. The Markdown-bullet typo was already fixed in 791bdd6. This is the last edit unless a reviewer asks for another.

@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).

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-destination-watch.ts Outdated
…3321)

Comment-only; no behaviour change.

Co-Authored-By: Claude Sonnet 5.5 <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

ready-for-human Valid work that needs human implementation, judgment, or maintainer merge

Projects

None yet

Development

Successfully merging this pull request may close these issues.

iOS: after close, the retained XCTest runner boots again a simulator shut down with simctl

1 participant