마지막 업데이트 2026-07-29
세션 중 stem ffmpeg 재시작 시 SIGKILL로 finalize되지 못한 2.8KB 손상 OGG가
ffprobe에서 start_time=0·duration≈17억초로 "성공적으로" 읽히며 기존 검증(null 체크·duration>0)을 모두 통과,
post-mix 정렬 기준(base = min start_time)을 0(=1970년)으로 무너뜨렸다.
그 결과 정상 stem들의 adelay가 epoch값 그대로(≈56년) 계산되어
ffmpeg가 56년치 무음을 128kbps CBR로 무한 인코딩 — 디스크 20GB가 찰 때까지(14GB 시점 발견) 멈추지 않았다.
hotfix는 손상 stem 격리(2겹 sanity check) + 폭주 상한(-t·타임아웃)의 4중 방어.
prod 서버 루트 디스크가 100%라는 신고. 현장에 도착해 보니 범행 흔적은 단 하나의 파일로 수렴했다 — 그리고 그 파일은 지금도 자라고 있었다.
/dev/nvme0n1p1 20G/20G 100% 사용 — 대부분 ppi-socket-prod 컨테이너 writable layer(14.2GB)/tmp/recordings/0dc4079a-…_4_1783337708161.mp3 — 약 14GB, 계속 증가 중adelay=1783337708425ms(epoch급), 한 stem은 adelay=0start_time=0.000000 / duration≈1,783,337,868초살아 있는 ffmpeg의 커맨드라인이 결정적이었다. adelay 1,783,337,708,425ms — epoch 밀리초와 자릿수가 같다. 그리고 adelay=0을 받은 stem은 고작 2.8KB인데 ffprobe는 "길이 17억 초"라고 답한다. 시작은 1970년, 길이는 56년. 이 기형 조합이 어떻게 만들어졌는지 인과를 역추적했다.
-use_wallclock_as_timestamps 1 -copyts로 기록되어
start_time이 절대 wallclock(예: 1,783,337,708초)이다. recordingManager.ts:953-955collectValidStems()는 duration>0만 검사 — 손상 stem의 duration이 17억 초(양수)라 통과.
postMixStems()의 가드는 start_time이 null인 stem만 드롭 — 이 stem은 0.0으로 "읽혔고" 0은 finite라 통과.base = Math.min(...knownPairs.map(p => p.start)) = 0이 되면서
정상 stem들의 adelay가 start − 0 ≈ 1.78e12ms ≈ 56년으로 계산됐다.adelay는 지연 구간을 무음 샘플로 채우고, amix duration=longest는 가장 긴 입력이 끝날 때까지 출력한다.
ffmpeg 입장에선 오류가 아니라 유효한 명령의 정상 수행이다.-t(출력 길이 상한)도 실행 타임아웃도 없어 await가 무한정 대기 — 유일한 종료 조건이 "디스크 가득참"이었다.사슬은 이어졌지만 출발점이 남았다. 2.8KB짜리 손상 OGG는 애초에 왜 생기는가, 그리고 왜 하필 "1970년"이라는 시각을 갖게 됐는가.
replaceChildConsumer / replaceAiConsumer가 해당 입력의 stem ffmpeg를 재시작한다. recordingManager.ts:385,420범인의 정체와 위장 수법이 드러났다. 다음 질문 — 이 위장이 어떻게 두 겹의 검문(collectValidStems, postMixStems)을 모두 통과했는가. 검문 기록을 한 줄씩 대조했다.
| 검증 지점 | 검사 내용(수정 전) | 손상 stem의 값 | 결과 |
|---|---|---|---|
collectValidStems 크기 | size > 0 | 2.8KB | 통과 |
collectValidStems duration | probe 성공 && durMs > 0 | ≈1,783,337,868,000ms (epoch급 양수) | 통과 (상한 검사 없음) |
postMixStems start_time | null 아님 && finite | 0.0 (finite) | 통과 (범위 검사 없음) |
| post-mix 출력 상한 | (없음) | — | 방어선 부재 |
필터들이 "읽을 수 있는가"만 봤지 "값이 말이 되는가"는 안 봤다. 대부분의 SIGKILL 잘림은 probe 실패·duration 0으로 기존 필터에 걸렸기에 평소 합본은 정상이었고, "검사 항목마다 그럴듯한 값을 돌려주는" 이 형태만 처음으로 뚫고 들어왔다.
침입 경로까지 확인됐으니, 마지막으로 범행 결과물 — 14GB 파일 안에 실제로 무엇이 들어 있고 왜 멈추지 않고 자랐는지를 감식했다.
거대 MP3는 손상 stem이 반복해서 합쳐진 결과가 아니다. 손상 stem(2.8KB)은 내용 기여가 거의 없고, 역할은 정렬 기준(base)을 1970년으로 끌어내린 것뿐이다. 파일을 채운 실체는 정상 stem 앞에 붙은 무음 패딩 — post-mix ffmpeg 한 번의 실행이 끝나지 않고 무음을 계속 인코딩한 결과다.
// base=0(손상 stem의 start_time)으로 계산된 출력 타임라인
1970-01-01 ──[손상 stem 2.8KB, adelay=0]──┐
│ │
│ ≈56년치 무음 패딩 │ ← adelay=1783337708425ms 가 만든 갭
│ (adelay 가 무음 샘플로 채움) │ ffmpeg 는 이 구간을 성실히 인코딩 중이었다
│ │
2026-07-06 ──[정상 stem들: 실제 수업 오디오]─┘ ← 타임라인 맨 끝에야 등장
adelay)",
"가장 긴 입력이 끝날 때까지 출력해라(amix duration=longest)"라고 지시했고,
2초든 56년이든 문법상 똑같이 유효한 파라미터라 시킨 일을 수행하고 있었을 뿐이다.
버그도 행(hang)도 아니어서 스스로 죽지도 않는다. 그 "56년"이라는 숫자를 만들어낸 게 우리 코드의 버그다
— 정상 start_time(epoch) − base(0) = epoch값 그대로가 adelay 로 번역.
amix duration=longest → 출력 타임라인 56년. 인코딩이 "끝나야" 멈추는데 끝이 오지 않는다.-t(출력 길이 상한)도 실행 타임아웃도 없어 await가 ffmpeg exit을 무한정 대기 — 유일한 종료 조건이 디스크 고갈(20GB)이었고, 14GB 시점에 발견됐다.그 14GB를 열어보면 앞부분부터 수십 년치 무음이며, 인코딩이 무음 구간을 다 지나지 못하고 발견됐으므로 실제 수업 오디오는 아예 담기지 못한 순수 무음 파일일 가능성이 높다.
| 조건 | 성격 | 설명 |
|---|---|---|
| ① SIGKILL 잘림 OGG 발생 | 상시 (구조) | producer 교체마다 짧은 타임아웃 kill — 잘린 stem은 운영 중 계속 생성 |
| ② finite면 신뢰하는 정렬 로직 | 상시 (PPI-1098 이후) | base=min(start_time)이 배포된 순간부터 모든 합본이 이 코드를 통과 |
| ③ 비대칭 손상 형태 | 확률 (유일한 우연) | 하필 "start_time=0 + epoch 꼬리"로 깨지는 바이트 경계에서 kill |
"언제 터질지"만 우연이었고 "언젠가 터진다"는 예정돼 있었다. 수업이 매일 돌아가는 이상 ③의 확률은 누적된다. 참고로 base=min(start_time) 정렬은 최근 PPI-1098에서 도입됐으므로, 그 이전이었다면 같은 stem이 섞여도 폭주로는 이어지지 않았다 — 새 정렬 설계 + 신종 손상 형태가 만나 처음 터진 사이드 이펙트다.
조건 ③(손상 형태)은 통제할 수 없고, 조건 ①(kill 재시작)은 녹음 공백과의 트레이드오프다. 따라서 검거는 조건 ②를 겨냥한다 — 위장한 stem이 들어와도 격리하고, 격리가 또 뚫려도 폭주 자체가 불가능하게 상한을 건다.
목표: 손상 stem 발생 자체를 막는 게 아니라(③은 통제 불가), 들어와도 피해가 "그 stem 하나 누락"에서 멈추도록 격리하고, 로직이 또 뚫려도 디스크 장애로 못 자라게 상한을 둔다.
collectValidStemsisPlausibleStemDurationMs(): duration이 12시간을 초과하는 stem은 타임스탬프가 깨진 것으로 보고 드롭.
이번 손상 stem(17억 초)은 여기서 바로 걸러진다.
planPostMix 순수 함수 추출mp3T0Ms만 참조하므로 plan 수정으로 함께 치유된다.
// start_time 이 null 이거나 녹음 시작 wallclock 에서 ±12h 벗어난 stem(손상 OGG 의 // start_time=0 등)은 드롭해 base 붕괴 → epoch 급 adelay → 무한 인코딩을 차단한다. export function planPostMix(startTimesSec, recordingStartWallMs): PostMixPlan { // plausible = null 아님 && finite && |startMs - recordingStartWallMs| <= 12h // plausible 0개 → 전부 0ms 폴백 (usedFallback=true, t0=recordingStartWallMs) // plausible 있음 → base = min(plausible), 나머지는 droppedIndices 로 제외 }
buildPostMixArgs-t 21600(6시간) 추가. 정렬 로직이 미지의 새 손상 형태에 또 뚫려도
mp3가 최대 ≈330MB(128kbps×6h)에서 잘린다. spawn 인자 빌드도 순수 함수로 추출해 테스트 대상화.
apps/socket/test/recording-postmix-guard.test.ts — 12케이스, RED(구현 전 실패) 확인 후 GREEN-t 상한 포함tsc --noEmit 클린planPostMix / isPlausibleStemDurationMs / buildPostMixArgs / collectValidStems / postMixStemsPPI-1106)