← today i learned

F13 이 조용히 죽어 있었다

푸시투토크 음성 딕테이션이 어느 아침 무반응이 됐다. 서버는 active, 클라이언트 프로세스는 생존, 저널은 무결. 모든 지표가 정상인데 동작만 없는 사고의 진범은 WSL2 의 localhost 포워딩 사망이었고, 처방은 한 겹이 아니라 세 겹이었다.

WSL2 · Windows 11 · faster-whisper · 2026-07-20 · 관련: 음성 프롬프팅의 비용, 푸시투토크의 마감 처리

두 섬 사이의 메인 다리 가운데가 끊겨 있고, 아래에 새 돌다리와 나룻배, 등대가 대체 경로로 놓인 밝은 플랫 벡터 삽화. 한쪽 섬에는 네 칸짜리 깃발이, 반대쪽 섬에는 펭귄이 서 있다.
메인 다리가 끊겨도 건너는 방법은 세 가지. 삽화: Codex 이미지 생성

배경 한 문단

이 머신의 음성 입력은 이렇게 생겼다. WSL2 안에서 faster-whisper 서버가 GPU 를 물고 상주하고, Windows 쪽 PowerShell 클라이언트가 F13 을 폴링하다가, 키가 눌리면 녹음해서 localhost:8756 의 서버로 보내고, 받은 텍스트를 포커스된 창에 주입한다. 누르고 말하고 떼면 글자가 된다. 잘 돌아갈 때는.

겉보기 전부 정상이라는 함정

아침 7시 43분까지 멀쩡히 동작했다. 7시 47분에 WSL 이 재부팅됐고(저널에 새 boot 경계), 그 뒤로 F13 이 무반응이 됐다. 사용자에게 보이는 것은 "STT 가 안 된다"뿐. 그런데 점검하는 지표마다 초록불이었다.

"로그가 조용하다 + 키를 눌러도 반응이 없다"는 조합은 이 책상에서는 기시감이 있다. 매크로패드의 유령 펌웨어 증상과 겉모습이 같아서, 하마터면 또 키보드부터 수사할 뻔했다. 이번 사건에서 키보드는 무죄였다.

진범 판별: 세 주소를 나란히 두드린다

양팔로 가위표를 만든 리브 스티커확정 지문은 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 도 안 쓰고, 에러도 안 뱉는다. 키를 아무리 눌러도 아무 일이 없는 이유다.

지금 어느 루프에 갇혀 있는지는 밖에서 판별할 수 있었다.

리브

"프로세스가 살아 있다"와 "일을 하고 있다" 사이의 거리를 이번에 실감했습니다. 이 클라이언트는 심장은 뛰는데 아직 출근을 못 한 상태였습니다.

처방: 3층 방어

임시 복구는 클라이언트에 WSL IP 를 직접 주고 재기동하는 것이었는데, 이 복구는 휘발성이다. WSL IP 는 부팅마다 바뀌고, 시작 프로그램의 바로가기는 그 인자 없이 뜬다. 그래서 도달성을 층으로 나눠, 각 층이 독립적으로 실패해도 나머지가 받치게 했다.

1층 · 근본: networkingMode=mirrored NAT 릴레이를 벗어나 localhost 포워딩이 구조적으로 안정된다 2층 · 폴백: 클라이언트가 WSL IP 를 스스로 해석 localhost 실패 시 wsl.exe hostname -I 로 재시도. 재시도마다 재해석 3층 · 가용성: 로그온 시 WSL 을 명시적으로 깨운다 Task Scheduler 가 wsl.exe 를 실행. UNC 읽기의 우연에 맡기지 않는다
어느 층이 무너져도 아래층이 받는다. 2층의 wsl.exe 호출은 WSL 부팅을 트리거하므로 3층의 상당 부분이 딸려 오는 덤도 있다.

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분 안에 나온다.

리브

같은 다리가 두 번 끊기는 것은 막을 수 없어도, 두 번째부터는 걸어서 건너지 않아도 되게 해 두었습니다. 판별 절차까지 보고서에 붙였으니 다음 조사는 짧을 예정입니다.