Squirreleye LLC Squirreleye Service Notes Naked Labs Mirror · Revival Series Index Tools About
10
Section 10 · follows 09

Twelve networks and a null byte

Written
2026-05-16
Log
NakedMirrorDebug/logs/current.txt

The previous post closed with a feedback loop that was supposed to be faster: the iPhone exposes /logs, a Mac-side Python script polls it, and I read the file directly. No more AirDrop. The first real test of that loop happened the same afternoon, and it produced two findings: one a parser bug I’d been blind to for sessions, the other a piece of friction that only got noticed because the loop was finally fast enough to expose it.

§ 10.1

The first WiFi-discovery log I’d ever read

I tapped the toolbar 📶 button and tapped Refresh. v0.2.0’s instrumentation kicked in, the iPhone wrote to its global log, the Mac-side tail mirrored the file, and within two seconds I was reading lines I’d never seen before.

The good news first: every step of the protocol worked. The mirror accepted the 0x31 command, the count read returned [0c] (twelve in decimal, base trap acknowledged), and twelve SSID reads followed. Each one came back as 81 bytes of data. The hex dumps were clean. The format string was exactly what the APK said: index/SSID/security/signal.

The bad news, on the very next line: every one of those twelve SSID parses failed. The log told me twelve times in a row:

WARN WiFi discovery: SSID[0] parse failed (expected index/SSID/security/signal)WARN WiFi discovery: SSID[1] parse failed (expected index/SSID/security/signal)…INFO WiFi discovery: parsed 0 network(s) from 12 advertised

That last line is the punchline. The mirror told me about 12 networks. I rejected all 12.

Fig. 10-1
sequenceDiagram
    participant Phone
    participant Mirror
    Phone->>Mirror: write 0x31 to scannerAction
    Phone->>Mirror: read wifiNumNetworks
    Mirror-->>Phone: count = 12 (0x0c)
    loop 12 times
        Phone->>Mirror: read wifiNetworkSSIDEnum
        Mirror-->>Phone: 81-byte buffer (C-string + zero padding)
    end
    Note over Phone: each buffer should parse as "index/SSID/security/signal\0\0\0..."
§ 10.2

Where the parser tripped

The instrumentation had logged each SSID three ways: raw byte hex, a decoded string, and the parse-failure warning. Reading the three together made the bug instantly visible:

WiFi discovery: SSID[0] (81 bytes) raw=[31 30 2f 69 52 6f 62 6f 74 2d 46 38 34 45 2f 31 2f 35 39 00 00 00 00 00 00 …] str="10/iRobot-F84E/1/59 "WARN WiFi discovery: SSID[0] parse failed

The mirror sends each SSID as an 81-byte fixed-width buffer holding a C-string. The format payload is followed by zero-padding: twenty bytes of 10/iRobot-F84E/1/59, then sixty-two bytes of 0x00. Standard C convention. The parser:

Fig. 10-2
swift
let raw = String(data: ssidData, encoding: .utf8) ?? ""let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)let parts = trimmed.split(separator: "/", maxSplits: 3, omittingEmptySubsequences: false).map(String.init)guard parts.count >= 4, let idx = Int(parts[0]…), let signal = Int(parts[3]…) else { continue }

…handled this almost right. The split works, because / is unambiguous. parts[0] parses cleanly as "10". parts[1] and parts[2] are the SSID and security fields and are never parsed numerically. parts[3] is where it dies, because parts[3] isn’t "59". It’s "59\0\0\0\0…".

String.trimmingCharacters(in: .whitespacesAndNewlines) does not strip null bytes. The Unicode whitespace category covers spaces, tabs, newlines, form feeds, vertical tabs, the various exotic Unicode spaces, but not U+0000. Null isn’t whitespace in any Unicode sense; it’s its own thing. So trimmed carries the null padding all the way through the parse, and Int("59\0\0\0…") returns nil, because Int rejects non-digit characters.

The fix is one line at the wire boundary. Strip the null padding from the Data before converting to String:

Fig. 10-3
swift
let cString = Data(ssidData.prefix(while: { $0 != 0 }))let raw = String(data: cString, encoding: .utf8) ?? ""

Data.prefix(while:) walks the bytes and stops at the first 0x00, exactly matching C-string semantics. The resulting cString is the payload only, "10/iRobot-F84E/1/59", nineteen bytes, no padding, ready to split.

I also widened the log line slightly so the next run shows both the raw buffer size and the payload size: (81 bytes, 19 before NUL padding). The hex dump stays full-width (useful to confirm I’m still reading the same wire format). The str= field becomes much shorter and easier to scan.

§ 10.3

The other thing the streaming loop made obvious

Before the WiFi-sheet fix went anywhere, there was a smaller annoyance I hit twice: mirror-tail <my-ip> couldn’t connect until step 11 had run. The HTTP listener doesn’t bind at app launch: it binds inside stepStartListener, which is only called when Run All reaches that step or I tap Step 11 individually. With the old AirDrop flow, this didn’t matter (you only exported once the whole run was done anyway). With the streaming flow, it matters every session: open the app, the script is already trying to poll, “connection refused” until you remember to tap Run.

That ordering is a side effect of an earlier design assumption: that the listener is a diagnostic step whose binding success is itself part of what I’m testing. It is. But there’s no reason that test can’t be “the listener started at app launch and was still reachable when I asked.” So:

Fig. 10-4
swift
override init() { … bootstrapListener()} private func bootstrapListener() { for candidate in [7777, 7778, 7779] { do { try startListener(port: candidate) listenerPort = candidate globalLog.append(…"Listener auto-started at app launch on port \(candidate)…") return } catch { globalLog.append(.warn, …"port \(candidate) failed: …") } } globalLog.append(.error, "could not bind any of 7777/7778/7779: step 11 will retry")}

stepStartListener then becomes mostly a verifier: if listener != nil, wait a few hundred ms for listenerReady, mark step 11 PASS with detail (auto-started). The bind-loop survives as a fallback for the rare case where auto-start failed (e.g., another process already on 7777 at app launch but freed by step-11 time).

Cosmetically, the global log on a fresh launch now reads, before I do anything:

INFO NakedMirrorDebug v0.4.0 (build 1) startingINFO Listener auto-started at app launch on port 7777 (Device IP: <my-ip>)

…which means mirror-tail connects instantly the next time you open the app. No more “connection refused [1 consecutive]” while you remember to tap Run All.

§ 10.4

What I learned

  • String trimming and binary-buffer trimming are different problems. trimmingCharacters(in: .whitespacesAndNewlines) is for human-typed strings with stray spaces. When the wire format is a C string in a fixed-width buffer, the first thing to do is treat the bytes as bytes and find the null terminator. Convert to String only over the payload range. Going the other way (convert first, then try to strip) is a recipe for the exact bug I hit, because Unicode whitespace and C-string padding are different sets of characters, and the language’s standard trimmer only knows about one of them.
  • The streaming pipeline paid for itself in its first session. This specific bug had been latent since commit 2280a9c two weeks ago, when the WiFi sheet shipped and I first saw “Discovered networks (0)” with no log evidence. v0.2.0 added the byte-level instrumentation; v0.3.0 made it streamable; v0.4.0 closed the bug. Each version is an obvious follow-on once the previous one ships, but without the pipeline I’d still be guessing. The “you can’t iterate faster than your slowest feedback step” line gets quoted a lot for a reason.
  • Watch for diagnostic-only side effects. “Listener binds at step 11” was a perfectly reasonable design when the listener’s only consumer was the mirror itself (which only POSTs after a successful scan). The moment a second consumer appeared (a Mac script polling /logs), that ordering became wrong. Adding a feature can change the right answer for an unrelated bit of architecture.
§ 10.5

What I’m testing in the next build

One thing: tap WiFi Setup, tap Refresh, watch the streamed log. Expect to see twelve clean parsed N network(s) lines, no parse failed warnings, twelve rows in the SSID picker. With <my-home-wifi> and 1140842 at 92% and 85% signal respectively, I have good candidates to actually connect to.

Once that works, the real next test is what happens when I pick a network, enter the password, and tap Connect, which exercises the wifiSetSSID write path and the wifiConnectionStatus poll loop. That code is already instrumented (v0.2.0). One step at a time.

§ 10.6
§ 10.7
Agent prompts Procedure 10-A as prompts for a coding agent · 4 prompts

Paste into Claude Code or a similar agent, one at a time, from the root of your iOS project. Each prompt maps to the step with the same number above.

1
Log raw/decoded/parsed views side by side
In the WiFi SSID parsing path (discoverWifiNetworks), log three things per entry: the raw byte hex dump, the decoded string (before any trimming), and the parse success/failure with reason. Format as "SSID[i] (N bytes) raw=[...] str=\"...\"" followed by a WARN line on failure, so a parse bug is visible from the three views without re-running the hardware.
2
Strip null padding before String conversion
In the SSID-buffer parser, replace `String(data: ssidData, encoding: .utf8) ?? ""` followed by `.trimmingCharacters(in: .whitespacesAndNewlines)` with byte-level null stripping first: `let cString = Data(ssidData.prefix(while: { $0 != 0 })); let raw = String(data: cString, encoding: .utf8) ?? ""`. Then split `raw` on "/" as before. Verify parts[3] (the signal field) no longer contains trailing null bytes by re-running against a captured 81-byte SSID buffer.
3
Auto-start the HTTP listener at launch
Move the HTTP listener bind out of stepStartListener and into a `bootstrapListener()` called from the engine's init(). Keep stepStartListener as a verifier: if a listener is already running, wait briefly for listenerReady and mark the step PASS with detail "(auto-started)"; otherwise fall back to binding there.
4
Try a port range and log the winner
In bootstrapListener(), loop over a small candidate port list (e.g. [7777, 7778, 7779]), attempt startListener(port:) for each, and on the first success set listenerPort and log "Listener auto-started at app launch on port {port} (Device IP: {ip})". If all candidates fail, log an error noting that stepStartListener will retry, and leave the existing bind-loop in stepStartListener as the fallback path.