Five Bugs in One Morning: Debugging the Phosphor iOS Client
September 4, 2026
The QR pairing post ended with a working native iOS client for Phosphor BBS. What it didn’t cover is what happened next: the app crashed randomly, the keyboard never appeared, and the whole UI rendered in a sad little window in the middle third of the screen. One morning of debugging later, every bug had a root cause, and every root cause was a lesson worth writing down.
This is a war story. Five bugs, five root causes, and the one thread that ties them together: things that pass every test and still fail on real hardware.
Bug 1: The QR scanner that stopped scanning
The first report was vague: “pairing no longer recognizes the QR code. Nothing happens. Camera never finds it.”
The scanner had worked when it shipped. Nothing in that area had changed on purpose. But a Swift concurrency cleanup had moved the camera start off the main thread, and during that refactor an extracted helper — armMetadataOutput() — was created and, well, never called:
// The refactor extracted this but forgot to invoke it:
private func armMetadataOutput() {
metadataOutput.metadataObjectTypes = [.qr] // ← the entire feature
}
Without metadataObjectTypes = [.qr], AVCaptureSession delivers no metadata objects at all. No callback, no error, no log. The camera preview runs; the recognition silently never starts. The diff that broke it was six lines long and looked perfectly innocent.
Lesson: extracting a helper in a refactor is a contract change. If nothing calls it, the behavior it carried evaporates — and the compiler won’t warn you, because assigning a property is not an error to omit. The fix (789fd2d) re-arms QR detection on every start(), so lifecycle transitions can’t strand it again.
Bug 2: The random crash — an ANSI escape sequence crossed a thread
This one took the crash logs to find. On-device reports showed the same signature every time:
_AAssertAutoLayoutOnAllowedThreadsOnly
hideCursor
Terminal.display
The sequence of events:
- The SSH read loop runs on a background task (correctly — it’s I/O).
- Remote bytes flow into the terminal emulator, including cursor-control sequences. The BBS sends
ESC[?25l— hide cursor — on every screen redraw. - SwiftTerm’s
hideCursorcallscaretView.removeFromSuperview(). - That is a UIKit view-hierarchy mutation. From a background thread, Auto Layout’s assertion fires and the process aborts.
So a byte sequence coming off the network could crash the app. Not malformed input, not an edge case — a completely normal escape sequence that every terminal sends. The crash was “random” only because it depended on whether a ?25l arrived while the background task was mid-flight. The simulator rarely reproduces it (timing is too forgiving), and no unit test exercises view mutation across threads — which is why it sailed through CI.
The fix is a threading contract at the bridge layer:
func write(_ data: [UInt8]) {
if Thread.isMainThread {
MainActor.assumeIsolated { self.writeOnMain(data) }
} else {
DispatchQueue.main.async { self.writeOnMain(data) }
}
}
write() may be called from any thread; SwiftTerm state is only ever mutated on main (111a50a). The deeper lesson: a view library’s thread-safety documentation is a property of its display path, not its input path. SwiftTerm’s feed API looked like it synchronizes updates. It doesn’t — and the proof was in the crash report, not the docs.
Bug 3: The keyboard that never appeared
Users tap the terminal and start typing — except on a fresh launch, there was nothing to tap into focus. SwiftTerm focus-on-tap exists, but nothing ever summoned the keyboard when the screen opened. The fix was two lines of intent: after layout settles, make the terminal first responder.
The subtle part is what we didn’t change. The app installs a custom accessory bar (a SwiftUI-hosted input accessory view) above the keyboard. That’s a notoriously fragile UIKit pattern, and it was the prime suspect for days. It turned out to be innocent — the accessory bar was fine. The real problem was simply that no code path called becomeFirstResponder() on screen entry. Sometimes the exotic hypothesis loses to “nobody asked for focus.”
Bug 4: The middle-third window
The app didn’t use the full screen — it rendered in a letterboxed, centered rectangle, like an old iPhone app on a modern display. On a device whose screen it had never been designed for.
That’s exactly what it was. The build log carried this warning for weeks:
warning: A launch configuration or launch storyboard or xib
must be provided unless the app requires full screen.
Without a UILaunchScreen entry in the Info.plist, iOS runs the app in scaled compatibility mode — the same letterboxing mechanism that lets old 4:3-era apps run on modern hardware. One empty dictionary is the entire fix:
<key>UILaunchScreen</key>
<dict/>
Lesson: the modern opt-in for full-screen is a presence check, not a value check. An empty dict says “this app supports your screen, whatever it is.” Twenty years of iOS development and the difference between “middle third” and “full screen” is one empty <dict/>.
Bugs 5a and 5b: The test suite that lied
While chasing the crash, the full unit suite started failing — three test classes, dozens of failures, all involving a profile store that kept resurrecting stale data. The investigation found two compounding defects, and both are worth framing.
5a: nil ?? default collapses intent. The store had a documented “memory-only” mode: ProfileStore(fileURL: nil). The initializer:
init(fileURL: URL? = nil) {
self.fileURL = fileURL ?? ProfileStore.defaultFileURL
}
nil never means “no file” here — it means “the real Application Support file.” Every test that believed it was isolated was secretly reading and writing the production profile store. It got worse: for weeks the tests passed anyway, because a different bug (missing parent directory) made every save silently fail. When we fixed the save path, the tests started passing saves into a shared real file — alphabetically-first tests seeded it, later tests ingested the pollution. Fixing one bug exposed another.
The fix was splitting the initializers so the two intents are physically different calls:
init() { self.fileURL = ProfileStore.defaultFileURL } // production: persists
init(fileURL: URL?) { ... } // nil = memory-only, honestly
5b: escaped interpolation is a silent string. Tests were also sharing one temp directory because a typo turned interpolation into a literal:
let dir = "pairscan-vm-\\(UUID().uuidString)" // literal backslash-paren — same string every time
"\(x)" interpolates. "\\(x)" is the two characters \( followed by x — identical on every call. Four test files, same typo, each giving every test in the class one shared, colliding temp directory. Grep for \\\\( in your test sources. It costs nothing and we found five.
The pattern
Every one of these bugs is invisible to the layer where it was born:
- The scanner refactor compiled fine and passed tests that never exercised the camera.
- The threading bug only reproduces with real network timing on real hardware.
- The missing launch screen only manifests on a device with a screen shape the app never declared.
- The store bug only manifests after another bug is fixed — the tests were green for the wrong reason.
Which is the argument for testing against real hardware, reading actual crash logs instead of guessing, and treating every green test suite as a hypothesis rather than a verdict. The native client this post is about isn’t released yet — but the server it talks to is. Run your own DTP server and the pieces are all there: session persistence, push notifications, QR pairing.
Try it: point your phone at bbs.phosphorbbs.net — or run your own and pair to that.
#BBS #iOS #Swift #SwiftTerm #debugging #opensource #socialterminal