Debugging WKWebView Process Churn on iPhone — Three Agents, One Loop

claude-codeai-developmentioswkwebviewdebuggingagents

The iPhone build was acting haunted. Every sync cycle, the log filled with WebContent processes launching, failing to initialize, and dying — 60 of them in 30 seconds. Each one logged the same cascade: CARenderServer bootstrap failures, errSecInteractionNotAllowed (-25308) on keychain access, “Failed to initialize application enviroment context” (Apple’s typo, not mine). Nothing was crashing outright — the app felt fine — but the signal in Console.app was the kind of red-flag pile that usually means something load-bearing is cycling.

I ran the /loop skill and asked Claude to keep going until end-to-end working, with a fresh agent for each verification pass. That constraint — fresh eyes on every check — is the part I want to get right as a solo dev habit.

The diagnostic agent

First job was figuring out where the processes were coming from. I didn’t want to read 40 files myself. I dispatched an Explore agent with a specific question: find every WKWebView instantiation, tell me whether each one is attached to a view hierarchy, and flag any sync/extraction code that could churn them in a loop.

The agent came back with the exact answer in three minutes:

MozillaReadabilityExtractor.swift:128 and :261: Fresh WKWebView created per extraction, never attached to view hierarchy. These are orphaned frames (1024x768) floating off-screen with no window parent.

Plus three more findings I hadn’t suspected:

  • SyncCoordinator.performSync() runs extractMissingContent + sweepStaleExtractions (up to 25 items batched) with maxConcurrency: 4 every sync
  • NARSSRApp.onChange(scenePhase) fires sync on every .active transition with no debounce
  • BackgroundRefresh.swift repeats the same cycle every 30 minutes

Doing the math: 4 concurrent × 25-item sweep × fires on every foreground resume. Six or seven batches to drain 25 stale items. Each WKWebView spins up its own WebContent process. ~60 processes per sync cycle, matched to what I was seeing.

The CARenderServer and keychain errors were symptoms, not causes. Off-screen WKWebViews without a window hierarchy can’t reach the render server or the keychain — WebKit expects a real parent window before it’ll bootstrap those services.

The implementation agent

Second agent, clean scope: ship four surgical changes.

1. Hidden window for extraction WebViews. A new ExtractionHostWindow utility creates one hidden UIWindow (alpha 0, 1x1, below-normal windowLevel) at app launch. Every extraction WKWebView is added as a subview of that window’s root view controller. Now the WebViews have a real window hierarchy — CARenderServer bootstraps, keychain queries work, accessibility registers. Gated #if canImport(UIKit) && os(iOS) so nothing affects macOS or visionOS.

2. Pool the WebViews. Two reusable WKWebView instances, capacity 2, serialized via MainActor queue with FIFO waiters. Each extraction leases a WebView from the pool via withWebView { ... }, runs Readability.js, and returns it blanked (loadHTMLString("", baseURL: nil)). Natural cap on concurrent WebContent processes.

3. Debounce foreground sync. NARSSRApp.swift tracks a last-sync timestamp. If .active scenePhase transitions fire within 60 seconds of the last sync, skip. Pull-to-refresh and explicit intents still bypass the debounce.

4. Stagger the stale sweep. SyncCoordinator reads a com.narssr.sync.lastSweepAt UserDefault. sweepStaleExtractions only runs every 4 hours, not every sync. The sweep of already-extracted items re-checking for newer content is background hygiene — nobody needs it firing 20 times a day.

Agent shipped all four in one pass. swift build clean. No commits yet — left the tree dirty for review.

The fresh verification agent

This is the /loop part I wanted to test: use a different agent with zero context from the fix for verification. Third agent, cold start: “a fix claims to do X. Verify it empirically.”

The agent built the iOS target, installed on an iPhone 17 Pro simulator, launched the app, captured 90 seconds of console logs, then queried the app’s SQLite database directly to verify extraction was still working. No assumptions about whether the fix was correct — just measurements.

Results:

metricbeforeafter
Unique WebContent PIDs (90s window)60+14
CARenderServer bootstrap failuresdozens1
-25308 keychain errorsconstant0
WebCrypto master key errorsconstant0
”Failed to initialize application enviroment”every process0
Articles extracted in 90s267 of 438

14 WebContent PIDs over 267 extractions is still more than the pool of 2 would suggest at first glance, but WKWebView site-isolates to different processes per origin. With 18 seeded feeds across 18 distinct domains, that’s normal.

I committed fa2def9.

The per-page audit

Then I sent a fourth agent on a sweep: walk every screen in the app, screenshot it, read the SwiftUI view backing it, flag anything suspicious. Cold eyes, no context about what I thought was done.

It caught three things I shouldn’t have shipped:

P0 — Nostr relay UserDefaults keys don’t match anywhere. SidebarView reads narssr.nostr.relayURLs. NostrSettingsView writes nostrRelayURLs. AppState reads narssr.sync.nostrRelayURLs. Three components, three keys, zero alignment. User-configured relays never actually drive sync or the status pill. It always says “Not configured.”

P1 — Newsletter toggle is a no-op. The code comment literally says “In a full implementation, this would persist…” The switch flips the @State but nothing else. Ships as fake functionality.

P1 — “Source Code” link in Settings points at https://github.com. Bare domain. Dead link.

Plus a Discover “Browse by Entity” that’s a ContentUnavailableView("Coming soon"), a Topic Graph with no tap-to-navigate, and a search screen whose “suggestions” are literal-text FTS queries that don’t match anything.

None of these would’ve crashed. All of them would’ve made TestFlight users tap something and get no feedback. Worth an agent pass.

What I’m keeping from this

Three observations after running four agents back-to-back on one bug:

  1. A fresh agent is the cheapest form of code review I have. The verification agent didn’t know the fix was “supposed” to work — it measured. That’s different from asking the same agent that wrote the fix to confirm its own work.
  2. Diagnostic agents want one specific question, not “figure out what’s wrong.” My prompt said “find every WKWebView, check if attached to window, flag sync churn.” The answer came back in the shape I asked for.
  3. A per-screen audit catches the stuff you stop seeing. I’ve looked at the Newsletter toggle dozens of times. A cold agent looked at it once and read the comment admitting it was fake. I’d have shipped it.

The /loop runs again tomorrow. Three P0/P1s in the queue, plus icon rendering on device, plus visionOS spatial intro device validation when I get the Vision Pro back.