마지막 업데이트 2026-07-22
세션 중 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중 방어.
/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초-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가 무한정 대기 — 유일한 종료 조건이 "디스크 가득참"이었다.replaceChildConsumer / replaceAiConsumer가 해당 입력의 stem ffmpeg를 재시작한다. recordingManager.ts:385,420| 검증 지점 | 검사 내용(수정 전) | 손상 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으로 기존 필터에 걸렸기에 평소 합본은 정상이었고, "검사 항목마다 그럴듯한 값을 돌려주는" 이 형태만 처음으로 뚫고 들어왔다.
거대 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이 섞여도 폭주로는 이어지지 않았다 — 새 정렬 설계 + 신종 손상 형태가 만나 처음 터진 사이드 이펙트다.
목표: 손상 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)