Skip to content

Give each concurrent test action its own simulator via a per-(device, OS) pool - #3026

Open
erneestoc wants to merge 8 commits into
bazelbuild:mainfrom
erneestoc:simulator-pool
Open

erneestoc wants to merge 8 commits into
bazelbuild:mainfrom
erneestoc:simulator-pool

Conversation

@erneestoc

@erneestoc erneestoc commented Jul 22, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Split out from #3021 per review feedback, redesigned as a simulator pool after further discussion: instead of serializing concurrent tests on one shared simulator, each concurrent test action gets its own.

Problem

A simulator can only service one test session at a time. Two failure modes follow whenever Bazel actually executes several simulator tests concurrently (CI runs --local_test_jobs=2):

  1. Session collisions: concurrent xcodebuild test-without-building / xctest sessions against the same reused simulator disrupt each other — 300s hangs, or Failed to initialize for UI testing: … kAXErrorCannotComplete.
  2. Creation races: concurrent tests racing through the create action each fail to find an existing simulator and create same-named duplicates. CI logs from Add screen_capture_format attribute to ios_xctestrun_runner #3021 show two BAZEL_TEST_iPhone 16_26.0 devices created 3 seconds apart, with every simulator test on that machine hanging from then on.

These are the dominant source of iOS test flakiness on this repo's CI. They stay invisible on main because the affected tests are almost always remote-cache hits; any PR that touches the runner invalidates those caches and forces real concurrent executions (#3021 hit this for 6 consecutive builds across 8 different agents until the interim locks landed on its branch).

Design

  • Pool slot claim (runner template): before invoking the simulator creator, the runner claims an exclusive slot in a machine-wide pool by atomically creating a shlock(1) pid lockfile in $TMPDIR. Slots are not keyed on device type or OS version, so a single invocation running tests across several simulator types still hands every concurrent test its own device.
  • Simulator naming: slot 0 keeps the historical simulator name, so existing simulators keep being reused; higher slots are only claimed while tests actually run concurrently and get their own suffixed simulator (…_1, …_2), which stays booted between test actions for warm reuse like slot 0.
  • Where the locks live: ${SIMULATOR_POOL_LOCK_DIR:-${TMPDIR:-/tmp}}. Bazel points TMPDIR at the per-user temp dir (/var/folders/…/T/), which is shared across sandboxed test actions and writable there — unlike TEST_TMPDIR, which is per-action. Verified under darwin-sandbox with two concurrent test actions.
  • Remote execution escape hatch: TMPDIR is not part of the action's declared environment (bazel aquery shows only PATH), so under remote execution the worker decides what it is, and a worker handing each action its own TMPDIR would silently put every test back on slot 0. --test_env=SIMULATOR_POOL_LOCK_DIR=… points the pool at a directory the execution environment actually shares. Set-but-unusable warns and falls back rather than failing the test.
  • Robustness: a claim is valid only while the claiming test process is alive. A test killed for any reason — including SIGKILL from a Bazel test timeout — never leaves a slot permanently claimed: the next prober validates the recorded pid and shlock atomically reclaims dead slots. (First attempt used a kernel flock held on an fd via lockf(1); abandoned because macOS lockf's fd mode misbehaves under try-lock probing — after one failed probe, every subsequent probe in that process fails even on free lock files.)
  • Session-wedge recovery (runner): a CI attempt log showed the dominant hang: the runner finds its pooled simulator booted, the readiness probe passes, and xcodebuild test-without-building sits silent until the Bazel timeout — the device is carrying a dead test session from a previously terminated test, and every new session against it hangs. Two complementary defenses: (1) the runner traps TERM/INT after claiming its simulator and shuts the device down (Bazel delivers SIGTERM with a grace period before SIGKILL), so the next attempt cold-boots clean; (2) for deaths the trap cannot catch — SIGKILL, grace overruns — the runner keeps a session marker holding its pid (same pid-liveness pattern as the pool locks), removed on any controlled exit, and the creator reboots any booted device whose marker names a dead pid before reusing it. Normal completions leave the device booted for warm reuse, in every slot. This also self-heals agents whose devices are already wedged: the first attempt still times out, but the device is recycled and the retry passes.
  • Bounded boot (creator): simctl bootstatus -b has no deadline of its own, so a device wedged mid-boot consumed the whole test timeout and was SIGKILLed — leaving it wedged for the next attempt. The boot is now bounded against the test's own TEST_TIMEOUT (minus a 30s cleanup reserve, 30s floor — the bound exists to fail cleanly before SIGKILL, not to ration boot time, so slow first-boot data migrations still get nearly the whole budget), and a timed-out boot is deliberately left in progress: the boot is owned by CoreSimulatorService and continues in the background, so a flaky-test retry re-enters bootstatus and resumes where the previous attempt left off — a slow first-boot data migration completes across attempts instead of restarting from zero each time.
  • Reused-simulator health check (creator): a simulator can report state booted while being unusable — if the test that started its initial boot was killed partway through, the device stays booted but never becomes able to run tests, and every later test reusing it hangs. Reused booted devices are now probed for readiness (SpringBoard in simctl spawn <udid> launchctl list, bounded at 30s) and rebooted if not responding.
  • Unaffected: ios_test_runner (doesn't set SIMULATOR_POOL_SLOT → slot 0 → today's behavior), reuse_simulator = False, device runs, and machines without shlock(1) (degrades to today's single-simulator behavior).

Review feedback addressed

  • Pool is no longer device/OS-version specific — it's machine-wide.
  • Reads $TMPDIR instead of shelling out to getconf DARWIN_USER_TEMP_DIR.
  • The machine-wide boot gate is removed entirely rather than having its timeout shortened: the creation race it partly covered is already fixed by per-test slots, and --local_test_jobs already bounds overlapping boots.
  • SIMULATOR_POOL_SLOT now parses as a hard error instead of silently falling back to slot 0.
  • Added SIMULATOR_POOL_LOCK_DIR so the lock directory is configurable for execution environments that do not share $TMPDIR.

Test plan

  • Concurrent-claim simulation: concurrent runners get distinct slots; a freed slot is reclaimed by the next test; a SIGKILLed holder's slot is atomically reclaimed.
  • Locking verified inside Bazel's darwin-sandbox (shared $TMPDIR is writable and lockable there, and identical across concurrent test actions, under both darwin-sandbox and --spawn_strategy=local).
  • SIMULATOR_POOL_LOCK_DIR: three concurrent claimers with differing $TMPDIRs still get distinct slots when pointed at a shared dir; unwritable dir, missing dir and unset $TMPDIR all fall back to slot 0 immediately instead of hanging; propagation through --test_env confirmed.
  • //test:ios_xctestrun_runner_ui_test behaves identically to main; single-test runs claim slot 0 and reuse the historically-named simulator.

Comment thread apple/testing/default_runner/ios_xctestrun_runner.template.sh Outdated
Comment thread apple/testing/default_runner/ios_xctestrun_runner.template.sh Outdated
Comment thread apple/testing/default_runner/simulator_creator.py Outdated
Comment thread apple/testing/default_runner/simulator_creator.py Outdated
Comment thread apple/testing/default_runner/simulator_creator.py Outdated
Comment thread apple/testing/default_runner/simulator_creator.py Outdated
@erneestoc
erneestoc force-pushed the simulator-pool branch 4 times, most recently from ba0f8b2 to d9e7c81 Compare August 19, 2026 18:59
@erneestoc
erneestoc force-pushed the simulator-pool branch 2 times, most recently from e12cde0 to 7ab1a25 Compare August 20, 2026 22:44
@erneestoc
erneestoc requested a review from keith August 20, 2026 23:22
@erneestoc
erneestoc force-pushed the simulator-pool branch 2 times, most recently from 30cf23b to c722998 Compare August 21, 2026 00:06
Comment thread apple/testing/default_runner/__pycache__/simulator_creator.cpython-314.pyc Outdated
Comment thread apple/testing/default_runner/ios_xctestrun_runner.template.sh
Comment thread apple/testing/default_runner/simulator_creator.py
A simulator can only service one test session at a time. When Bazel runs
multiple simulator tests concurrently (--local_test_jobs is 2 on CI), the
sessions against the shared reused simulator disrupt each other,
manifesting as 300s hangs or "Failed to initialize for UI testing:
kAXErrorCannotComplete" errors. Separately, concurrent tests racing
through the create action each fail to find an existing simulator, create
same-named duplicates, and cold-boot them all at once (CI logs show two
'BAZEL_TEST_iPhone 16_26.0' devices created three seconds apart, with
every test on the machine hanging from that point on). These have been
the dominant sources of iOS test flakiness on CI: they surface whenever a
change invalidates the cached results of the runner's tests and several
of them actually execute at once.

Instead of serializing tests on one simulator, give each concurrent test
action its own:

- Before invoking the simulator creator, the runner claims an exclusive
  slot in a machine-wide pool using an atomic shlock(1) pid lockfile in
  $TMPDIR, which Bazel points at the per-user temp dir shared across
  sandboxed test actions (unlike the per-action $TEST_TMPDIR). Slots are
  not keyed on device type or OS version, so a single invocation running
  tests across several simulator types still hands every concurrent test
  its own device.

- Slot 0 keeps the historical simulator name so existing simulators are
  still reused; higher slots, which only exist while tests actually run
  concurrently, get their own suffixed simulator.

- A claim is valid only while the test process is alive, so a test killed
  for any reason (including SIGKILL from a timeout) never leaves a slot
  permanently claimed - the next prober validates the recorded pid and
  atomically reclaims dead slots. (A kernel flock held on an fd was tried
  first, but macOS lockf(1)'s fd mode misbehaves under try-lock probing:
  after one failed probe, every subsequent probe in that process fails
  even on free lock files.)

Runners that do not set SIMULATOR_POOL_SLOT (ios_test_runner) keep
today's behavior, as do reuse_simulator = False and device runs, and the
pool degrades to today's single-simulator behavior if shlock(1) is
unavailable.

Verified: concurrent claim simulation (distinct slots for concurrent
runners, freed and SIGKILLed slots reclaimed), shlock behavior under
Bazel's darwin-sandbox, and //test:ios_xctestrun_runner_ui_test passing
end-to-end with slot 0 reusing the historically-named simulator.
The first CI run of the pool exposed a boot-path gap (last-green job,
build 11829): a cold boot hung past the creating test's timeout, the
SIGKILL left the simulator reporting state "booted" while actually
half-initialized, and the creator trusted that state - so every later
test reusing the device hung for its full timeout (three targets, three
attempts each).

Probe reused "booted" simulators for readiness (SpringBoard present in
`simctl spawn <udid> launchctl list`, bounded at 30s); if the device is
not responding, shut it down and reboot it. State "booted" alone is not a
readiness signal when a previous boot was killed midway.

Verified: probe returns healthy for a live booted simulator and unhealthy
for a shutdown one; //test:ios_xctestrun_runner_ui_test passes end-to-end
with the probe active on the warm path.
`simctl bootstatus -b` blocks until the device finishes booting and has no
deadline of its own, and `_simctl` passed no timeout. A device wedged
mid-boot therefore consumed the entire test timeout and was SIGKILLed with
no indication of where the time went. That silence is what made the CI
timeouts undiagnosable.

The readiness probe added in the previous commit does not cover this: it
only runs for devices already in state "booted", while a device still
part-way through a boot reports "booting" and goes straight into the
unbounded `bootstatus` call.

Bound that wait against the test's own deadline, which Bazel exports as
TEST_TIMEOUT. The bound exists to fail with a clear error before the
SIGKILL, not to ration boot time - legitimate boots can be slow (the
first boot of a runtime migrates data and can take minutes on a loaded
machine) - so it waits nearly the whole budget, reserving 30s to report.

On timeout the device is deliberately left booting: the boot is owned by
CoreSimulatorService and keeps making progress after this process stops
waiting, so a flaky-test retry (or the next test to claim the simulator)
re-enters bootstatus and resumes where this attempt left off - a slow
first-boot migration completes across attempts instead of restarting
from zero on each one. A boot that instead dies leaving the device
falsely "booted" is caught by the readiness probe on reuse.

Verified: with a stubbed `simctl` whose bootstatus never returns, the
boot aborts at the bound (30s floor for short timeouts, 270s for the
300s default, 870s for 900s) with a clear error naming the simulator,
and no shutdown is issued; real simulator tests pass on the warm and
cold paths.
A CI attempt log finally showed where the 300s timeouts go: the runner
finds its pooled simulator already booted, the readiness probe passes
(SpringBoard is up), `xcodebuild test-without-building` launches against
it - and then sits silent until the Bazel timeout SIGTERMs it. The device
is carrying a dead test session: a previous test terminated mid-run kills
the xcodebuild client, but the device-side session lingers, and every
later session against that device hangs indefinitely. The wedge therefore
repeats for each retry (TIMEOUT in 3 out of 3) and spreads to every test
reusing the device, including later builds on machines that keep
simulators booted between builds.

Trap TERM/INT after the simulator is claimed and shut the device down:
Bazel delivers SIGTERM with a grace period before SIGKILL (the attempt
log shows the xcodebuild child dying of exactly that signal, after which
bash regains control), which is enough to stop the device so the next
attempt starts from a clean cold boot - bounded by the previous commit -
instead of hanging identically. Tests that end normally still leave the
device booted for warm reuse.

Verified: a passing run leaves the device booted; delivering
process-group SIGTERM mid-pipeline (the template's xcodebuild | tee
shape) runs the trap and the device transitions to Shutdown; the next
test finds it shutdown, boots it cleanly, and passes.
The TERM trap added in the previous commit stops session wedges only
when the runner gets a catchable signal and enough grace to act. A test
killed outright - SIGKILL, a Bazel server death, a shutdown that
outruns the grace period - still leaves the device booted with a dead
test session, and nothing at reuse time could tell: the readiness probe
passes because SpringBoard is genuinely up.

Close the gap with the same pid-liveness pattern the pool slots use:
the runner writes a session marker holding its pid next to the pool
locks while it uses a simulator and removes it on any controlled exit.
When the creator finds a booted device whose marker names a dead pid,
some test died mid-session without cleanup - however it died - so the
device is shut down and rebooted before reuse. A marker with a live pid
is left alone, and an unwritable lock dir degrades to trap-only
behavior.

Verified: marker with a dead pid triggers the reboot path end-to-end
(planted marker + booted device -> "may be left in a bad state ...
rebooting" -> test passes) and is removed after handling; live-pid,
missing, and garbage markers change nothing; a passing run removes its
own marker on exit.
Fixes from an adversarial review of the branch:

- The TERM/INT trap now exits (143) instead of returning: previously a
  Bazel timeout's SIGTERM was effectively swallowed - the runner kept
  executing its whole post-test tail against the simulator it had just
  shut down and deleted TEST_PREMATURE_EXIT_FILE as if the run ended
  cleanly - and Ctrl-C did not terminate the runner at all.
- The trap and the session marker are armed only when this test actually
  claimed an exclusive pool slot. In every degraded mode (no shlock,
  unusable lock dir, exhausted probe, per-action TMPDIRs) concurrent
  tests share the slot-0 simulator, and a shutdown on one test's timeout
  would sabotage the tests still using the device; unclaimed now means
  exactly the pre-existing shared-simulator behavior.
- The trap removes the session marker only when its shutdown actually
  succeeded - a failed shutdown leaves the device suspect, and the
  marker is precisely the evidence the next test needs to recycle it.
- Recycling a suspect device now verifies the shutdown took effect
  (polls for the Shutdown state, bounded) before rebooting: previously a
  failed or ignored shutdown fell through to `bootstatus -b` on a
  still-booted device, which reports success (directly or via the
  exit-149 handler) and handed the same wedged simulator back.
- A session marker naming a live pid now disables all destructive
  recovery for that device, including the health probe: another live
  test owns it (degraded pool or mixed runners), and a transiently
  failing probe must not shut a device down under the test using it.
- The boot bound is computed from the remaining test budget (start
  measured at creator entry) with a 10s reporting reserve instead of a
  30s one: pre-boot work no longer pushes the deadline past Bazel's
  SIGKILL, and short-timeout tests get 50s of boot headroom instead of
  30s, so cold boots that fit the budget before the bound still fit.
- Suffixed pool simulators (slot >= 1) are shut down on any controlled
  exit, while still holding the slot lock so no other test can be
  booting them: they only exist during concurrency bursts, and left
  booted they accumulated forever since serial runs only touch slot 0.
- SIMULATOR_POOL_SLOT set-but-empty parses as slot 0 instead of dying
  on a bare int('') traceback; garbage values still hard-error.

Known limitation (documented, unchanged): the legacy ios_test_runner
does not participate in the pool, so a mixed-runner invocation with
REUSE_GLOBAL_SIMULATOR can still share the slot-0 device with an
xctestrun test, as both runners already did before this change.

Verified: signal harness exits 143 with the marker removed on successful
shutdown and preserved on failed shutdown; a planted dead marker recycles
through the verified-shutdown path and passes; a planted live marker
prints the hands-off note and the test passes without recovery; a 3-way
concurrent burst leaves slot 0 booted for warm reuse, every suffixed
slot shut down, and zero leaked markers; elapsed-aware bounds measured at
290s/170s/50s/30s-floor for the corresponding budgets.
Review feedback: the new environment variable is part of the documented
contract for custom create_simulator_action binaries, so describe it
alongside the other SIMULATOR_* variables and regenerate doc/rules-ios.
@erneestoc
erneestoc requested a review from aaronsky August 21, 2026 20:23
erneestoc added a commit to erneestoc/rules_idb that referenced this pull request Aug 24, 2026
…EST_TIMEOUT

CoreSimulator calls (bootstatus, shutdown, delete, spawn probes) can hang
indefinitely against a wedged device or a stuck CoreSimulatorService; an
unbounded call eats the test's whole budget and dies as a silent SIGKILL.
A new run_bounded helper (bg + poll + SIGKILL, exit 124 like timeout(1),
lock fds closed for the child) now wraps every lifecycle wait: the
SpringBoard probe (10s per attempt), the re-boot and infra-retry paths,
every teardown shutdown (60s), and the pool helper's python
shutdown/delete (60s).

The boot deadline is now derived from the test's own timeout (Bazel
exports TEST_TIMEOUT): min(240, TEST_TIMEOUT - 30, floor 30), overridable
via RULES_IDB_BOOT_TIMEOUT as before. A wedged boot fails attributably
here instead of as a silent SIGKILL, while slow legitimate boots keep
nearly the whole budget of longer tests. On overrun the boot is
deliberately left running: CoreSimulatorService owns it and keeps going,
so a flaky-test retry resumes the wait instead of restarting a slow
first-boot data migration from zero.

Ported from the rules_apple simulator-pool work (bazelbuild/rules_apple#3026).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
erneestoc added a commit to erneestoc/rules_idb that referenced this pull request Aug 24, 2026
A test killed without cleanup -- SIGKILL after Bazel's timeout grace, a
crashed shell -- releases its pool slot instantly (kernel flock), but can
leave its simulator carrying a dead test session: the device still
reports Booted, yet every future session against it hangs. Two
complementary defenses, ported from bazelbuild/rules_apple#3026:

- cleanup() now shuts the simulator down (bounded 10s, inside Bazel's
  SIGTERM->SIGKILL grace) when exiting on TERM/INT, so the common
  timeout path recycles the device immediately. Normal exits keep it
  warm.

- A session marker (slot-N.session, holding pid + udid) is written once
  the simulator is resolved and removed on any controlled exit. Finding
  one at claim time proves the previous holder died without cleanup --
  we hold its released flock, so it is gone; a clean exit would have
  removed the marker -- and covers the SIGKILL case the trap cannot.
  The next claimant shuts the named simulator down and boots it fresh.
  Simpler than the rules_apple variant: flock possession replaces the
  pid-liveness check, so there is no pid-reuse edge at all.

Verified: clean runs leave no marker; a planted stale marker triggers
"died mid-session ... recycling" and the test passes on the rebooted
device; a real bazel --test_timeout TERM mid-run prints "terminated
mid-run", leaves the device Shutdown, and removes the marker.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
erneestoc added a commit to erneestoc/rules_idb that referenced this pull request Aug 24, 2026
The warm path trusted state "Booted" unconditionally, but Booted is not
a readiness signal: a device whose boot or session died midway can stay
Booted while SpringBoard never answers, hanging the run. Reuse now
recycles the simulator when the previous holder died mid-session (the
session marker), and otherwise probes SpringBoard the same way the
post-boot path always has -- one bounded probe (~instant on a healthy
device) before handing the simulator to the companion; an unresponsive
device is shut down (bounded) and routed through the normal boot block,
reusing its gate, readiness wait, and settle.

Ported from the reused-simulator health check in
bazelbuild/rules_apple#3026.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
erneestoc added a commit to erneestoc/rules_idb that referenced this pull request Aug 24, 2026
The three recycle/re-boot paths ran `simctl shutdown ... || true` and then
set `simulator_state="Shutdown"` (or immediately re-ran `bootstatus -b`)
unconditionally. A shutdown that fails or times out against a wedged
CoreSimulatorService leaves the device Booted; the code then trusts
"Shutdown", `bootstatus -b` returns immediately for the already-booted
device, `wait_for_springboard` passes (SpringBoard is up even for a dead
session), and the test runs against the wedged simulator -- hanging until
the Bazel timeout, the exact failure recycling was meant to prevent.

Add `shutdown_and_verify`, which shuts the device down and polls until it
actually reports Shutdown (state is authoritative; `simctl shutdown` also
returns non-zero for an already-Shutdown device, so its exit code cannot
be trusted). The stale-session and not-responding recycle paths now fail
fast with a clear "CoreSimulatorService may need attention" message
instead of silently proceeding against a maybe-wedged device; the
post-boot re-boot fallback verifies the shutdown but stays best-effort.

Mirrors the _shutdown_and_wait hardening in bazelbuild/rules_apple#3026.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
erneestoc added a commit to erneestoc/rules_idb that referenced this pull request Aug 24, 2026
On an abnormal exit (Bazel timeout -> 143, Ctrl-C -> 130) cleanup() shut
the slot's simulator down and then removed the session marker
unconditionally. If that shutdown timed out against a wedged
CoreSimulatorService the device stayed Booted with a dead session, yet
the marker -- the evidence the next claimant needs to recycle it -- was
destroyed anyway, so the wedge was handed on silently.

Use shutdown_and_verify (bounded tight to stay within the SIGTERM->SIGKILL
grace) and remove the marker only when the device is confirmed Shutdown.
If it cannot be confirmed the marker is kept, so the next test on this
slot recycles the device; if this cleanup is itself SIGKILLed mid-verify
the marker simply survives, which is the same safe outcome.

Mirrors the conditional-marker-removal hardening in
bazelbuild/rules_apple#3026.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The EXIT trap shut down any suffixed pool simulator (slot >= 1) on every
controlled exit, including passing tests. With --local_test_jobs=2 every
test action landing in slot 1 therefore cold-booted its simulator and shut
it down again, roughly doubling that test's wall time and adding a
multi-GB memory spike per boot.

Pool simulators now stay booted so later tests reuse them warm, the same
as slot 0. Failure and wedge recovery is unchanged: the TERM/INT trap
still shuts the device down on timeout or Ctrl-C, the session marker
still lets the creator reboot devices left by dead sessions, and the
readiness probe still reboots booted-but-unresponsive devices.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
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.

3 participants