"이게 지금 잘못된 경보라고." ("This one, right now, is a false alarm.") — Dad, Málaga, 2026-10-19, 20:11 CEST
One Word, Several Causes
The night paragraph reading came apart (Track 4's "Whole Again: Zero Breaks Before a Fast Start" tells the reading's side), build 156 on hotel Wi-Fi with AirPods gave Dad a whole evening of symptoms. Replies broke up. The microphone took long seconds to open. One reply spoke a single sentence and stopped. And the phone said "Reconnecting — The engine did not answer in time" while the engine, on the Mac that was then the Pippa machine, answered every call it received. Only a reboot, or killing the app, helped. Out loud, all of it has one name: 끊긴다, "it breaks up".
At 19:59 CEST Dad refused to treat that word as a diagnosis. His frame, as the design doc records it: what he hears as "끊긴다" is one symptom over several causes (the network, synthesis, the pipeline, accumulated state, Bluetooth), and each is to be measured on its own, against the stable home baseline from before the Málaga changes (todo #536). At 20:06 he closed one door on purpose: iOS and the AirPods are stable and used the way Apple advertises them, the AirPods microphone included, so every fix would be in our code. At 20:11 he pointed at the one symptom that described nothing he could hear, the "Reconnecting" line itself, in the words at the top of this page.
Six Connections and a Burst
Since todo #376 every call from the phone had ridden one kept-alive pool, the shared kit's HTTPConnectionPool, because from Seville a fresh TCP connection per request had cost a 285 ms round trip before a byte was sent. The pool opens at most six connections to a host. Under paragraph reading one reply could fill all six: at 20:07, when a reply's tool ended, the phone fired four take prepares, four take streams and the 1.96 MB microphone-rate copy of Dad's recording in the same second, beside the turn's own stream. The turn progress check, which asks again 3 seconds after each answer with the kit's quick policy (a 5 second request timeout), queued behind the audio, timed out, and one miss was enough to print "Reconnecting". The engine was healthy all day, on Wi-Fi and LTE alike.
Then it got worse. Once that line appeared, nothing recovered: the pool's connections stayed wedged until Dad killed the app at 20:16, and only a relaunch made new ones. A status line built to tell him the phone had lost Pippa was reading the phone's own congestion as Pippa's absence.
Traffic by Kind
The first fix stopped audio and control from sharing a line. Build 157 (cbe99bd0) gave bulk transfers a pool of their own: her takes, speech, uploads, downloads and video. Reads, polls and the turn no longer wait behind a byte of audio. Build 157 then showed the next shape: six minutes after launch the remaining pool's kept-alive connection went stale, and a dictated turn sat 49 seconds on the phone before its POST while the line said only "not sent for 37s". So build 158 (e9fbe699) gives the turn's stream a connection of its own on every turn (HTTPTransports.lineStream): one handshake, well under a second from Málaga, the price of never waiting on a dead connection.
The voice-mode review of 2026-10-22 (f58e7fea) finished the split from the other side. The listening grant and a take's ticket (/api/tts/prepare) had ridden the bulk pool with 120 to 180 second timeouts, so a stale bulk connection held her first unit 29 seconds while the stall check, asking the healthy control pool, said her voice was still being made. Short control calls ride the control pool now, with 20 and 30 second limits (/api/tts/ensure, which may make a whole take before it answers, gets a minute). Only byte streams and uploads stay on bulk, and a quiet "Getting her voice ready" probes the bulk pool itself, because the control pool answering says nothing about a stale bulk connection. The excerpt below is where each kind of call is assigned its pool.
A Pool That Heals, and a Heal That Fed Itself
A split pool still meets stale connections, so the pool learned to heal (cwkSiblingKit f655776). After buffered replies time out in a row, it opens a fresh session for every later call, lets the calls in flight finish on the old one, logs the reset and counts it as pool_reset. It began at three timeouts in a row, at most once every 30 seconds. Build 158 made it two in a row, at most once every 20 seconds (21507b4), because three had cost Dad that 49 second wait: the healing was slower than the connection it healed.
The review then found the heal feeding itself. The pool's router matched each callback to its caller by taskIdentifier, a number unique only within one session. After a reset the old session's calls were still in flight on the same router, so the first call on the new session shared its number with the first on the old one. The newer route replaced the older, the old reply's body went to nobody, its caller timed out, and that timeout counted toward the next heal. Live, one app run logged up to five pool_reset. The router is keyed by the task object now (cwkSiblingKit e50efa6), and byte and line streams report their timeouts and lost connections, so the bulk pool heals too. A self-healing mechanism deserves the same suspicion as the fault it heals: when the heal can produce the symptom it counts, it is a loop, not a cure.
"Reconnecting" Is a Verdict
Dad's complaint was about the words too: "리커넥팅이라고만 돼 있으니까" ("all it says is Reconnecting"). A line that fires on one missed check and names nothing gives him nothing to act on. Since build 157 "Reconnecting" is a verdict (AppModel.reconnecting, excerpted below): two checks in a row failing, failing for at least 6 seconds, with nothing heard from the engine for 15. Her streamed words count as hearing from it, and since the review so do the stage board's answers. When the verdict does fall, the line names the failing check and how long it has failed.
The waits around it say what they wait on, too. A turn still on the phone counts the other calls open on the shared connection, from the kit's pool_reply counter: "not sent for 20s (4 other calls open)". The microphone opens on the phone first, at the 16 kHz every grant carries, and only then asks Pippa for the listening grant; what Dad says meanwhile is kept and goes in first, and "Opening the microphone…" names what it is waiting for (the audio session, Pippa with its seconds, the listening socket). The web caught up in the same review: words said while a listen connects are kept, and the grant is asked beside opening the microphone.
On 2026-10-23 the web's lost-link banner learned the same honesty (46d86006). A restart's cut had read as the same error a dropped tailnet throws, so the banner told Dad to check Tailscale while the engine was only restarting. Now it says "Connection lost. Checking why…" first, then "Pippa is restarting, back in a few seconds." when the engine's twin answers 502, 503 or 504 for an engine that is down, or "This device is offline.", or "Pippa cannot be reached from this device." Only the last carries the Tailscale advice. Dad's words for what it fixed: "멍 때리거나 엉터리 안내하는 UI가 죄악인 거야." ("A UI that zones out or gives bogus guidance is the real sin.")
One Measurement per Cause
Dad's frame became instrumentation. Every voice turn's report (the timeline from this track's "The Voice Lane: Measured From Málaga") now carries one reading for each cause he named, so a break can be attributed instead of argued about:
| cause | what is measured | where it shows |
|---|---|---|
| the network | underruns= per unit (count, silent seconds, longest, the cushion, the player) beside link= | the [voice-turn] line |
| synthesis | first_byte, total, rate against real time, need= (the cushion this take alone needed), beside= (takes made at the same time) | one [tts-take] line per take on the Pippa machine |
| the pipeline and accumulated state | health: device and app uptime, turns, audio-session activations, memory, and live= counts of players, microphone engines, audio queues, takes, open byte streams, replies in flight, and the pool resets since launch | the [voice-turn] line |
| Bluetooth | route= with inputs, rate, category and mode, and route_change@<s>=<reason>:<outputs> for every change while she reads | the [voice-turn] line |
| the wait itself | each gap before a unit with its lead (0.56a(-0.30): queued 0.3 s after the last unit ended, so her words were late), and listen_open:grant=…,session=…,total=… | the [voice-turn] line |
Each reading comes with the rule that reads it. A stutter on a turn with underruns=0 and +bluetooth is the radio between the phone and the AirPods, not the stream. A take whose need= sits above the phone's cushion broke on synthesis; one under it broke on the network. A live= count of objects that grows from turn to turn is our process holding something it should have let go. One entry is not a live count: pool_reset is never counted down, so it is a tally of heals since launch, and its reading is whether it moved since the last turn. And the next device check costs one swipe: when it degrades again, force-quit Pippa before rebooting. Clean after that means our process held it, and health: shows what grew; still broken means the phone's own audio state, which only the reboot resets.
The doc wrote down what "done" means for this work before anyone could claim it: a day of dictated turns on Wi-Fi and LTE with speech_end_to_post within the end-of-speech window plus the upload, pool_reset at 0, and no "not sent" line. The breaks themselves are Track 4's story: after the Pippa machine moved, 103 phone voice turns, 90 of them over cellular, each logged underruns=0.