Give each concurrent test action its own simulator via a per-(device, OS) pool - #3026
Open
erneestoc wants to merge 7 commits into
Open
Give each concurrent test action its own simulator via a per-(device, OS) pool#3026erneestoc wants to merge 7 commits into
erneestoc wants to merge 7 commits into
Conversation
erneestoc
requested review from
aaronsky,
adincebic,
brentleyjones,
keith and
luispadron
as code owners
July 22, 2026 17:27
keith
reviewed
Aug 18, 2026
erneestoc
force-pushed
the
simulator-pool
branch
4 times, most recently
from
August 19, 2026 18:59
ba0f8b2 to
d9e7c81
Compare
5 tasks
erneestoc
force-pushed
the
simulator-pool
branch
2 times, most recently
from
August 20, 2026 22:44
e12cde0 to
7ab1a25
Compare
erneestoc
force-pushed
the
simulator-pool
branch
2 times, most recently
from
August 21, 2026 00:06
30cf23b to
c722998
Compare
3 tasks
aaronsky
reviewed
Aug 21, 2026
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
force-pushed
the
simulator-pool
branch
from
August 21, 2026 20:21
39c1ec5 to
049d1c3
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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):xcodebuild test-without-building/xctestsessions against the same reused simulator disrupt each other — 300s hangs, orFailed to initialize for UI testing: … kAXErrorCannotComplete.BAZEL_TEST_iPhone 16_26.0devices 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
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.…_1,…_2).${SIMULATOR_POOL_LOCK_DIR:-${TMPDIR:-/tmp}}. Bazel pointsTMPDIRat the per-user temp dir (/var/folders/…/T/), which is shared across sandboxed test actions and writable there — unlikeTEST_TMPDIR, which is per-action. Verified underdarwin-sandboxwith two concurrent test actions.TMPDIRis not part of the action's declared environment (bazel aqueryshows onlyPATH), so under remote execution the worker decides what it is, and a worker handing each action its ownTMPDIRwould 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.shlockatomically reclaims dead slots. (First attempt used a kernel flock held on an fd vialockf(1); abandoned because macOSlockf's fd mode misbehaves under try-lock probing — after one failed probe, every subsequent probe in that process fails even on free lock files.)xcodebuild test-without-buildingsits 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.simctl bootstatus -bhas 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 ownTEST_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-entersbootstatusand resumes where the previous attempt left off — a slow first-boot data migration completes across attempts instead of restarting from zero each time.bootedwhile being unusable — if the test that started its initial boot was killed partway through, the device staysbootedbut never becomes able to run tests, and every later test reusing it hangs. Reusedbooteddevices are now probed for readiness (SpringBoard insimctl spawn <udid> launchctl list, bounded at 30s) and rebooted if not responding.ios_test_runner(doesn't setSIMULATOR_POOL_SLOT→ slot 0 → today's behavior),reuse_simulator = False, device runs, and machines withoutshlock(1)(degrades to today's single-simulator behavior).Review feedback addressed
$TMPDIRinstead of shelling out togetconf DARWIN_USER_TEMP_DIR.--local_test_jobsalready bounds overlapping boots.SIMULATOR_POOL_SLOTnow parses as a hard error instead of silently falling back to slot 0.SIMULATOR_POOL_LOCK_DIRso the lock directory is configurable for execution environments that do not share$TMPDIR.Test plan
darwin-sandbox(shared$TMPDIRis writable and lockable there, and identical across concurrent test actions, under bothdarwin-sandboxand--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$TMPDIRall fall back to slot 0 immediately instead of hanging; propagation through--test_envconfirmed.//test:ios_xctestrun_runner_ui_testbehaves identically to main; single-test runs claim slot 0 and reuse the historically-named simulator.