Squirreleye LLC Squirreleye Service Notes Naked Labs Mirror · Revival Series Index Tools About
02
Section 02 · follows 01

The 10-second timeout that fired in 2

Written
2026-05-03
Commit
3bb9d14
Log
nakedmirror-debug-20260503-184157.txt

In the last post I shortened the upload URL I was writing to the mirror’s cloudToken characteristic, hit Run All again, and expected to finally see step 15 trigger a real scan. Step 15 failed. Differently this time, more interestingly, and with a small surprise that turned out to invalidate the previous post’s headline diagnosis.

Three things were going on at once. Working through them one by one is a useful walkthrough of how to read a BLE diagnostic log when nothing is what it claims to be.

§ 2.1

The puzzle

The relevant slice of the log:

Fig. 2-1Step 15 log slice
00:41:42.976 Writing uploadURL (40 bytes / MTU budget 512) to cloudToken: http://<my-ip>:7777/upload/d5530200:41:43.689 Token written. Writing Command.initDeviceForScan (0x32) to scannerAction00:41:43.752 NOTIFY scanError = 1100:41:45.644 ERR initDeviceForScan action write failed: The operation couldn't be completed. (NSURLErrorDomain error -1001.)

The error text is the localized form of URLError(.timedOut). The writeChar path in my diagnostic looks like this:

Fig. 2-2writeChar, before the fix
swift
private func writeChar(_ char: CBCharacteristic, data: Data) async -> Error? { return await withCheckedContinuation { (cont: CheckedContinuation<Error?, Never>) in pendingWriteContinuation = cont peripheral?.writeValue(data, for: char, type: .withResponse) Task { try? await Task.sleep(nanoseconds: 10_000_000_000) if let c = self.pendingWriteContinuation { self.pendingWriteContinuation = nil c.resume(returning: URLError(.timedOut)) } } }}

So the URLError(.timedOut) is something I made. It only ever fires from inside that 10-second Task.sleep. The action write started at 43.689 and the error landed at 45.644, 1.95 seconds later. The 10-second sleep can’t possibly have elapsed.

What did?

ErratumCorrects the MTU diagnosis in 01.

Surprise 1: the MTU theory was wrong

Before I get to the timeout puzzle, look at the first line of the log slice:

Writing uploadURL (40 bytes / MTU budget 512) to cloudToken

That MTU budget 512 is a new diagnostic line I’d added in step 4, straight from peripheral.maximumWriteValueLength(for: .withResponse). The previous post had assumed the mirror was tied to a tight ATT MTU. With a 512-byte budget, that theory falls apart.

CBATTError.invalidAttributeValueLength doesn’t only come from iOS’ local transport check. The same error code comes back from a peripheral that rejects the write at the application layer, and the mirror’s cloudToken characteristic does exactly that. Try to write more than ~40-50 bytes and the firmware says no, regardless of how much MTU room you have at the radio.

So the previous post had the right fix (use a shorter scan ID) for the wrong reason. Worth knowing for next time you stare at this error: it can come from either side, and “I just have to negotiate a bigger MTU” might not help.

That’s correction one. The other two are bigger.

§ 2.3

Surprise 2: the scan trigger actually worked

Look at the line right after the action write:

00:41:43.752 NOTIFY scanError = 11

scanError byte 11, decoded against the APK’s ScanError enum:

INVALID(0), NO_ERROR(1), SCALE_NOT_DETECTED(2), SCALE_NOT_LEVEL(3),MIRROR_NOT_LEVEL(4), PROCESSOR_BUSY(5), INTERNAL_ERROR(6), ...SCALE_ALIGNMENT(11), ...

SCALE_ALIGNMENT. The mirror is saying “I tried to start scanning. The scale isn’t where I expect it to be.”

That notification fires 63 milliseconds after I wrote 0x32 to the action multiplexer. So the mirror received the action byte, decoded it as initDeviceForScan, started its scan state machine, took a fast look at the scale’s physical position, decided it wasn’t aligned, and emitted the error. That is an entire successful round-trip of the protocol path I’ve been working toward for weeks.

I noticed this independently too: a red flashing ring this time, a different visual indicator from the blue pairing-style flash I’d seen before. Red is the mirror’s “I can’t do what you asked” indicator. The fact that the visual changed in response to my action byte is hardware-level confirmation that the mirror heard me.

So the diagnosis “step 15 failed” is wrong in two ways now. The protocol succeeded, the mirror produced a meaningful, decodable, application-level error, and my diagnostic still claimed the underlying BLE write itself had timed out. Why?

Root causeThe bug was in the diagnostic, not the mirror.

Surprise 3: the bug was in my diagnostic

This is the actual cause of the bogus error.

writeChar (and its sibling readChar) used a buggy timeout pattern: every call kicks off an unstructured Task that sleeps for 10 seconds and then checks if there’s still a pending continuation to resume with URLError(.timedOut). Nothing cancels that sleep. The Task runs to completion regardless of whether the write resolved cleanly via the BLE delegate.

That’s fine if calls are spaced more than 10 seconds apart. They aren’t.

Step 14 (the wifi-list flow) starts with a write of 0x31 to the same scannerAction characteristic, at 00:41:35.200. That call’s timeout is scheduled to fire at roughly 00:41:45.200. Step 14 then reads wifiNumNetworks and loops 17 reads of wifiNetworkSSIDEnum, every one of those scheduling its own 10-second timeout, before completing at 00:41:36.404.

Then I look at the dialog, tap “Confirm Scan” 6 seconds later, and step 15 runs. The cloudToken write at 42.976 succeeds, sets pendingWriteContinuation to a fresh continuation, schedules its own timeout for 52.976, and resolves cleanly via the delegate at 43.689. Now pendingWriteContinuation is briefly nil. Then the action write starts immediately after and sets pendingWriteContinuation to a new fresh continuation, scheduled for 53.689.

Here’s the race, drawn out:

Fig. 2-3The stale-timer race
sequenceDiagram
    participant S14 as Step 14 (0x31 write)
    participant Slot as pendingWriteContinuation
    participant S15 as Step 15 (action write)
    S14->>Slot: set continuation, schedule timeout for +10s
    Note over Slot: step 14 resolves cleanly via delegate
    S15->>Slot: set NEW continuation (action write in flight)
    Note over S14: step 14's 10s timer finally wakes at ~45.2
    S14->>Slot: "still pending?" yes, resume with timedOut
    Slot-->>S15: receives bogus URLError(.timedOut)
    Note over S15: real delegate callback arrives, finds slot nil, no-ops

At approximately 45.2, give or take Task scheduling drift, the timeout from step 14’s original 0x31 write finally wakes up. It does its check:

if let c = self.pendingWriteContinuation { self.pendingWriteContinuation = nil c.resume(returning: URLError(.timedOut))}

pendingWriteContinuation is non-nil. It belongs to step 15’s action write, which is still in flight, perfectly healthy, waiting for its own delegate callback that’s about to come. The stale timer doesn’t know any of that. It picks up the slot, resolves it with URLError(.timedOut), sets the slot to nil, and exits.

Step 15 awaits the result. Sees URLError(.timedOut). Logs "action write failed: timeout". Marks step 15 failed.

The actual delegate callback for the action write probably fired correctly a moment later, found pendingWriteContinuation was nil, and silently no-op’d. I never even saw it land.

A continuation slot reused as a single mutable global, with timers that close over an implicit “is this still mine?” check, is the same bug pattern as a use-after-free in C. Whoever last wrote the slot wins; whoever else is referencing it loses.

§ 2.5

The fix

Make the timeout Task something I can cancel, hold onto a reference, and cancel it the moment the delegate actually resolves the operation:

Fig. 2-4writeChar and its delegate, after the fix
swiftcommit 3bb9d14
private var pendingWriteContinuation: CheckedContinuation<Error?, Never>?private var pendingWriteTimeoutTask: Task<Void, Never>? private func writeChar(_ char: CBCharacteristic, data: Data) async -> Error? { return await withCheckedContinuation { (cont: ...) in pendingWriteTimeoutTask?.cancel() // defensive: kill any in-flight pendingWriteTimeoutTask = nil // ... .withoutResponse fast path elided ... pendingWriteContinuation = cont peripheral?.writeValue(data, for: char, type: .withResponse) pendingWriteTimeoutTask = Task { try? await Task.sleep(nanoseconds: 10_000_000_000) guard !Task.isCancelled else { return } if let c = self.pendingWriteContinuation { self.pendingWriteContinuation = nil self.pendingWriteTimeoutTask = nil c.resume(returning: URLError(.timedOut)) } } }} nonisolated func peripheral(_ p: CBPeripheral, didWriteValueFor c: CBCharacteristic, error: Error?) { Task { @MainActor in pendingWriteTimeoutTask?.cancel() pendingWriteTimeoutTask = nil if let cont = pendingWriteContinuation { pendingWriteContinuation = nil cont.resume(returning: error) } }}

Same change to readChar and didUpdateValueFor. The guard !Task.isCancelled else { return } after the sleep means a cancelled timer becomes a no-op when it eventually wakes up. try? await Task.sleep swallows the cancellation as a thrown error, but the surrounding code still runs, so I have to check explicitly.

The other one-shot continuations on the engine (bleStateContinuation, connectContinuation, servicesContinuation, charsContinuation, notifyStateContinuation) have the same pattern but each is used at most once per Run All sequence, so they can’t poison anything. I left them alone for now.

§ 2.6

Bonus: actually decode scanError

Since I now know the mirror will send me decodable scanError bytes when something goes wrong, I wired the 04285A29-… characteristic’s notifications through a new scanErrorNotifCallback in step 15, parallel to the existing scanState callback. Added a ScanError enum to MirrorUUIDs.swift matching the APK’s Java enum byte-for-byte. Step 15’s failure detail now includes the last scanError seen, so the next time the mirror complains about scaleAlignment I get a log line like:

scanError → .scaleAlignment (0xb)scanState → .errorMirror reached scanState=.error with last scanError = .scaleAlignment

instead of the misleading “action write failed: timeout.”

§ 2.7

What I’m testing in the next build

Three things should change in the next log:

  1. The action write to scannerAction should report success. With the stale timer killed, the delegate’s resolve will be the only thing that closes out the continuation. I expect “Token written. Writing Command.initDeviceForScan (0x32) to scannerAction” followed by nothing, until the mirror actually does something.

  2. scanState notifications should arrive in sequence. The real APK enum’s progression is INVALID(0) → ALIGN_SCALE(1) → WARMING_UP(2) → WAITING_FOR_WEIGHT(3) → COUNT_DOWN(4) → PRE_CHECK(5) → SCANNING(6) → POST_CHECK(7) → COMPLETE(8). I don’t expect to reach COMPLETE. The scale is currently attached to the mirror via USB-C for charging, so it’s almost certainly still misaligned for scanning purposes. But I should see the mirror at least begin transitioning. Anything beyond INVALID is progress.

  3. A scanError = .scaleAlignment notification should land cleanly. Not as a fake timeout. The diagnostic’s failure detail will name the actual error.

If I see those three things, the BLE protocol path is fully verified end-to-end. The only remaining work to actually trigger a successful scan is physical: get the scale off the USB-C cable, put it on the floor in the mirror’s field of view, get on it. That’s not a debugging problem anymore, it’s a user manual problem.

If the mirror reaches COMPLETE and POSTs to my local listener, the next thing I find out is the architectural question I’ve been carrying since day one: does it send a finished result ZIP (a PLY mesh + measurements JSON), or does it dump raw depth data and expect the phone to do the body reconstruction? One observation answers it.

§ 2.8

Takeaways

  • Cryptic Foundation errors in your own code might be your own fault. URLError(.timedOut) showing up before your timer should have fired is a hint that someone else’s timer is firing, most often a stale one. Continuation slots used as mutable globals are the classic pattern.
  • Cancel your timers when the work resolves. Unstructured Task is fine for fire-and-forget but if it’s gating something, give it a handle and cancel it on success. Task.cancel() plus guard !Task.isCancelled after the sleep is a five-line idiom that prevents this entire class of bug.
  • CBATTError.invalidAttributeValueLength is two errors with the same code. Local rejection (you exceeded MTU) and remote rejection (peripheral rejected the value at the application layer) surface the same way. If your peripheral.maximumWriteValueLength(.withResponse) is comfortably above your payload, look at the peripheral’s per-characteristic limits.
  • Read your own log. The line NOTIFY scanError = 11 was sitting in the log file the entire time, telling me the mirror had heard me and tried to scan. I was so focused on the failed write that I almost missed the protocol-level success three lines above.
§ 2.9
§ 2.10
Agent prompts Procedure 2-A as prompts for a coding agent · 5 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
Cancelable timeout handles
In the BLE engine, find every read/write helper (writeChar, readChar, and any others) that schedules its timeout as a fire-and-forget `Task { try? await Task.sleep(...) }`. Store each timeout Task in a property instead, e.g. `pendingWriteTimeoutTask: Task<Void, Never>?` and `pendingReadTimeoutTask`. Before scheduling a new timeout, cancel and nil the previous one. Keep the existing timeout durations. Show me the diff before moving on.
2
Cancel in the delegate
In `peripheral(_:didWriteValueFor:error:)` and `peripheral(_:didUpdateValueFor:error:)`, cancel and nil the matching timeout Task before resuming the pending continuation. In each timeout Task, add `guard !Task.isCancelled else { return }` directly after the sleep, because `try?` swallows the cancellation error. Add a test that issues two writes less than the timeout apart and asserts the second never receives `URLError(.timedOut)` from the first write's timer.
3
Owner check on continuation slots
Audit every continuation slot on the BLE engine (`pending*Continuation`, plus one-shots like `connectContinuation` and `servicesContinuation`). For any slot that overlapping operations can reuse, add an owner check: give each operation an ID, capture it in its timeout closure, and only resume if the slot still holds that ID. List the one-shot slots you left unchanged and why they are safe.
4
Decode scanError
Add a `ScanError: UInt8` enum to `MirrorUUIDs.swift`, copied byte-for-byte from the APK's `ScanError` Java enum (INVALID=0, NO_ERROR=1, SCALE_NOT_DETECTED=2, ... SCALE_ALIGNMENT=11, ...). Do not invent cases; read them from the decompiled source. In the scan step, subscribe to notifications on the scanError characteristic (`04285A29-…`) with a callback parallel to the scanState one. Log each value as `scanError → .caseName (0xNN)` and include the last scanError in the step's failure detail.
5
Tell local and remote length errors apart
Wherever a write can fail with `CBATTError.invalidAttributeValueLength`, log the payload length next to `peripheral.maximumWriteValueLength(for: .withResponse)`. If the payload fits inside that budget, label the failure in the log as a peripheral-side (application-layer) rejection, not an MTU problem.