My perf test counted 534 git subprocesses. It only ran 6

FinderGit watches your Git repos with FSEvents. The whole point of its v0.2 performance work was to stop a naïve "one filesystem event → four git subprocesses" storm. So there's a lock-in test that fires a burst of 100 file writes and asserts the watcher coalesces them into a tiny number of git calls:
#expect(worktreeEvents <= 15,
"watcher coalescing regressed: \(worktreeEvents) worktree events for a 100-file burst")
#expect(runs <= 30,
"subprocess count regressed: \(runs) git runs for a 100-file burst")
It's a great guard. It also started failing — hard:
✘ subprocess count regressed: 534 git runs for a 100-file burstThe cap is 30. Pre-refactor — the bad old days this test exists to prevent — was "400+ runs". So at face value: the debouncer is gone, the regression is back, ship nothing.
Except the app was fast. Idle CPU near zero, exactly as designed. Something didn't add up.
Step 1: believe the other half of the test
The test reads two numbers from a shared stats object. The runs number screamed. The events number was calm:
worktreeEvents = 6
metadataEvents = 0
runs = 534Six. The watcher coalesced a 100-file burst down to six worktree events and zero metadata events. The coalescing works perfectly. So the part of the test that measures the thing the test is named after is green.
That reframes the whole problem. The consumer in the test does exactly one git call per event:
for await event in watcher.events {
if event == .worktree { _ = try? await repo.client.status(in: repo.url) }
else { _ = try? await repo.client.snapshot(in: repo.url) }
}Six events in → six git calls out. So where did the other 528 come from?
Step 2: ask the counter who's calling
runs comes from a process-wide stats singleton, incremented once per git invocation:
public func incrementGitClientRun() {
lock.withLock { _runCount += 1 }
}So I made it confess. Temporarily, in a throwaway worktree:
public func incrementGitClientRun() {
let count = lock.withLock { _runCount += 1; return _runCount }
if count <= 10 {
Thread.callStackSymbols.prefix(8).forEach { print(" \($0)") }
}
}The backtrace, demangled, was not my test:
GitClient.run(_:in:)
← RepoTrustScanner.scanReferences(repository:git:)
← RepoTrustScanner.scan(repository:git:)
← RepoTrustStore.scan(repoURL:) // app target
← RepoTrustStore.fetchIfNeeded(repoURL:) // app targetRepoTrustStore is a feature of the app — it scans repositories for supply-chain droppers, in the background, by shelling out to git for-each-ref + git ls-tree per ref. It has no business running during a framework unit test.
Unless the app is running.
The reveal: your hosted test runs inside your real app
Here's the line in the Xcode project that explains everything:
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/MyApp.app/.../MyApp"The test bundle is hosted by the app. When xcodebuild test runs, it doesn't spin up a bare test runner — it launches the actual app, runs its @main lifecycle, and then executes the tests inside that process. The app comes up, discovers repositories, and kicks off RepoTrustStore scans in the background… all while my test is measuring.
And the counter? A static let shared singleton. One per process. My test and the app's live background work were incrementing the same integer. My 6 honest calls were drowning in ~528 from a feature that wasn't even on screen.
┌─ MyApp.app (test host process) ──────────────────────────┐
│ │
│ RepoTrustStore → scanReferences → git ──┐ │
│ RepoStatusManager → git ────────────────┤ │
│ ▼ │
│ PerfStats.shared ◄── my test reads this
│ ▲ │
│ my test's consumer → git ───────────────┘ │
│ │
└──────────────────────────────────────────────────────────┘That's why CI / xcodebuild test surfaced it while a quick run in the Xcode GUI could look fine: it's entirely dependent on what the host app decides to do in those couple of seconds, which depends on machine state, configured folders, how many repos it finds. Deterministically nondeterministic. The worst kind.
The fix: measure yourself, not the world
A global counter is the wrong instrument for "what did this test do". The test owns its own watcher and its own consumer loop — so count there, in a local actor, and never touch the shared singleton:
actor Metrics {
private(set) var worktreeEvents = 0
private(set) var runs = 0
func record(_ event: GitWatcher.Change) {
if event == .worktree { worktreeEvents += 1 }
runs += 1 // one git call per consumed event
}
}
let metrics = Metrics()
let consumer = Task {
for await event in watcher.events {
await metrics.record(event)
if event == .worktree { _ = try? await repo.client.status(in: repo.url) }
else { _ = try? await repo.client.snapshot(in: repo.url) }
}
}
// … fire the burst, settle …
#expect(await metrics.runs <= 30)Now the assertion measures exactly the events this watcher delivered to this consumer — invisible to whatever the host app is doing on the side. Green, deterministically.
Three things I took away
A failing assertion has two halves: the number, and where it came from. The "534 runs" was real; "the watcher regressed" was my inference, and it was wrong. The calm events = 6 was the tell. Read the metric you didn't expect to move.
Hosted test bundles run your whole app. Anything your app does at launch — timers, background scans, @Observable stores spinning up — is live during your tests, sharing the same process. Lovely for integration tests; a landmine for anything that reads global state.
static let shared is a shared mutable variable with good PR. A process-global counter can't tell your test's work from your app's. If a test asserts on a number, that number should come from something the test owns — a local actor, an injected recorder — not a singleton the rest of the process also writes to.
The watcher was innocent the whole time. The test just couldn't hear it over the app it was living inside.


Replies