Back to Blog

Locking iOS Simulators for Parallel Appium Tests

AppiumiOSTestingAutomationReact Native

If you run end-to-end tests against iOS, you eventually want to run more than one suite at a time: a second git worktree for a hotfix, a parallel CI shard, or just a second Claude/terminal session driving a different branch. The moment you do, you hit a wall that has nothing to do with your tests and everything to do with a shared global resource: the simulator.

This is the approach I pulled out into workways as the sim-lock piece of the rn-e2e cluster. It's small, it's cooperative, and it's saved me from a lot of mysterious "why did my test just tap a button on the other branch's screen" moments.

The problem: simctl knows "booted," not "busy"

Appium binds to a simulator by name or UDID and then drives its foreground. Two Appium sessions pointed at the same sim will race each other: one launches your app, the other relaunches it, taps land on the wrong screen, and both suites flake in ways that don't reproduce.

You might reach for xcrun simctl to coordinate. It can't help you here. simctl will happily tell you a sim is Booted or Shutdown, but "booted" is not "busy." A sim can be booted and idle, or booted and in the middle of someone else's login flow. There's no built-in notion of "this simulator is currently claimed by that test run."

So I added one.

The idea: a UDID-keyed intent lock

Each simulator gets a lockfile under ~/.cumbre/sim-locks/<udid>.lock. The file is just JSON describing who claimed it:

{
  "pid": 48213,
  "worktree": "/Users/me/code/app-hotfix",
  "branch": "fix/login-crash",
  "name": "iPhone 16",
  "claudeSessionId": "…",
  "tty": "ttys004",
  "started": "2026-05-30T18:40:11.204Z"
}

Now any session can answer the question simctl couldn't: is this sim free, and if not, who has it and what are they doing? The lock is advisory, all the cooperating processes agree to check it, but that's all you need when the "adversary" is just your own other terminal.

Acquiring without a race

The one part you can't hand-wave is the acquire step. If two runs both see "no lock exists" and both write the file, you've coordinated nothing. The fix is to let the filesystem be the referee with an exclusive create:

// from scripts/lib/lockfile.mjs
const acquire = (key, payload, { force = false } = {}) => {
  ensureDir();
  const p = lockPath(key);
  try {
    // 'wx' = O_CREAT | O_EXCL: fails if the file already exists.
    const fd = fs.openSync(p, 'wx');
    fs.writeSync(fd, JSON.stringify(payload, null, 2));
    fs.closeSync(fd);
    return { ok: true, holder: null };
  } catch (e) {
    if (e.code !== 'EEXIST') throw e;
    const existing = readLock(key);
    if (!force && existing && pidAlive(existing.pid)) {
      return { ok: false, holder: existing };   // someone alive has it
    }
    // holder is dead (or we're forcing), take it over
    fs.writeFileSync(p, JSON.stringify(payload, null, 2));
    return { ok: true, holder: null };
  }
};

The wx flag maps to O_EXCL, so the create is atomic at the OS level: exactly one of two simultaneous callers wins, the other gets EEXIST and reads back the holder's info instead of clobbering it. When it loses, it fails fast with a useful message rather than fighting for the foreground:

sim-lock: held by pid 48213 (branch=fix/login-crash worktree=/Users/me/code/app-hotfix).
Re-run with --force to override.

Stale locks fix themselves

Locks that need manual cleanup are locks people stop trusting. If a process holding a sim crashes, its lockfile stays behind, so before honoring a lock we check whether its owner is actually alive:

export function pidAlive(pid) {
  if (!pid || pid <= 0) return false;
  try { process.kill(pid, 0); return true; } catch { return false; }
}

kill(pid, 0) sends no signal; it just asks "could I signal this PID?" If the owning process is gone, the next acquire silently takes the lock over. No reaper daemon, no TTL guessing. --force is there as the escape hatch for the rare case where the PID is alive but wedged (a hung Appium that won't die on its own).

Elastic allocation: claim-any

Hardcoding a UDID per run doesn't scale past one machine. claim-any auto-discovers the iPhone simulators on the box and grabs the first free one:

$ node sim-lock.mjs claim-any
UDID=0DDE1018-EA8A-4630-9CE0-07D51508FD59

Discovery is just xcrun simctl list devices available --json filtered to available iPhone-family devices, so it's zero-config in the common case. If you want to pin a specific subset (say, only the two sims you keep warm), drop a ~/.cumbre/sim-pool.json and it overrides discovery:

{
  "sims": [
    { "udid": "0DDE1018-EA8A-4630-9CE0-07D51508FD59", "name": "iPhone 16" },
    { "udid": "8F9BDE7B-E344-4B8D-A914-E89191AA8301", "name": "iPhone 16 Pro" }
  ]
}

On a fresh Xcode with no sims, or when every candidate is busy, claim-any on an interactive terminal offers to provision one for you, xcodebuild -downloadPlatform iOS for the runtime and xcrun simctl create for the device. On a non-TTY (CI, piped stdin) it prints the manual commands and exits non-zero instead of hanging forever waiting on a prompt nobody will answer.

Wiring it into the test run

You almost never call acquire/release by hand. A wrapper holds the lock for exactly the lifetime of the command and cleans up no matter how it exits:

# scripts/sim-lock/with-lock.sh (the important bits)
node "$SIM_LOCK" acquire "$UDID" --pid "$$"

cleanup() { node "$SIM_LOCK" release "$UDID" --pid "$$" || true; }
trap cleanup EXIT INT TERM

export IOS_UDID="$UDID"
"$@"

The trap on EXIT INT TERM is what makes it safe to Ctrl-C a run: the lock is released even on interrupt. And because it exports IOS_UDID into the child environment, your WebdriverIO config can prefer the locked UDID over a device name, so the suite is pinned to exactly the sim you claimed:

# elastic: grab any free sim, run the suite, release on exit
scripts/sim-lock/with-lock.sh --claim-any -- npm run e2e:ios

# fixed: bind to the UDID in .env.test
scripts/sim-lock/with-lock.sh --from-env IOS_UDID -- npm run e2e:ios

The convention that ties it together

The tooling enforces nothing about how many sims you run; that's a convention, and it's a deliberately boring one:

One sim per phone model. The lock is the queue.

Don't create iPhone 16 #2 and iPhone 16 #3 to get parallelism. Instead, keep distinct models and let the lock serialize contention:

  • iPhone 16 → owned by e2e (npm run e2e:ios)
  • iPhone 16 Pro → reserved for hands-on manual QA

If both are busy, the third caller fails fast with "held by <branch>" instead of silently double-booking a device. Two suites that genuinely need to run at once just claim two different models.

One nice side effect: because the lock records the claiming session (via CLAUDE_CODE_SESSION_ID, falling back to the controlling TTY), sibling terminals in the same directory each see only the lock they own. That powers a tiny status-line readout, Sim: iPhone 16 Pro when this window holds a lock, Sim: - otherwise, with a JSON-only lookup so it stays cheap.

Where this fits

The simulator is only one of three globals that collide when you parallelize iOS e2e; the other two are the Metro bundler port (baked into the .app at build time) and Xcode's DerivedData. sim-lock composes with metro-lock and a per-worktree DerivedData redirect to solve all three, each layer exporting the env var the next one reads. But the sim lock is the one you'll want first, because it's the collision you hit the very first time you open a second worktree.

If you want the whole toolkit, it's npx workways add rn-e2e. It copies the scripts into your repo so you own and edit them, rather than depending on anything at runtime.

Happy (parallel) testing. 🧪