Skip to content

Give every checkout a simulator of its own - #141

Merged
kyleve merged 2 commits into
mainfrom
per-checkout-simulators
Jul 26, 2026
Merged

Give every checkout a simulator of its own#141
kyleve merged 2 commits into
mainfrom
per-checkout-simulators

Conversation

@kyleve

@kyleve kyleve commented Jul 26, 2026

Copy link
Copy Markdown
Owner

Resolving simulators by UDID fixed ambiguity between same-named devices on different runtimes, but every checkout on a machine still landed on the same device. Agents working in parallel clones and worktrees raced each other booting it, installing the same bundle ID, and erasing it mid-run — collisions that fail as Application failed preflight checks (Busy) or Mach error -308, which read as test failures.

A simulator is now a per-checkout resource.

What ./simulator does

  • Derives a device name from the checkout's path (Stuff-<folder>-<hash>-iPhone-17-27.0) and creates that device on first use. A worktree resolves to its own root, so it counts as a separate checkout. Expect a couple of minutes the first time — a fresh device runs a data migration on its first boot.
  • Keeps an index under ~/Library/Application Support/Stuff/simulators recording which checkout owns which device. It's an index, not a source of truth: simctl still answers whether a device exists, and the name is derived rather than looked up.
  • Serializes lookup-or-create behind a per-checkout lock, so two concurrent first runs converge on one device instead of creating twins. The lock does not span the boot, and it records its pid so one left behind by a killed run is taken over rather than waited out.
  • New flags: --list, --prune (with --dry-run), --delete, --recreate, and --shared for the old name-based behavior.

--prune is deliberately cautious. It skips a checkout whose parent directory is also missing, since an unmounted volume looks exactly like a deletion and a deleted device takes everything installed on it along. Devices no index entry claims — the leftovers of a renamed or moved checkout — are reported as unowned but never deleted, because a cleared index makes a live checkout's device look identical.

CI

The boot step passes --shared: a job already owns its VM, so the isolation is free there and a per-run device would only add a first-boot migration and pile up on a reused runner. Resolution still happens by UDID, exactly as before.

Docs

The AGENTS.md section is now "Selecting a simulator — one device per checkout, addressed by UDID", and the "done" bullet spells out the destination, because a bare tuist test lets xcodebuild pick the machine-wide device this change exists to stop sharing. README.md carries the recipe and the cleanup commands. Module docs keep their bare tuist test <Bundle>Tests lines — the destination rule lives once, at the root.

Test plan

  • Creation on first run, reuse on the second, and --list showing the owning checkout
  • A git worktree getting its own device, and --prune deleting it once the worktree was removed
  • --prune forgetting an entry whose device was deleted by hand, and skipping one whose parent directory is missing
  • --prune --dry-run reporting the plan and changing nothing
  • An unowned Stuff- device reported by --list and --prune, and left alive
  • Two concurrent first runs producing one device and the same UDID twice
  • A second run returning in under a second while another is mid-boot (lock no longer spans the boot)
  • A stale lock (dead pid) reclaimed with a warning
  • --shared resolving the stock iPhone 17 without touching the index
  • ./swiftformat --lint clean, and tuist test Stuff-iOS-Tests green against a freshly created device
  • CI green on the --shared boot step

Made with Cursor

kyleve and others added 2 commits July 26, 2026 14:45
Resolving by UDID fixed ambiguity between same-named devices on different
runtimes, but every checkout on a machine still landed on the same device — so
agents working in parallel clones and worktrees raced each other booting it,
installing the same bundle ID, and erasing it mid-run. Those collisions fail as
`preflight checks (Busy)` / `Mach error -308`, which read as test failures.

`./simulator` now derives a device name from the checkout's path, creates that
device on first use, and keeps an index (in Application Support) of which
checkout owns which device so `--list` and `--prune` can report and clean up
after a deleted worktree. `--recreate` replaces a wedged device, and a
per-checkout lock keeps two concurrent first runs from creating twins. CI opts
out with `--shared`: a job already owns its VM, so a fresh device would only
add a first-boot data migration to every run.

Co-authored-by: Cursor <cursoragent@cursor.com>
Review of the per-checkout work turned up four problems worth fixing before
anyone relies on it.

The lock spanned the boot, so a second run in the same checkout queued behind a
first boot — measured at 108 seconds here, against a 120-second timeout whose
error told the user to delete a lock a healthy run was still holding. It now
covers only the lookup-or-create, records its pid so a lock left by a killed
run is taken over rather than waited out, and is idempotent so a mode that
already locked can't deadlock itself.

`--prune` treated any missing directory as a deleted checkout, which an
unmounted volume looks exactly like; it now skips when the parent is missing
too, and `--dry-run` reports the plan without touching anything.

Devices whose index entry is gone — the leftovers of a renamed or moved
checkout — were invisible to both `--list` and `--prune`. Both now scan for the
name prefix and surface them as unowned, and prune reports rather than deletes
them, because a cleared index makes a live checkout's device look identical.

Finally, "done" in AGENTS.md asked for a bare `tuist test`, which lets
xcodebuild pick the machine-wide device this change exists to stop sharing.

Co-authored-by: Cursor <cursoragent@cursor.com>
Comment thread AGENTS.md
@kyleve
kyleve merged commit 1bc075d into main Jul 26, 2026
3 checks passed
kyleve added a commit that referenced this pull request Jul 26, 2026
Two commits: the typed LifecycleKit rework (#116) and per-checkout
simulators (#141). Four conflicts, all small.

OnboardingView's init changed on main — a `LifecycleGateHandle` plus an
explicit session, replacing the step UI bridge — so its snapshot case
was re-authored against the new signature. It keeps
`PreviewSupport.onboardingModel()` rather than main's `loadedModel()`,
because a model whose `hasOnboarded` is false is what pins the capture
to the intro phase. All 232 references still match, so the rework
didn't change what onboarding renders.

Main added an onboarding hosting smoke test and touched two in
ScreenHostingTests. The snapshot bundle owns "does this screen render"
here, so the smoke test is dropped and the file stays deleted; main's
real fix in it — DataSettingsView now reads the session from the
environment — is ported into DataSettingsViewTests, which is where
those two tests moved.

The snapshot CI job adopts main's new `./simulator --shared`. It matters
more here than for the unit job: references are pinned to one renderer,
so a freshly created device's first-boot state is exactly the variable
to keep out of the run. The re-record instructions in both snapshot
READMEs move off name-based destinations to `$(./simulator --os 27.0)`,
matching the convention main documented.

Co-authored-by: Cursor <cursoragent@cursor.com>
kyleve added a commit that referenced this pull request Jul 27, 2026
Brings in the typed LifecycleKit migration (#116), matrixed WhereUI
snapshot testing (#101), and per-checkout simulators (#141).

Two conflicts, both documentation prose where each side added a
different thing to the same paragraph:

- Root AGENTS.md, the dev-script list: main added `simulator` owning a
  device per checkout and renamed its section heading; this branch added
  `attribution`. Kept main's wording and anchor, and folded `attribution`
  back into both the list and the "owns state easy to corrupt" set,
  which is now three scripts rather than two.

- WhereUI README, the RootView bullet: main rewrote it for the typed
  launch plan and LifecycleKitUI's container; this branch had appended
  the About screen's description. Took main's rewrite as the base and
  re-added the About sentence after the data-screens clause.

Everything else auto-merged. Verified on the merged tree rather than
trusting that: regenerated the project, then swiftformat --lint,
xcstrings --lint, bumper lint, and the full Stuff-iOS-Tests scheme —
251 suites, all green, with the attribution suites among them.
kyleve added a commit that referenced this pull request Jul 27, 2026
…#140)

## Summary

Adds an **About** block at the bottom of Settings covering the things the app couldn't previously answer: which build is running, what third-party work it's built with, and where its bundled region boundaries came from.

The app had no build metadata at all — version and build number were Tuist's `.extendingDefault` values and nothing recorded the commit — so this also adds the plumbing to stamp it.

**Credits are vended by the module that owns the thing being credited**, and the screen only renders them:

- `WhereCore.BuildInfo` — the version keys plus the commit stamped into the built Info.plist.
- `CreditKit.SoftwareCredit` — third-party software and its license notice, in a new Shared module (see below).
- `RegionKit.RegionDataSource` — the origin, license, and fidelity of the bundled GeoJSON.

### Attribution: CreditKit reports, the app ships the report

Attribution started out in WhereCore, which was wrong: WhereCore is just the only module that happens to link an external package today, and the edge pointed the wrong way — Broadway, Periscope and friends are documented as portable and app-agnostic, so none of them could have vended a credit without depending on app code.

The first pass moved it to a Shared module, but that module then *was* the Where app's credits wearing a library's clothes: a `credits.json` and five vendored notices in its own resource bundle, a generator hardcoding this repo's paths, and tests asserting ZIPFoundation was linked. A second app could not have used any of it.

`Shared/CreditKit` now owns the **format and the tooling and nothing else**. An app declares its own inputs, `./attribution` runs the report, and the result lands in that app's resources — for Where, `Where/Where/Resources/attribution.json`:

```json
{
  "output": "Where/Where/Resources/attribution.json",
  "sources": [
    { "type": "swiftPackageManager", "kind": "library",
      "manifest": "Package.swift", "resolved": "Package.resolved" },
    { "type": "agentSkills", "kind": "developmentTool",
      "manifest": ".agents/external-skills.json" }
  ]
}
```

Dropping the resource bundle dropped the last dependency too: CreditKit is Foundation-only and throws rather than logs, leaving the caller to decide what a missing report means. Notices are **inlined** into the report, so one decode yields everything needed and the missing-file path stops existing.

The report is still *derived*, which is what stops it going stale: credits come from packages a target actually links via `.product(name:package:)`, pinned by `Package.resolved`, with each notice read at the **pinned revision**. A new dependency is credited wherever it lands, and a package resolved only for tooling (BumperBowling, swift-syntax) correctly isn't. The generated notices came out byte-identical to the ones previously vendored by hand, and re-running produces no diff.

**Kind is derived too, not declared.** Linking is not shipping: `SnapshotKitTesting` links the snapshot-testing engine and the accessibility parser, and crediting those as libraries would tell a reader their binary contains a test harness. The config's `shippedFrom` names the package targets the app and its extensions link (`WhereUI`, `WhereIntents`); the generator walks the manifest's target graph from there, so a package inside that closure is a `library` and any other linked package is a `developmentTool` — same bucket as the vendored agent skills, credited because the repo depends on them rather than because they reach a device. `shippedFrom` is the only value set by hand.

`WhereCore.AppAttribution` reads the report from `Bundle.main` beside `BuildInfo`, and splits the two failures they share: a bundle with **no** report is legitimate (only the app target ships one), while a report that won't decode is a corrupt resource and faults. `AppAttribution.main` caches the decode for the process — About takes it as a default argument, and Swift re-evaluates those on every `init`, so reading eagerly re-read and re-decoded the file on every SwiftUI body pass; `current(bundle:)` remains for tests and other bundles.

About says something in **every** empty case, keyed on the section rather than on the report: "no report at all" and "nothing of this kind" describe different builds, and a header and footer over no rows describes neither. The generator also refuses to write a report with duplicate credit names (`SoftwareCredit` is `Identifiable` by name, and a library's name is its repo basename, so two orgs can collide) or an unrecognized `kind`, which would otherwise generate and commit cleanly and then fail to decode inside the app.

### The build stamp

A post-build script phase on the `Where` target writes `WhereGitSHA` / `WhereGitStatus` into the built product's plist. Post (not pre) so it edits the plist after "Process Info.plist" writes it and before signing seals the bundle, and `basedOnDependencyAnalysis: false` so an unchanged source tree can't ship the previous commit's SHA. A checkout without git metadata stamps `unknown` rather than failing the build, and unstamped bundles (RegionViewer, `StuffTestHost`, the extensions) report no commit rather than a placeholder.

### Content call worth a look

Canada and the EU have no external source and no license — `RegionKit/README.md` calls them coarse placeholders unfit for a real residency audit. The page says that plainly ("Approximate outline drawn for this app — coarse, and not authoritative") rather than omitting them. Say if you'd rather word it differently or leave them off.

## Test plan

- [x] `./swiftformat --lint`, `./xcstrings --lint`
- [x] `swift run bumper config .`, `swift run bumper test .`, `swift run bumper lint .` — no violations
- [x] `tuist test CreditKitTests`, `WhereCoreTests`, `WhereTests`, `RegionKitTests`, `WhereUITests` — all green, including the new `AttributionManifestTests`, `AppAttributionTests`, `BuildInfoTests`, `RegionDataSourceTests`, `AboutSettingsViewTests`, `LicenseViewTests`
- [x] `./attribution` is idempotent — a second run rewrites the same bytes
- [x] Built and installed the app: the stamp lands in `Where.app/Info.plist` (`WhereGitSHA 778d7e8`, `WhereGitStatus dirty`) and re-runs on every build
- [x] Rendered both screens in the simulator
- [x] Full `Stuff-iOS-Tests` scheme — 251 suites green after merging `main` (the earlier run's four `StuffTestHost` bundle kills were simulator contention on this machine; #141's per-checkout simulator cleared it)
- [x] `tuist test WhereUISnapshotTests` — 31 suites green on a clean re-run after recording

### Snapshots

`main`'s snapshot bundle (#101) landed after this branch wrote the About screen, so merging the two invalidated 9 of the 11 `SettingsViewSnapshotTests` references — the Settings list grew an About row. Those are re-recorded, and `AboutSettingsView` / `LicenseView` now have the `SnapshotProviding` conformance and suite the module convention asks for. The About matrix is built around *absence*, since the shipping build is the only one both stamped and attributed: a full report, a dirty tree, no report at all, and a report crediting only one kind. That last case pins the empty-section fix as an image, which suits a failure that was purely visual.

Recording caught a stale string: `LicenseView`'s empty-notice case still said "This license text couldn't be loaded from the app bundle", describing the old architecture where notices were separate bundle resources. Nothing is loaded now — the notice is inlined — so it reads "This credit's report entry carries no license text".

### Drift guards

- `RegionDataSourceTests` fails if a catalog region is covered by zero or two sources, so a regenerated catalog can't ship uncredited geometry. Coverage derives from the `us-` prefix the generator mints plus an explicit id list — deliberately not an "everything else" bucket that would quietly mis-credit a new region.
- `./attribution --check` re-derives the expected report from `Package.swift`, `Package.resolved`, and the skills manifest and diffs it against the committed one, gated in CI. It makes no network calls — notices are read at the pinned revision, so a matching revision means matching text — and runs in under a second. **This replaced a guard that didn't work.** `AppAttributionTests` had compared the report to a hardcoded list of names, which a stale report satisfies exactly as well as a fresh one; it passed on this branch while the committed report was missing two packages the `main` merge had added. A test bundle can't read `Package.swift`, so it can't do this job at all. It now asserts only what the shipping bundle can answer: present, decodable, notices inline, unique names, both kinds populated.
- `AboutSettingsViewTests.separatesLinkedLibrariesFromDevelopmentTools` fails if either section would render empty or lose its heading.

## Follow-up filed

Root `TODOs.md`: a `LogSession` names only a version and build number, both pinned in the manifest, so every developer build logs as `v1.0 (1)`. PeriscopeCore sits below WhereCore and can't read `BuildInfo`, so carrying the commit into log sessions needs a seam rather than a direct adoption.

Made with [Cursor](https://cursor.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.

1 participant