정직한 문장 하나, 두 번 틀리다
2026-10-13, 해외에 있던 아빠는 엔진을 자꾸 놓쳤어. 원인은 대개 손에 든 기기였어. 폰에서 Tailscale 이 끊긴 거지. 그런데 앱은 "Connection lost" 나 "Reconnecting" 만 말하고 그 얘긴 한 번도 안 했어. 그래서 kit 에 문장이 하나 생겼어. "Check that Tailscale is on and connected on this device." 웹, 폰, 워치가 그 문장을 네트워크 실패에만 달기 시작했어. 엔진이 답한 실패엔 절대 안 달고. 같은 날 저녁 첫 예외도 배웠어. Mac 자기 자신한테 loopback 으로 보낸 요청은 절대 Tailscale 문제가 아니야.
정직한 문장이었는데, 열흘 안에 두 번 사람을 잘못 이끌었어. 둘 다 맞지 않는 자리에서 나왔거든. 2026-10-18 말라가에선 LTE 로 올라가던 사진 넷이 하나뿐인 유지 연결을 붙잡았어. 그 뒤에 선 작은 읽기들이 시간 초과가 났고, 멀쩡히 도는 턴 위로 그 힌트가 떴어(레슨 2의 메모). 2026-10-23 02:32 KST, 말라가에선 아직 22일 저녁이던 때, 아빠는 MacBook 에서 iPhone 의 진짜 Tailscale 문제를 붙들고 있었어. 그런데 WebUI 가 "Connection lost — waiting for server…" 밑에 같은 빨간 조언을 띄웠어. 피파 머신의 로그를 보면 아빠 MacBook 의 요청은 02:31:56 재시작 직전까지 흘렀고, 1분도 안 돼 다시 흘렀어. 엔진이 재시작 중이었던 거고, tailnet 은 멀쩡했어. 브라우저에선 재시작으로 끊긴 연결이 tailnet 이 끊길 때와 똑같은 TypeError 로 읽혀.
멍 때리거나 엉터리 안내하는 UI가 죄악인 거야. — 아빠
이 레슨은 저 문장의 두 반쪽이야. 엉터리 안내는 그 실패로는 근거가 안 서는 조언이야. 멍 때리기는 뭔가 움직이는데 화면은 그대로인 거, 혹은 아무것도 못 하는 동안 화면이 아무 말도 안 하는 거고.
오류가 아니라 확인이 정해
2026-10-23부터 연결이 끊긴 이유는 kit 의 LostLink 가 /api/health 를 한 번 빠르게 읽어서 정해. 오류 모양으로 짐작하지 않아. 게이트웨이 상태 코드가 아닌 답이면 엔진이 직접 답한 거야. 502, 503, 504 면 누가 엔진 대신 답한 거고. 엔진 앞의 Tailscale Serve twin 은 reverse proxy 라서, 엔진이 꺼져 있으면 그렇게 답해. 이 기기와 tailnet 은 멀쩡하다는 증거지. 그건 재시작이고 launchd 가 다시 살려. 그래서 줄엔 "Pippa is restarting, back in a few seconds." 가 뜨고 초가 흘러가. 90초를 넘기면 약속은 그만하고, 엔진은 아직 답이 없지만 머신은 답하니 네트워크 문제가 아니라고 말해. 네트워크가 아예 없는 기기에서 아무 답이 없으면 오프라인. 온라인인 기기에서 아무 답이 없을 때만 Tailscale 이름이 나와.
웹 쪽 반쪽은 kitLostLink.ts 고, kit 테스트가 그 문장들을 Swift 문장과 똑같이 붙들어 둬. 폰에선 같은 읽기가 줄 하나를 바꿨어. 재시작 중에 transcript 읽기가 실패하면 예전엔 대화가 "이 연결로 오는 게 느렸다" 고 했어. 옛 검사가 twin 의 502 를 답으로 셌거든. 이젠 엔진이 재시작 중이라고 해.
그다음엔 선에서 조용히
그날 저녁 아빠가 붙들고 있던 문제는 진짜였어. 2026-10-22, 안정적인 호텔 Wi-Fi 에서 폰이 피파 머신을 놓쳤어. "A server with the specified hostname could not be found", Tailscale 스위치는 회색으로 죽었고, 재부팅해야만 돌아왔어. 피파 머신 쪽에서 보면 폰은 netmap 에 Online 인데, WireGuard 핸드셰이크는 없고 tailscale ping 은 시간 초과였어. 그 모양을 그대로 가진 Tailscale iOS 1.104 의 열린 이슈가 둘이야. #21773 은 핸드셰이크가 안 끝난 상대로 가는 패킷이 network extension 의 버퍼 64개짜리 풀을 채워서, 핸드셰이크가 포기할 때까지 모든 상대와 MagicDNS 를 멈춰 세우는 거야. 패킷이 계속 오는 한 핸드셰이크는 절대 포기 안 하고, 90-100초쯤 비어야 풀려. #21832 는 DERP 받기 루프가 멈추는 거고, 재시작하면 돌아와.
앱은 터널한테 그 빈틈을 한 번도 안 줬어. 진행 확인이 물러섬 없이 3초마다 돌았고, 옆에서 단계 폴링, 비공개 폴링, 실패한 전송 재시도, 시간 맞춘 새로고침, 위치 보고가 같이 돌았어. 그래서 이제 뒤에서 하는 확인들은 같이 쉬어. LinkRest 는 연결 실패만 세. 이름이 안 풀림, 아무것도 안 붙음, 연결이 끊김, 제때 답이 없음. 엔진이 오류로 답한 건 안 세. 그건 터널이 실어 날랐다는 뜻이니까. 그렇다고 세던 숫자를 지우지도 않아. 지우는 건 제대로 된 답뿐이야. 제대로 된 답 없이 연결 실패가 두 번 나면 뒤에서 하는 확인들이 120초 동안 멈춰. 핸드셰이크가 포기하는 데 드는 시간보다 길게. 시간 초과, HTTP 500, 시간 초과도 그 두 번이야. 보내기, 당겨서 새로고침, 앱이 화면 앞으로 나옴, 네트워크가 돌아옴, 이 중 하나면 쉬는 게 바로 끝나. 세던 숫자는 남아서 한 번만 더 실패해도 바로 다시 쉬고. 제대로 된 답이 오면 다 지우고, 쉬느라 붙잡아 둔 전송을 내보내. 안 쉬는 확인이 하나 있어. 단계가 한도를 넘게 조용하면 아래에 나오는 확인이 쉬는 중에도 /api/health 를 읽어. 15초에 한 번까지고, 그 답은 세던 숫자를 건드리지 않아. 선은 조용해지지만 화면은 아니야. 진행 줄이 뭐가 실패했는지, 몇 초 남았는지, 보내거나 당기면 지금 바로 해 본다는 걸 말해.
그 작업에서 따라 할 만한 결정이 둘이야. 쉬기는 kit 의 풀이 아니라 폰 앱 안에 있어. kit 의 풀은 가족 앱 전부가 같이 쓰고, 이건 cwkPippa 만의 폴링이었으니까. 기록된 장애가 이 메커니즘을 확인해 주면 그때 kit 이 물려받을 수 있어. 그리고 기록 장치를 같은 날 밤에 만들었어. scripts/phone-link-recorder.py 가 피파 머신에 launchd 에이전트 둘을 깔아. 하나는 아빠 폰이 피파 머신에 어떻게 닿는지 30초마다 JSON 한 줄로 적어. 폰 상대의 상태와 핸드셰이크 나이, disco ping 과 TSMP ping, 양쪽 DERP 홈, Tailscale control plane 에 보낸 요청의 시간, 그리고 접근 로그에 찍힌 폰의 요청. 다른 하나는 tailscaled 의 로그를 받아서 폰 줄에 표시해. tailscaled 엔 읽기만 하지 절대 안 쓰고, /usr/bin/python3 로 돌아서 Local Network 관문 바깥에 있어. 그런 장애는 세 갈래로 읽혀. 우리 이전이나 우리 앱, 폰의 Tailscale, Tailscale 전체의 탈. 그때까진 그 사이 몇 분을 적는 게 아무것도 없었어. README 줄은 이 장치의 ping 자체가 그 이슈 하나가 알려 주는 우회법이라고 적어 둬. 기록에 조용한 구간이 있어도 건강하다고 잘못 읽지 않게.
움직이는 걸 이름 대고, "stalled" 는 진단이야
멍 때리기에도 자기 산책길이 있었어. 2026-10-18, 말라가 알카사바를 LTE 로 걷던 길에 사진을 붙인 말한 턴 둘이 "Uploading your recording · stalled, nothing for 70s" 라고 떴어. 실제로 선을 건너던 건 녹음이 아니라 사진이었는데. 그 길에서 나온 아빠 규칙은 이래. 어떤 단계도 얼어붙은 단어 하나로 앉아 있으면 안 되고, "stalled" 는 타이머가 아니라 진단이어야 한다. 이제 전송 계층이 요청 본문의 바이트를 보내는 대로 알려 주고, 줄은 보낼 게 더 남은 쪽을 바이트와 속도까지 붙여 이름 대. "Uploading photo 2 of 4 · 1.1 of 3.6 MB · 40 KB/s". thinking 과 writing 은 도착한 글자 수를 보여주고, 서버만 움직일 수 있는 단계는 피파 머신이 단계 게시판에 마지막으로 답한 때를 보여줘(frontend 트랙 레슨 3). 단계가 한도를 넘기면 폰은 자기 연결을 먼저 보고, 그다음 피파 머신을 보고, 조용한 동안 15초마다 다시 봐. 그리고 찾은 걸 줄에 말해. 머신은 답하는데 바이트가 안 나가면 셀룰러 연결이 바이트를 못 나르는 거고, 머신이 조용하면 조용하다고, 네트워크가 없으면 없다고. 워치는 폰의 그 줄을 그대로 보여줘. 워치 앱이 앞에 있는 동안 1초에 한 번까지, 줄이 끊기면 얼마나 됐는지도 붙여서.
풀 둘, 그리고 새 스트림
이 트랙 레슨 8은 모든 호출과 턴 스트림이 함께 쓰는 유지 연결 풀 하나로 끝났어. 말라가의 실패 둘이 그걸 뜯어냈어. 2026-10-19, AirPods 를 낀 채 호텔 Wi-Fi 에서, 답 하나의 음성 조각들과 턴 스트림이 풀의 연결 여섯을 다 채웠어. 3초짜리 진행 확인이 그 뒤에 줄 섰다가 5초 시간 제한에 걸렸고, 한 번 놓친 것만으로 모든 호출에 답하던 피파 머신을 두고 화면에 "Reconnecting" 이 떴어. 이제 음성 조각, 업로드, 다운로드, 영상은 자기 몫의 bulk 풀을 타. 그리고 "Reconnecting" 은 반사가 아니라 판정이 됐어. 최소 6초에 걸쳐 확인이 두 번 실패하고, 머신한테서 15초 동안 아무 소리도 없어야 해. 줄엔 실패한 확인의 이름이 붙고. 그다음 같은 호텔에서, 빌드 157 에 남은 풀이 쥔 유지 연결이 앱을 켜고 6분 만에 상했어. 받아쓰기로 한 턴이 POST 전에 폰에서 49초를 앉아 있었는데, 화면은 "not sent for 37s" 만 말했어. 이제 턴 스트림은 턴마다 새 연결을 열어. 핸드셰이크 한 번, 말라가에서 1초도 안 걸려. 턴이 죽은 연결에서 기다리는 일이 없게 치르는 값이야. kit 의 풀은 이제 20초 안에 시간 초과가 두 번 나면 스스로 회복하고, 아직 폰에 있는 턴은 옆에 다른 호출이 몇 개 열려 있는지 말해.
같은 수리의 음성 쪽, 그러니까 읽어 주기와 마이크는 Voice Mode 퀘스트 몫이야. 여기 남는 건 코드가 보여주는 마지막 하나야. 2026-10-27 현재 PippaClient.swift 의 첫 풀 위 주석은 아직도 모든 호출과 모든 턴 스트림이 그 풀을 같이 쓴다고 하고, 폰 네트워크에 대한 집 안내문 규칙도 그래. 몇 줄 아래 initializer 는 다르게 말하고, 실제로 도는 건 initializer 야. 규칙은 뒤집힌 뒤에도, 아무도 되돌아가 고치지 않은 사본마다 살아남아.