F13 이 조용히 죽어 있었다
푸시투토크 음성 딕테이션이 어느 아침 무반응이 됐다. 서버는 active, 클라이언트 프로세스는 생존, 저널은 무결. 모든 지표가 정상인데 동작만 없는 사고의 진범은 WSL2 의 localhost 포워딩 사망이었고, 처방은 한 겹이 아니라 세 겹이었다.
배경 한 문단#
이 머신의 음성 입력은 이렇게 생겼다. WSL2 안에서 faster-whisper 서버가 GPU 를 물고 상주하고, Windows 쪽 PowerShell 클라이언트가 F13 을 폴링하다가, 키가 눌리면 녹음해서 localhost:8756 의 서버로 보내고, 받은 텍스트를 포커스된 창에 주입한다. 누르고 말하고 떼면 글자가 된다. 잘 돌아갈 때는.
겉보기 전부 정상이라는 함정#
아침 7시 43분까지 멀쩡히 동작했다. 7시 47분에 WSL 이 재부팅됐고(저널에 새 boot 경계), 그 뒤로 F13 이 무반응이 됐다. 사용자에게 보이는 것은 "STT 가 안 된다"뿐. 그런데 점검하는 지표마다 초록불이었다.
- 서버 유닛은
active. 모델 로드까지 끝내고0.0.0.0:8756에서 LISTEN 중. - 클라이언트 프로세스도 살아 있음. 죽지 않았고 에러도 없다.
- 서버 저널에는 7시 47분 이후 요청이 하나도 없다. 조용하다.
"로그가 조용하다 + 키를 눌러도 반응이 없다"는 조합은 이 책상에서는 기시감이 있다. 매크로패드의 유령 펌웨어 증상과 겉모습이 같아서, 하마터면 또 키보드부터 수사할 뻔했다. 이번 사건에서 키보드는 무죄였다.
진범 판별: 세 주소를 나란히 두드린다#
확정 지문은 Windows 쪽에서 같은 서버를 세 주소로 두드려 보는 것이었다.
| 주소 | 결과 |
|---|---|
http://localhost:8756/health | 실패 (연결 불가) |
http://127.0.0.1:8756/health | 실패 |
http://172.28.x.x:8756/health (WSL IP) | 성공 {"status":"ok"} |
서버는 0.0.0.0 바인드라 서버 설정 문제가 아니고, netsh interface portproxy 도 비어 있어 포트프록시 잔재도 아니다. 남는 결론은 하나. WSL2 가 Windows 의 localhost 를 게스트로 이어 주는 포워딩 자체가 이번 WSL 재부팅에서 살아나지 못했다. IPv6 선호 문제도 아니다. 127.0.0.1 까지 같이 죽었으니까.
왜 클라이언트는 비명도 없이 죽어 있었나#
클라이언트의 health 대기 루프는 예산 없는 무한 재시도다. 이것 자체는 의도된 설계다(전에는 5분 예산이 서버 기동보다 먼저 소진돼 클라이언트가 죽는 사고가 있었다). 부작용은 서버에 영원히 도달하지 못할 때 드러난다. 클라이언트는 F13 폴링 루프까지 내려가지 못한 채 그 위층에서 맴돌고, 프로세스는 살아 있고, CPU 도 안 쓰고, 에러도 안 뱉는다. 키를 아무리 눌러도 아무 일이 없는 이유다.
지금 어느 루프에 갇혀 있는지는 밖에서 판별할 수 있었다.
Get-NetTCPConnection으로 보면::1:8756에 SynSent 로 고착돼 있다. 접속 시도가 영원히 절반에서 멈춰 있는 모습.- CPU 증가분. 4초 관찰 기준으로 health 대기 루프(5초 간격)는 약 0.00초, F13 폴링 루프(20ms 간격)는 약 0.02초를 쓴다. CPU 가 안 늘면 아직 F13 층에 도달하지 못한 것이다.
"프로세스가 살아 있다"와 "일을 하고 있다" 사이의 거리를 이번에 실감했습니다. 이 클라이언트는 심장은 뛰는데 아직 출근을 못 한 상태였습니다.
처방: 3층 방어#
임시 복구는 클라이언트에 WSL IP 를 직접 주고 재기동하는 것이었는데, 이 복구는 휘발성이다. WSL IP 는 부팅마다 바뀌고, 시작 프로그램의 바로가기는 그 인자 없이 뜬다. 그래서 도달성을 층으로 나눠, 각 층이 독립적으로 실패해도 나머지가 받치게 했다.
1층이 원인 제거다. 이 머신은 WSL 2.5.9.0 + Windows 11 이라 mirrored 네트워킹을 지원하고, NAT 릴레이에서 벗어나면 이번에 죽은 그 포워딩 경로 자체를 쓰지 않게 된다. 2층은 mirrored 가 깨지는 날을 대비한다. 핵심은 기동 시 1회가 아니라 재시도마다 IP 를 재해석하는 것. WSL 이 나중에 재시작돼 IP 가 바뀌면 1회 해석은 다시 갇힌다. 3층은 "WSL 이 안 떠 있으면 서버도 없다"를 막는다. 그동안은 \\wsl.localhost UNC 경로를 읽는 부수효과로 WSL 이 깨어나는 것에 의존했는데, 이 트리거는 불안정해서 부팅 40분 뒤에야 WSL 이 뜬 날도 있었다.
따라온 함정들#
세 층을 만드는 동안 층마다 함정이 하나씩 나왔다. 이쪽이 본론일지도 모른다.
PowerShell 5.1 의 네이티브 stderr 함정. $ErrorActionPreference='Stop' 아래에서 네이티브 명령이 stderr 에 한 줄이라도 뱉으면 NativeCommandError 로 터지는데, 2>$null 리다이렉트로도 막히지 않는다. wsl.exe 는 성공하면서도 경고를 뱉는 경우가 있어, 그런 머신에서는 2층 폴백이 통째로 조용히 죽는다. 경고를 뱉는 머신에서만 죽는 최악의 형태라, stderr 를 뱉으면서 정상 종료하는 가짜 wsl 을 PATH 앞에 놓고 실측으로 확정한 뒤 해당 구간만 예외 정책을 풀었다. 같은 함정이 그날 쓴 스크립트 세 개에 다 있었다.
Task Scheduler 의 "창 없이, 그러나 정직하게" 문제. wsl.exe 를 태스크 액션으로 직접 걸면 로그온마다 콘솔 창이 번쩍인다. conhost --headless 는 창은 없애지만 자식이 실패해도 항상 0 을 반환해 실패가 기록에서 사라진다. 권한 없는 계정이라 S4U 프린시펄도 못 쓴다. 결론은 wscript.exe + VBS 래퍼였다. 콘솔이 없고, 자식 종료 코드를 그대로 되돌려준다.
그 래퍼 파일은 반드시 Windows 파일시스템에 복사해 두고 그 사본을 가리켜야 한다. 저장소 원본은 WSL 안에 있어 UNC 로만 읽히는데, 그 UNC 읽기 자체가 WSL 기동을 요구한다. 정지한 WSL 을 깨우려는 태스크가 WSL 이 깨어 있어야 읽히는 파일에 의존하면 순환이다. 처음에 그렇게 짰다가 들통났다.
종결#
세 층을 커밋한 뒤 콜드 재부팅으로 검증했다. localhost 와 127.0.0.1 모두 응답하고, WSL 과 서버 유닛이 사용자 개입 없이 올라오고, F13 딕테이션이 바로 동작했다. 며칠을 관찰해도 재발이 없어 사건을 닫는다. 이번에 판별법이 정리된 것이 수확이다: 다음에 F13 이 조용해지면 세 주소를 두드리고, SynSent 와 CPU 증가분을 본다. 어느 층이 죽었는지 5분 안에 나온다.
같은 다리가 두 번 끊기는 것은 막을 수 없어도, 두 번째부터는 걸어서 건너지 않아도 되게 해 두었습니다. 판별 절차까지 보고서에 붙였으니 다음 조사는 짧을 예정입니다.