Skip to content

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

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

Give each concurrent test action its own simulator via a per-(device, OS) pool#3026
erneestoc wants to merge 7 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 only exist while tests actually run concurrently and get their own suffixed simulator (…_1, …_2).
  • 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. 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.
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