[Economi] Moonlight 세션 종료 후 수신 루프 잔존 버그 — 확인 대화상자 자체의 이중 결함과 수정
초록
세션 재사용 시 Moonlight 내부에 수신 루프가 남아 CPU를 소비하는 결함의 전체 개정 이력(v1~v6). v1 최초 진단(해상도 오인 반증)부터 v2 재발 확인, v3 재현 경로·판정지표 확정과 자동 정리기 구현, v4 자동 종료 폐기와 사용자 확인 방식 전환, v6에서는 그 확인 대화상자 자체가 그래픽 세션 환경변수 경합으로 전혀 뜨지 못했던 이중 결함(환경변수 누락 + 오처리의 영속화)을 규명하고 수정했다.
[Economi] Moonlight 세션 종료 후 수신 루프 잔존 버그 — "해상도 문제"로 오인된 앱 내부 상태 손상
개요
Flatpak 전환(2026-09-02-economi-moonlight-flatpak-migration-plan) 이후 첫 장시간 스트리밍(7시간 48분)은 무사고로 끝났으나, 세션을 종료한 직후부터 Moonlight 내부에 좀비 수신 루프가 남아 10시간 동안 CPU 25.7%를 소비하며 로그 19MB를 쌓는 현상이 발견되었다. 사령관에게는 이것이 "해상도를 720p에서 올렸더니 접속이 안 된다"는 증상으로 나타났고, 실제 원인은 해상도와 무관했다. 증상의 인접성이 원인 추정을 어떻게 오도하는지, 그리고 로그 포렌식으로 이를 어떻게 반증했는지 기록한다.
증상
사령관 보고: "해상도가 너무 낮아서 720에서 올려서 접속하려는데 접속이 안 된다."
표면적으로는 해상도 변경이 원인처럼 보이는 전형적 구도다 — 설정을 바꿨고, 그 직후 접속이 실패했다.
로그 포렌식
1. 종료된 세션 자체는 완벽했다
종료 시점(앱 내부 시각 07:54:20)에 Moonlight가 출력한 세션 통계:
Incoming frame rate from network : 30.04 FPS
Decoding frame rate : 30.04 FPS
Rendering frame rate : 30.03 FPS
Average network latency : 12 ms (variance: 3 ms)
Average decoding time : 0.51 ms
Frames dropped by network : 0.01%
디코딩에 0.51ms밖에 쓰지 않았다. 즉 해상도를 1080p로 올려도 성능 여유는 충분했고, "디코더가 못 버텨서 접속이 안 된다"는 가설은 이 수치와 모순된다.
2. 실패는 해상도 변경 이전에 이미 시작되어 있었다
종료 직후 로그가 곧바로 다음 3줄의 무한 반복에 들어갔다:
07:54:20 - Stopping audio stream...
07:54:20 - ENet wait interrupted
07:54:20 - Control stream connection failed: 4
07:54:20 - Video decode unit queue overflow ← 이 시점부터 무한 루프 시작
07:54:20 - IDR frame request sent
07:54:20 - Waiting for IDR frame
즉 사용자가 종료를 지시한 바로 그 순간, 디코더는 정리되었으나 비디오 패킷 수신·디코드 큐 관리 경로가 살아남아 "큐가 넘쳤다 → 키프레임을 다시 요청한다 → 기다린다"를 영원히 반복하기 시작했다. 해상도 변경은 그로부터 약 2분 뒤(07:56:00 UI 재진입)에야 이루어졌으므로, 시간 순서상 원인이 될 수 없었다.
3. 새 접속은 스트림 시작 단계에 도달조차 못했다
로그 전체에서 마지막 Video stream is ... 라인은 308번째 줄(전날 720p 세션)이 마지막이었다. 이후 10시간, 29만 줄에 이르는 로그 어디에도 새 스트림 초기화 기록이 없다. 새 접속 시도는 서버 조회(Server GFE version: 3.23.0.74)까지만 진행되고 Starting video stream에 도달하지 못했다 — 앱 내부 상태가 이미 손상되어 있었기 때문이다.
4. 정량 측정
로그 라인 수 : 290,481 → 290,496 (3초간 +15줄, 초당 약 5줄)
로그 파일 크기 : 19 MB
프로세스 누적 CPU : 2시간 35분 (10시간 가동 중)
현재 CPU 점유율 : 25.7%
wchan : futex_do_wait ← 겉보기엔 '정상 대기'
조치
pkill -x moonlight(SIGTERM) — 무시됨. 프로세스가 내부 루프에 갇혀 종료 신호를 처리하지 못했다.- 새 인스턴스를 먼저 기동(정상 동작 확인) 후, 구 프로세스를
kill -9로 강제 종료. - 구 로그 증가 정지 확인 → 정리. 시스템 상태(gnome-shell D-Bus, XWayland) 정상 유지 확인.
재시작 후 새 인스턴스는 CPU 4.7%, 로그 증가 없음(유휴)으로 정상 복귀했다.
시사점
1. 증상의 시간적 인접성은 인과를 의미하지 않는다
"설정을 바꿨더니 안 된다"는 보고는 자연스럽지만, 이번 사례에서 실패는 설정 변경 2분 전에 이미 확정되어 있었다. 로그의 타임스탬프 순서를 되짚는 것만으로 이 오인을 즉시 반증할 수 있었다. 사용자 보고를 가설로만 받아들이고 독립 증거로 검증하는 절차가 필요하다.
2. 기존 watchdog은 이 유형을 감지하지 못한다
2026-09-02-economi-moonlight-hang-watchdog-and-isolated-testing에서 구현한 watchdog의 감지 조건은 wchan이 유휴 상태가 아니면서 CPU가 계속 증가하는 것이다. 그런데 이번 좀비 루프의 wchan은 futex_do_wait — 정상 대기 상태로 보고된다. 실제로는 25.7%의 CPU를 태우고 있는데도 스핀 시그니처에 걸리지 않는다.
즉 이것은 watchdog 설계가 상정하지 않은 새로운 실패 유형이다. 향후 보완한다면 wchan 판정에만 의존하지 말고, "세션 종료 후에도 CPU 점유율이 임계치 이상 지속" 또는 "로그 증가율 이상" 같은 보조 지표를 병행하는 것이 타당하다.
3. 다만 이 버그는 시스템을 위협하지 않는다
AppImage 시절의 실패는 XWayland·gnome-shell을 끌어내려 데스크톱 전체를 마비시켰다. 반면 이 버그는 앱 프로세스 내부에 갇혀 있고, 컴포지터와 디스플레이 서버는 10시간 내내 무손상이었다(gnome-shell PID·Xwayland PID 모두 교체 없음). Flatpak 전환의 핵심 성과 — "실패의 파급 범위를 앱 내부로 한정" — 가 이번 사례로 오히려 입증된 셈이다. 대가는 CPU 25.7% 낭비와 로그 19MB이며, 앱 재시작으로 완전 복구된다.
4. 실무 권고
Flatpak Moonlight 사용자는 스트리밍 세션 종료 후 앱을 완전히 종료하는 습관을 들이는 편이 안전하다. 종료 후 앱을 띄워둔 채 재접속을 시도하면 본 사례처럼 원인 파악이 어려운 실패를 겪을 수 있다. 재현성이 확인되면 상류(upstream) 보고 대상이다.
참고
- 선행 논문:
2026-09-02-economi-moonlight-flatpak-migration-plan(v3),2026-09-02-economi-moonlight-hang-watchdog-and-isolated-testing,2026-09-02-economi-xwayland-5th-recurrence-bang-prefix-limitation-lazy-xwayland - 실행 환경:
hb5u(Ubuntu 25.10, GNOME Shell 49.0, Wayland) · Moonlight Flatpak 6.1.0 (org.kde.Platform 6.11)
v2 추가: 재발 확인 (2026-09-04) — 재현성 입증과 탐지 지표의 수정
재발 경위
v1 작성 다음 날, 동일한 버그가 정확히 같은 형태로 재발했다. 사령관의 보고는 "접속이 안 되는데 Ctrl+Alt+Shift+Q로도 닫히지 않는다"였다.
프로세스를 확인하니 두 개가 공존하고 있었다:
PID 37842 경과 93,528초(약 26시간) CPU 24.1% 누적 CPU 06:17:13 wchan=futex_do_wait ← 좀비
PID 144207 경과 279초(약 4분) CPU 9.3% wchan=futex_do_wait ← 새 인스턴스
전날 세션을 종료한 뒤 앱을 띄워둔 채로 방치한 결과, 좀비 루프가 26시간 동안 6시간 17분어치의 CPU를 소비했고 로그는 30MB(439,541줄)까지 불어났다. 조치는 v1과 동일하게 kill -9였고, 즉시 정상화되었다.
이로써 이 버그는 1회성 이상 현상이 아니라 재현 가능한 결함으로 확정된다(2/2). 상류(upstream) 보고의 근거로 삼을 수 있다.
새로 밝혀진 사실 1: Ctrl+Alt+Shift+Q가 듣지 않는 이유
Moonlight의 종료 단축키 Q는 활성 스트리밍 세션 중에만 동작한다. 좀비 상태의 인스턴스는 스트리밍 중이 아니라 내부 루프에 갇혀 있으므로 어떤 단축키도 처리하지 못한다. 즉 "단축키로 안 닫힌다"는 것은 고장 신호가 아니라 좀비 상태를 식별하는 부수적 단서다.
SIGTERM(pkill -x moonlight)도 v1에서 이미 무시됨을 확인했으므로, 이 상태의 유일한 확실한 조치는 SIGKILL이다:
pkill -9 -x moonlight
새로 밝혀진 사실 2: 로그 증가율은 신뢰할 수 있는 탐지 지표가 아니다
v1에서는 watchdog 보완 지표로 "로그 증가율 이상"을 제안했다. 그러나 이번 재발 사례에서는 다음과 같이 관측되었다:
로그 라인 수 : 439,541 → 439,541 (3초간 +0줄)
동시 CPU : 24.1% (지속)
로그는 전혀 자라지 않는데 CPU는 계속 태우고 있었다. 1차 사례(초당 약 5줄)와 다른 양상이다. 따라서 로그 증가율은 이 결함을 놓칠 수 있는 지표이며, v1의 제안을 철회한다.
대신 신뢰할 수 있는 조합은 다음과 같다:
| 지표 | 좀비 상태에서의 값 | 정상 유휴 상태에서의 값 |
|---|---|---|
| CPU 점유율 | 20~26% 지속 | 0~1% |
wchan |
futex_do_wait (정상으로 위장) |
futex_do_wait |
| 활성 스트림 유무 | 없음 | 없음 |
| 프로세스 경과 시간 | 세션 종료 후에도 계속 증가 | — |
즉 판정식은 "활성 스트림이 없는데도 CPU 점유율이 수 분 이상 임계치(예: 15%)를 넘는다"이다. wchan은 두 상태에서 동일하게 나타나므로 판정에 쓸 수 없다 — 이는 기존 watchdog의 스핀 감지 로직(wchan 의존)이 이 결함을 구조적으로 감지하지 못하는 이유이기도 하다.
예방 수칙 (갱신)
- 스트리밍 종료(
Ctrl+Alt+Shift+Q) 후 앱 창까지 닫는다. 앱을 띄워둔 채 방치하는 것이 좀비화의 유일한 전제 조건이다. - 접속이 안 되거나 단축키가 듣지 않으면 좀비를 의심하고
pkill -9 -x moonlight후 재실행한다. - 장기적으로는 위 판정식(무스트림 + 지속 CPU)을 watchdog에 추가하는 것이 타당하다.
v3 추가: 재현 경로 확정과 자동 정리기(reaper) 구현 (2026-09-04)
재현 경로 확정
v2 작성 후 같은 날 세 번째 재발이 발생했고, 이 시점에 사령관이 정확한 재현 절차를 특정했다. 이것이 본 개정의 가장 중요한 성과다.
1. Moonlight로 호스트에 접속해 스트리밍 중인 상태
2. Ctrl+Alt+Shift+Q 로 스트리밍만 종료 (앱은 살아서 호스트 목록 화면으로 복귀)
3. 같은 앱 인스턴스에서 다시 접속 시도 ← 여기서 좀비화
4. 반면 바탕화면 아이콘으로 새 인스턴스를 띄우면 정상 접속됨
즉 결함의 본질은 "종료 후 방치"가 아니라 "한 인스턴스 안에서의 세션 재사용"이다. 첫 세션의 스트리밍 자원(수신 루프)이 완전히 정리되지 않은 채 두 번째 세션 초기화가 진행되면서 내부 상태가 손상되는 것으로 보인다. v1·v2에서 "앱을 띄워둔 채 방치하는 것이 전제 조건"이라고 서술했던 것은 부정확했으며 본 v3에서 정정한다 — 방치 자체가 아니라 재접속 시도가 방아쇠다.
새 프로세스에서는 정상 동작한다는 점이 이를 뒷받침한다. 프로세스 단위로 상태가 초기화되면 문제가 사라지므로, 손상되는 것은 시스템 자원이나 호스트 측이 아니라 앱 프로세스 내부 상태다.
좀비 판별 지표의 확정 — 실측
v2에서 "무스트림 + 지속 CPU"라는 판정식을 제시했으나, "무스트림"을 무엇으로 측정할지는 미해결로 남겨두었다. 이번에 /proc/<pid>/io의 rchar(프로세스가 읽어들인 총 바이트) 증가율을 실측해 이를 해결했다.
세 상태의 실측값:
| 상태 | CPU (tick/s) | rchar 증가율 |
|---|---|---|
| 유휴(호스트 목록 화면) | ~0 | ~0 B/s |
| 정상 스트리밍 | 높음 | ~1.25 MB/s (10 Mbps 기준) |
| 좀비 | 높음(코어의 10~25%) | ~0 B/s |
즉 "CPU는 태우는데 읽어들이는 데이터가 없다"가 좀비의 고유 시그니처다. 스트리밍은 비트레이트에 비례해 rchar가 자라므로 임계값을 100 KB/s로 두면 실제 스트림(1.25 MB/s)과 10배 이상의 여유를 두고 구분된다.
이로써 판정 지표의 변천은 다음과 같이 정리된다:
| 지표 | 상태 |
|---|---|
wchan |
좀비·정상 모두 futex_do_wait — 사용 불가 (v2에서 확인) |
| 로그 증가율 | 재발 사례에서 0 — 철회 (v2에서 철회) |
| CPU 점유율 단독 | 스트리밍과 구분 불가 — 불충분 |
| CPU + rchar 증가율 조합 | 확정 (v3) |
자동 정리기 구현
하루 세 번 수동 개입이 필요해진 시점에서, 판정식을 상주 감시기로 구현했다.
구조
~/Applications/moonlight-zombie-reaper.sh 감시 스크립트
~/.config/systemd/user/moonlight-zombie-reaper.service systemd 사용자 서비스(로그인 시 자동 시작)
~/.local/share/moonlight-zombie-reaper.log 동작 로그
판정 파라미터
INTERVAL = 10초 샘플 간격
CPU_TICKS_MIN = 5 초당 CPU 틱 임계치(코어의 5%)
RCHAR_MAX = 100000 초당 수신 바이트 상한(100 KB/s)
STRIKES_NEEDED = 6 연속 6회(=60초) 충족 시 정리
MIN_AGE = 90초 기동 직후 유예(초) — 접속 핸드셰이크 중 오탐 방지
조건을 60초 연속 충족한 프로세스만 kill -9 하고 데스크톱 알림을 띄운다. 다른 정상 인스턴스는 건드리지 않으며, 프로세스별로 독립적인 strike 카운터를 유지한다.
검증
배포 전, 판정 로직만 분리해 세 가지 상황에서 시험했다:
| 시험 사례 | CPU | rchar | 판정 | 기대 |
|---|---|---|---|---|
| CPU만 태우는 프로세스(좀비 모사) | 100 tick/s | 0 B/s | 좀비 | ✅ |
| CPU + 대량 수신(스트리밍 모사) | 9 tick/s | 7.0 MB/s | 정상(제외) | ✅ |
| 실제 Moonlight 유휴 인스턴스 | 1 tick/s | 0 B/s | 정상(제외) | ✅ |
세 사례 모두 기대대로 분류되었다. 특히 두 번째 사례는 오탐(정상 스트리밍을 죽이는 것)이 발생하지 않음을 보이는 것이 목적이었다.
시사점
- 재현 경로를 아는 것이 지표를 아는 것보다 먼저다. v1·v2는 "방치가 원인"이라는 부정확한 전제 위에서 예방 수칙을 제시했다. 사령관이 실제 조작 순서를 특정해준 뒤에야 "세션 재사용이 방아쇠"라는 본질이 드러났고, 예방 수칙도 정확해졌다. 사용자의 조작 맥락은 로그만으로는 복원되지 않는 정보다.
- 판정 지표는 세 번의 개정을 거쳐 확정되었다 —
wchan(v1, 실패) → 로그 증가율(v1 제안, v2 철회) → CPU 단독(불충분) → CPU + rchar(v3, 확정). 결함을 관측할 때마다 지표를 재검토한 결과이며, 각 단계의 실패를 기록으로 남긴 것이 다음 단계의 출발점이 되었다. - 완화책(workaround)과 결함 해결은 다르다. 이 reaper는 증상을 자동으로 치우는 장치일 뿐 결함 자체를 고치지 않는다. 확정된 재현 경로가 있으므로 상류(upstream) 보고가 다음 단계로 타당하다.
갱신된 실무 수칙
- 스트리밍 종료 후 같은 인스턴스에서 재접속하지 않는다. 앱을 닫고 바탕화면 아이콘으로 새로 실행한다. (이것이 유일하게 확실한 예방책)
- 좀비가 생기더라도 60초 내 reaper가 자동 정리하므로 수동 개입은 불필요하다.
- 수동 정리가 필요하면
pkill -9 -x moonlight. - reaper 관리:
systemctl --user status|stop|disable --now moonlight-zombie-reaper
v4 추가: 자동 정리기의 오탐과 폐기 — "사용자 확인" 방식으로의 전환 (2026-09-04)
무슨 일이 있었나
v3에서 배포한 자동 정리기(reaper)는 가동 6분 만에 정상 사용 중이던 인스턴스 두 개를 종료했다. 사령관의 보고는 다음과 같다:
"지금 열어놓은 채로 내가 다른 일을 하고 있었는데, 60초 지났다고 닫혔어. 좀 불편하네."
자동화가 해결하려던 불편(하루 세 번의 수동 개입)보다, 자동화가 만들어낸 불편(사용 중인 창이 예고 없이 사라짐)이 더 컸다. v3의 설계는 실패했으며 본 v4에서 폐기한다.
실패 원인: 대표성 없는 단일 측정
판정식의 CPU 조건은 다음 실측에 근거했다:
유휴(호스트 목록 화면) 인스턴스 실측 → CPU 0~1 tick/s, rchar 0 B/s
이 값을 "유휴 상태의 대표값"으로 삼아 임계치를 5 tick/s로 정했다. 그러나 이 측정은 창이 가려져 있던 순간의 값이었던 것으로 보인다. 창이 화면에 보이는 상태의 유휴 Moonlight는 렌더 루프 때문에 6~10 tick/s를 지속적으로 소비한다. 이는 좀비의 CPU 사용량과 사실상 동일하다.
reaper 로그가 이를 그대로 보여준다:
11:26:44 의심 PID=160046 (1/6) cpu=8tick/s rchar=0B/s
11:27:24 해제 PID=160046 (cpu=9tick/s rchar=753706B/s — 정상 판정) ← 데이터가 들어와 리셋
11:27:34 의심 PID=160046 (1/6) cpu=9tick/s rchar=0B/s
…
11:28:25 좀비 확정 PID=160046 — 강제 종료
교훈: 한 번의 측정으로 "정상 상태의 범위"를 정하지 말 것. 상태 판정의 기준선은 조건(창 표시 여부, 포커스, 화면 갱신 등)을 바꿔가며 여러 번 측정해 분포를 확인한 뒤에 정해야 한다. 이번에는 단일 표본으로 임계치를 정했고, 그 표본이 하필 예외적인 조건에서 나온 값이었다.
더 근본적인 문제: 좀비와 유휴는 프로세스 지표로 구분되지 않는다
| 상태 | CPU | rchar | 구분 가능? |
|---|---|---|---|
| 정상 스트리밍 | 높음 | ~1.25 MB/s | ✅ 구분됨 |
| 유휴(창 표시 중) | 6~10 tick/s | ~0 B/s | ❌ |
| 좀비 | 6~10 tick/s | ~0 B/s | ❌ |
스트리밍은 rchar로 확실히 구분되지만, "열어두고 안 쓰는 상태"와 "좀비"는 프로세스 수준 지표로는 사실상 동일하다. 둘의 진짜 차이는 "UI가 응답하는가"인데, 이는 외부에서 저비용으로 관측하기 어렵다. 즉 이 결함은 자동 판정으로 안전하게 해결할 수 있는 종류가 아니다.
전환: 자동 종료 폐기, 사용자 확인 방식
자동 kill을 완전히 제거하고, 의심 상태가 3분 지속되면 사용자에게 묻는 방식으로 바꿨다.
구현 과정에서 만난 제약
처음에는 알림에 버튼을 다는 방식(notify-send --action)을 시도했으나 실패했다:
$ notify-send --action=kill="종료하기" ...
Actions are not supported by this notifications server. Displaying non-interactively.
libnotify 0.8.6은 액션을 지원하지만, GNOME Shell 49의 알림 서버가 액션 기능(capability)을 광고하지 않는다. 따라서 알림 버튼 방식은 이 환경에서 사용할 수 없다.
대안으로 zenity 확인 대화상자를 채택했다. 알림은 상황 고지용으로 먼저 띄우고(놓쳐도 알림 목록에 남는다), 실제 선택은 대화상자로 받는다.
최종 동작
1. CPU ≥ 5 tick/s + rchar < 5 KB/s 가 3분(18회) 연속 지속
2. 데스크톱 알림 표시 → 이어서 zenity 확인 대화상자
3. [종료하기] → 해당 PID만 kill -9 + 완료 알림
4. [그대로 두기] → 종료하지 않고, 그 PID를 이후 감시 대상에서 영구 제외
(/tmp/.moonlight-reaper-ignore-<pid> 표식, 프로세스 종료 시 자동 정리)
대화상자 응답 대기는 백그라운드 서브셸에서 처리해 감시 루프를 막지 않는다. 어떤 경우에도 사용자 확인 없이 종료되는 경로는 존재하지 않는다.
시사점
- 자동화의 실패 비용은 대칭적이지 않다. 좀비를 놓치는 비용은 CPU 낭비(복구 가능)지만, 정상 창을 죽이는 비용은 사용자의 작업 흐름 중단(복구 불가)이다. 판정이 불확실한 영역에서는 놓치는 쪽으로 기울이고 사람에게 묻는 것이 옳다.
- "구분 불가능함"을 인정하는 것도 결론이다. v3은 지표를 정교화하면 자동 판정이 가능하리라 가정했다. 그러나 유휴와 좀비는 프로세스 지표상 동일하며, 이는 임계치 조정으로 해결되지 않는다. 잘못된 전제 위에서 파라미터만 다듬는 대신 접근 자체를 바꿨다.
- 환경 제약은 구현 단계에서 드러난다. GNOME 49가 알림 액션을 지원하지 않는다는 사실은 문서가 아니라 실행 결과로 확인되었다. 설계 시 가정한 UI 수단이 대상 환경에서 동작하는지는 반드시 실측해야 한다.
- 이 개정으로 본 논문의 지표 탐색사는 다음과 같이 마무리된다:
wchan(실패) → 로그 증가율(철회) → CPU 단독(불충분) → CPU+rchar(스트리밍 구분에는 유효, 유휴 구분에는 무효) → 자동 판정 포기, 사용자 확인으로 전환.
최종 실무 수칙
- 예방: 스트리밍 종료 후 같은 인스턴스에서 재접속하지 않는다. 앱을 닫고 새로 실행한다.
- 탐지: 감시기가 의심 상태를 3분간 관찰하면 확인 대화상자를 띄운다. 판단은 사용자가 한다.
- 수동 정리:
pkill -9 -x moonlight - 감시기 관리:
systemctl --user status|stop|disable --now moonlight-zombie-reaper
v5 추가: 확인 대화상자 자체의 이중 결함 — 환경변수 누락과 그 부작용의 영속화 (2026-09-08)
무슨 일이 있었나
v4에서 자동 종료를 폐기하고 "3분 관찰 → zenity 확인 대화상자" 방식으로 전환한 뒤, 며칠 뒤 실제로 의심 상태가 발생했다. 그런데 사령관은 대화상자가 전혀 뜨지 않는다고 보고했다:
"아무리 기다려도 팝업 (그대로두기,종료)가 안나타나는데?"
로그를 확인하니 실제로는 대화상자 표시 로직이 실행되었고, "사용자가 유지 선택"으로 처리까지 되어 있었다 — 그런데 표시(11:56:17)부터 처리(11:56:20)까지 단 3초였다. 사람이 실제로 화면을 보고 판단하기엔 비현실적으로 빠른 시간이었고, 이것이 두 번째 결함의 발견으로 이어졌다.
결함 1: 서비스가 그래픽 환경 없이 시작됨
tr '\0' '\n' < /proc/<reaper_pid>/environ | grep -iE "DISPLAY|XAUTH"
# → 아무 결과 없음 (DBUS_SESSION_BUS_ADDRESS만 존재)
moonlight-zombie-reaper.service가 WantedBy=default.target으로만 등록되어 있어, 그래픽 세션이 완전히 준비되어 systemd 사용자 매니저 환경에 DISPLAY/XAUTHORITY가 등록되기 전에 먼저 시작되고 있었다. 이미 실행 중인 프로세스는 자신이 시작된 시점의 환경 스냅샷을 그대로 들고 가므로, 이후 systemctl --user show-environment가 정상값을 보여줘도 이미 떠 있는 reaper 프로세스에는 반영되지 않는다.
그 결과 백그라운드 서브셸의 zenity --question이 디스플레이 서버에 연결하지 못해 조용히 실패했고, 스크립트의 if zenity ...; then ... else ... 구조에서 이 실패가 사용자가 "그대로 두기"를 선택한 것과 구분 없이 동일한 else 분기로 처리되었다. 로그에 "사용자가 유지 선택"이라고 남았지만, 실제로는 아무도 아무것도 선택하지 않았다 — 스크립트가 실패를 성공적인 사용자 응답으로 오기록한 것이다.
수정
[Unit]
After=graphical-session.target
PartOf=graphical-session.target
서비스가 그래픽 세션이 완전히 준비된 뒤에만 시작되도록 의존성을 추가하고 재시작했다. 재시작 후 새 프로세스의 환경을 확인하자 DISPLAY=:0, XAUTHORITY=...가 정상적으로 존재했고, 실제 zenity 창도 화면에 렌더링됨을 확인했다(사령관이 직접 보고 응답).
결함 2: 실패의 부작용이 파일 시스템에 영속화됨
수정 후 재시작했음에도 사령관은 여전히 "아직 안 뜨는데?"라고 보고했다. 두 번째 원인은 더 미묘했다.
결함 1의 zenity 실패가 else 분기(그대로 두기로 간주)를 타면서, 스크립트는 다음을 실행했었다:
touch "/tmp/.moonlight-reaper-ignore-$pid"
그리고 감시 루프의 매 반복은 이 표식이 있으면 해당 PID를 완전히 건너뛴다:
[ -e "/tmp/.moonlight-reaper-ignore-$pid" ] && continue
즉 환경변수 버그로 인한 오처리 한 번이, 그 PID에 대한 영구 감시 제외라는 부작용을 파일로 남겼다. 서비스를 재시작해 환경변수 문제 자체는 고쳐졌지만, 문제가 되던 그 프로세스(PID 25700, 며칠째 살아있던 동일 좀비 의심 프로세스)는 이 표식 때문에 여전히 감시 대상에서 빠져 있었다. 근본 원인을 고쳤어도 그 근본 원인이 낳은 부작용은 별도로 청소해야 했다:
rm -f /tmp/.moonlight-reaper-ignore-25700
표식 제거 후 같은 PID가 다시 감시되기 시작했고, 5분 뒤(300초 임계치대로) 실제로 화면에 대화상자가 렌더링되었으며 사령관이 직접 보고 응답했다(6초 만에 처리 — 실제 판단에 걸린 정상적인 시간).
시사점
- "else"로 실패와 정상 응답을 뭉뚱그리지 말 것.
if zenity; then 종료 else 그대로두기구조는 "사용자가 명시적으로 거부함"과 "애초에 물어보지도 못함"을 구분하지 못한다. 두 경우의 사후 조치는 근본적으로 달라야 한다 — 전자는 그 선택을 존중해 제외하는 것이 맞지만, 후자는 재시도하거나 최소한 실패를 별도로 로깅해야 한다. 이번 버그는 코드 리뷰 시점에 이 구분의 부재를 놓친 것이 근본 원인이다. - 처리 소요시간은 그 자체로 하나의 검증 신호다. 대화상자 표시부터 응답까지 3초라는 로그 타임스탬프 자체가 "이건 사람이 아니다"라는 강력한 증거였다. 자동화 로직을 만들 때, 사람이 개입하는 단계의 소요시간이 비현실적으로 짧다면 그 자체를 이상 탐지 신호로 활용할 수 있다.
- 부작용의 영속화는 원인 수정과 별개로 청소해야 한다. 환경변수를 고쳐도 그 버그가 이미 남긴 파일(ignore 표식)은 저절로 사라지지 않았다. 근본 원인 수정과 그 원인이 이미 만들어낸 상태(state)의 정리는 서로 다른 작업이며, 후자를 빠뜨리면 "고쳤는데도 안 고쳐진 것처럼 보이는" 상황이 재현된다.
- systemd 사용자 서비스와 그래픽 세션 환경변수 전파 타이밍 문제가 이번 세션에서 두 번째로 발생했다. 같은 날 Sunshine 서비스(
2026-09-08-economi-cmg-v16-input-paralysis-blind-remote-diagnosis참조, 별도 호스트 cmg-V16)에서도 X 세션 교체 후DISPLAY값이 갱신되지 않아 스트리밍이 실패했던 것과 본질적으로 동일한 유형의 결함이다. 그래픽 세션에 의존하는 systemd--user서비스는graphical-session.target에 대한 명시적 의존성 없이는 이런 경합(race)에 구조적으로 취약하다는 것이, 서로 다른 두 컴퓨터·서로 다른 두 애플리케이션에서 독립적으로 재확인되었다.
최종 실무 수칙 (갱신)
- 예방: 스트리밍 종료 후 같은 인스턴스에서 재접속하지 않는다.
- 탐지: 감시기가 의심 상태를 5분(300초, v5 기준
STRIKES_NEEDED=18 × INTERVAL=10s) 관찰하면 확인 대화상자를 띄운다. 대화상자가 뜨는 데 걸리는 시간이 비정상적으로 짧다면(수 초 이내) 환경변수 문제를 의심한다. - 그래픽 세션 의존 systemd 서비스를 새로 만들 때는 반드시
After=graphical-session.target+PartOf=graphical-session.target을 함께 지정한다. - 수동 정리:
pkill -9 -x moonlight - 감시기 관리:
systemctl --user status|restart|stop|disable --now moonlight-zombie-reaper - 오처리로 생긴 제외 표식이 의심되면:
ls /tmp/.moonlight-reaper-ignore-*로 확인 후 필요 시rm
