불가능한 오류의 발생
rdkafka-ruby 112번째 줄, FFI::Struct의 필드에 접근하는 부분에서 오류가 발생했습니다. elem[:partition] 접근 시 elem이 FFI::Struct임에도 불구하고, 내부적으로 필드 정의를 저장하는 rbFieldMap 해시가 문자열로 변형되어 default 메서드를 호출하려다 실패한 것이었습니다.
초기 가설과 실패
-
musl 라이브러리 문제: 사용 환경이 Alpine Linux(musl libc)였기에, 컴파일된 Gem과 musl 간의 ABI 불일치, 구조체 정렬 문제 등을 의심했습니다.
-
진단 스크립트: FFI 정수 타입 크기, 구조체 패딩, 실제 Gem 구조체의 크기 및 오프셋 등을 확인하는 스크립트를 실행했으나, 모든 진단 결과는 완벽하게 일치했습니다. 모든 ‘명백한’ 설명은 실패로 돌아갔습니다.
쓰기 장벽(Write Barrier)의 부재
#default 메서드가 Hash의 기능이라는 점에 착안하여, FFI의 내부 해시가 런타임에 문자열로 대체될 가능성을 가정했습니다. 이는 Ruby GC가 해시를 해제한 후, 동일한 메모리 주소에 문자열을 할당하여 C 코드의 포인터가 잘못된 타입의 객체를 가리키게 되는 시나리오였습니다.
FFI #1079 이슈와 쓰기 장벽
FFI GitHub 이슈 #1079에서 ‘missing write barriers’ 언급을 발견했습니다. 쓰기 장벽은 Ruby GC에게 객체 간의 참조 관계를 알려주는 메커니즘입니다. FFI 1.16.3 버전의 ext/ffi_c/StructLayout.c 코드에서 layout->rbFieldMap = rb_hash_new();와 같이 Ruby 객체를 할당하면서 RB_OBJ_WRITE 매크로를 사용하지 않아 GC가 해당 참조를 인지하지 못했습니다.
-
취약점: GC는 C 구조체가 Ruby 객체를 참조하고 있다는 사실을 모르기 때문에, C 구조체가 스코프를 벗어나면 해당 Ruby 객체를 해제할 수 있었습니다.
-
해결책: FFI 1.17.0에서는
RB_OBJ_WRITE(self, &layout->rbFieldMap, rb_hash_new());와 같이 쓰기 장벽을 추가하여 GC에게 C 구조체가 이 Ruby 객체를 소유하고 있으니 해제하지 말라고 명시했습니다.
버그 재현 환경 구축
단순한 GC.stress로는 즉각적인 세그멘테이션 폴트만 발생했으므로, 해시가 문자열로 변하는 상황을 재현하기 위해 다음과 같은 정교한 환경을 구축했습니다.
-
수많은 임시 구조체 클래스 정의 및 스코프 이탈
-
다중 스레드를 통한 메모리 압력 및 자연스러운 GC 타이밍 유도
-
GC 실행과 필드 접근 사이에 Ruby가 새 객체를 할당할 시간 간격 확보
-
메모리 제약이 있는 Docker 컨테이너에서 반복 실행
이러한 환경에서 결국 undefined method 'default' for an instance of String 오류를 재현하는 데 성공했습니다. 이는 FFI 내부 해시가 GC에 의해 해제되고 동일한 메모리 위치에 문자열 객체가 할당되었음을 명확히 입증하는 것이었습니다.