Skip to content

fix: bound retained iOS runner lifetime with an idle stop#1025

Merged
thymikee merged 2 commits into
mainfrom
claude/ios-runner-idle-stop
Jul 2, 2026
Merged

fix: bound retained iOS runner lifetime with an idle stop#1025
thymikee merged 2 commits into
mainfrom
claude/ios-runner-idle-stop

Conversation

@thymikee

@thymikee thymikee commented Jul 2, 2026

Copy link
Copy Markdown
Member

Summary

Follow-up to #1021 and the #1013 thread. A runner retained after session close holds the device's runner lease indefinitely, which blocks every other daemon on the machine from using the device — observed in the wild during benchmarking, when a leftover verification daemon squatted the simulator through its retained runner and every command from other daemons failed with "already owned by another agent-device daemon".

Session close now schedules an idle stop when it retains the runner: if nothing touches the runner within 5 minutes, it is stopped and the lease released. Any ensureRunnerSession call (a new session opening on the device, a command, an explicit prepare) cancels the pending stop — so the fast close→open reuse from #1021 is unaffected in the window that matters.

  • AGENT_DEVICE_IOS_RUNNER_IDLE_STOP_MS overrides the window; 0 disables idle stops entirely (the previous retain-until-daemon-exit behavior).
  • The timer is unref'd (never holds the daemon open) and cleared on explicit stop and on shutdown detach (a handed-off runner belongs to the next daemon's lifecycle).
  • Runners warmed by explicit prepare ios-runner are not timed — prepare expresses intent to keep a warm runner.

Validation

  • Unit: idle window fires → session gone; any use cancels; 0 disables (947 tests green across apple core + daemon handler suites).
  • Live on iPhone 17 Pro sim with an 8s window: runner retained at +2s after close, stopped and lease released at +12s; clean:daemon and detach paths unaffected.
  • typecheck / lint / format / build clean.

A runner retained after session close (#1021) holds the device's runner
lease indefinitely, which blocks every other daemon on the machine from
using the device - observed in the wild when a leftover verification
daemon squatted a simulator through its retained runner.

Session close now schedules an idle stop when it retains the runner:
if nothing touches the runner within 5 minutes (any ensureRunnerSession
call cancels the timer), it is stopped and the lease released.
AGENT_DEVICE_IOS_RUNNER_IDLE_STOP_MS overrides the window; 0 disables
idle stops entirely (the previous retain-until-daemon-exit behavior).
The timer is unref'd and cleared on explicit stop and shutdown detach.

Runners warmed by explicit prepare ios-runner are not timed - prepare
expresses intent to keep a warm runner, and its ensureRunnerSession call
cancels any pending stop.
@thymikee thymikee force-pushed the claude/ios-runner-idle-stop branch from 37a975e to 9fd1396 Compare July 2, 2026 12:28
@github-actions

github-actions Bot commented Jul 2, 2026

Copy link
Copy Markdown

Size Report

Metric Base Current Diff
JS raw 1.5 MB 1.5 MB +765 B
JS gzip 479.4 kB 479.6 kB +206 B
npm tarball 580.7 kB 580.9 kB +222 B
npm unpacked 2.0 MB 2.0 MB +765 B

Startup median (7 runs, lower is better):

Scenario Base Current Diff
CLI --version 28.1 ms 28.7 ms +0.6 ms
CLI --help 49.6 ms 50.3 ms +0.8 ms

Top changed chunks:

Chunk Raw diff Gzip diff
dist/src/9722.js +715 B +193 B
dist/src/session.js +50 B +13 B

@devin-ai-integration devin-ai-integration 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.

Devin Review found 1 potential issue.

Open in Devin Review

const mockPrewarmAppleRunnerCache = vi.mocked(prewarmAppleRunnerCache);
const mockPrepareIosRunner = vi.mocked(prepareIosRunner);
const mockStopIosRunner = vi.mocked(stopIosRunnerSession);
const mockScheduleIosRunnerIdleStop = vi.mocked(scheduleIosRunnerIdleStop);

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.

🟡 New test mock is never reset between tests, allowing call history to leak across test cases

The newly added mock for the idle-stop scheduler is never cleared between tests (mockScheduleIosRunnerIdleStop.mockReset() missing from beforeEach at src/daemon/handlers/__tests__/session.test.ts:162), so call counts from earlier close-on-iOS-simulator tests leak into later ones.

Impact: A test asserting that the scheduler was called could pass even if the code under test never invoked it, masking a real regression.

Every other mock in the file follows the reset pattern

All 25+ other mocks declared at src/daemon/handlers/__tests__/session.test.ts:134-160 have a corresponding mockXxx.mockReset() call inside beforeEach at lines 162-219. The new mockScheduleIosRunnerIdleStop (line 143) is the only one without a reset. The assertion at line 3530 (expect(mockScheduleIosRunnerIdleStop).toHaveBeenCalledWith('sim-1')) could see stale calls from a prior test that also closes an iOS simulator session with runner retention.

Prompt for agents
In src/daemon/handlers/__tests__/session.test.ts, the beforeEach block (starting around line 162) resets every mock declared in the file except the newly added mockScheduleIosRunnerIdleStop. Add mockScheduleIosRunnerIdleStop.mockReset() inside beforeEach, following the same pattern as the other mocks (e.g., after mockStopIosRunner.mockReset() around line 181). This prevents call-count leakage between tests.
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Fixed in ac96532 — added mockScheduleIosRunnerIdleStop.mockReset() to beforeEach, matching the file's pattern. Good catch.

Review finding on #1025: every other mock in the file resets in
beforeEach; without it the retention assertion could pass on call
history leaked from an earlier close test.
@thymikee thymikee added the ready-for-human Valid work that needs human implementation, judgment, or maintainer merge label Jul 2, 2026
@thymikee thymikee merged commit b67053e into main Jul 2, 2026
21 checks passed
@thymikee thymikee deleted the claude/ios-runner-idle-stop branch July 2, 2026 13:29
@github-actions

github-actions Bot commented Jul 2, 2026

Copy link
Copy Markdown
PR Preview Action v1.8.1
Preview removed because the pull request was closed.
2026-07-02 13:30 UTC

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.

1 participant