Finding and Fixing Ghostty's Largest Memory Leak

Mitchell Hashimoto

Ghostty의 가장 큰 메모리 누수를 찾고 고치기

원문은 Mitchell Hashimoto님이 에 게재했습니다. 이 블로그 구독하기

몇 달 전부터 Ghostty가 터무니없이 많은 메모리를 사용한다는 제보가 들어오기 시작했고, 한 사용자는 10일간 계속 실행한 뒤 메모리가 37 GB까지 치솟았다고 보고했습니다. 오늘 기쁘게도 수정 사항이 확정되어 병합되었다는 소식을 전합니다. 이 글에서는 누수의 원인과 Ghostty 내부 구조의 일부, 그리고 문제를 추적한 과정을 간략히 정리합니다.1

이 누수는 적어도 Ghostty 1.0부터 존재했지만, 최근 들어 인기 있는 CLI 애플리케이션(특히 Claude Code)이 대규모로 누수를 촉발하는 조건을 만들어내기 시작하면서 본격적으로 드러났습니다. 누수를 일으키는 조건이 매우 한정적이었기 때문에 진단이 특히 까다로웠습니다.

수정 사항은 이미 병합되었으며 tip/nightly 빌드에서 사용할 수 있고, 3월에 태그될 1.3 정식 릴리스에도 포함될 예정입니다.


PageList

버그를 이해하려면 먼저 Ghostty가 터미널 메모리를 어떻게 관리하는지 알아야 합니다. Ghostty는 터미널 콘텐츠를 저장하기 위해 PageList라는 자료구조를 사용합니다. PageList는 터미널 콘텐츠(문자, 스타일, 하이퍼링크 등)를 담는 메모리 페이지들의 이중 연결 리스트입니다.

PageList: 메모리 페이지의 이중 연결 리스트

Page 1 가장 오래된 스크롤백 ↔ Page 2 ↔ Page 3 ↔ Page 4 가장 최신 활성 화면

여기서 말하는 “페이지”는 단일 가상 메모리 페이지가 아니라, 페이지 경계에 정렬되고 시스템 페이지 크기의 정수배로 이루어진 연속된 메모리 블록입니다.2

이 페이지들은 mmap을 이용해 할당됩니다. mmap은 그리 빠르지 않기 때문에 잦은 시스템 호출을 피하고자 메모리 풀을 사용합니다. 새 페이지가 필요하면 풀에서 가져오고, 사용이 끝나면 재사용을 위해 풀에 반납합니다.

풀은 페이지에 표준 크기를 사용합니다. 표준 규격 택배 상자를 사는 것에 비유하면 이해하기 쉽습니다. 대부분의 발송 물품은 표준 상자에 들어가고, 표준 상자를 쓰면 여러모로 효율적입니다.

하지만 때로는 표준 페이지가 제공하는 것보다 더 많은 메모리가 필요할 때도 있습니다. 한 묶음의 라인에 이모지나 스타일, 하이퍼링크가 많으면 더 큰 페이지가 필요합니다. 이럴 때는 풀을 거치지 않고 mmap으로 비표준 페이지를 직접 할당합니다. 보통은 드문 경우입니다.

두 가지 페이지 할당 방식

표준 페이지(풀에서 할당)

• 고정 크기

• 해제 시 풀에 반환

• 향후 할당에 재사용 가능

비표준 페이지(mmap 직접 할당)

• 가변 크기(표준보다 큼)

• 해제 시 munmap 호출 필요

• 재사용 불가

페이지를 “해제”할 때는 간단한 로직을 적용합니다:

  1. 페이지가 <= standard size이면: 풀에 반환한다
  2. 페이지가 > standard size이면: munmap을 호출해 해제한다

이것이 Ghostty 터미널 메모리 관리의 핵심 배경이며, 아이디어 자체는 타당합니다. 다음에 살펴보겠지만, 최적화 과정의 로직 버그가 누수를 만들었습니다.


스크롤백 최적화

버그를 이해하려면 한 가지 배경을 더 짚고 넘어가야 합니다. 바로 스크롤백 정리(pruning)입니다.

Ghostty에는 유지할 히스토리 양을 제한하는 scrollback-limit 설정이 있습니다. 이 한도에 도달하면 스크롤백 버퍼에서 가장 오래된 페이지를 삭제해 메모리를 확보합니다.

하지만 이 과정은 대량의 데이터를 빠르게 출력할 때처럼 매우 빈번하게 실행되는 경로에서 자주 발생하고, 풀을 사용하더라도 메모리 페이지를 할당하고 해제하는 비용은 큽니다. 그래서 우리는 최적화를 하나 두고 있습니다. 바로 한도에 도달했을 때 가장 오래된 페이지를 가장 새로운 페이지로 재사용하는 것입니다.

스크롤백 정리: 가장 오래된 페이지 재사용

이전: 스크롤백 한도에 도달한 상태

Page 1 정리 대상 ↔ Page 2 ↔ Page 3 ↔ Page 4

앞에서 제거해 뒤에서 재사용

이후: 페이지가 끝에 재사용된 상태

Page 2가 이제 가장 오래됨 ↔ Page 3 ↔ Page 4 ↔ Page 1 재사용!

이 최적화는 아주 효과적입니다. 할당을 전혀 하지 않고, 빠른 포인터 조작만으로 페이지를 리스트의 앞에서 뒤로 옮깁니다. 페이지를 “비우기” 위해 약간의 메타데이터 정리만 수행하고, 기존 메모리는 그대로 둡니다.

매우 빠르고, 실제로 스크롤백이 많은 작업에서 성능을 크게 향상시킵니다.


버그

스크롤백 정리 최적화 과정에서 우리는 항상 페이지를 다시 표준 크기로 리사이즈했습니다. 하지만 실제 메모리 할당 자체는 리사이즈하지 않고 메타데이터에만 크기가 줄었다고 기록했습니다. 실제 메모리는 여전히 큰 비표준 mmap 할당이 그대로 남아 있었지만, 이제 PageList는 그 페이지를 표준 크기라고 인식하게 된 것입니다.

메타데이터 불일치가 누수를 일으키는 과정

1 비표준 페이지 할당 메타데이터: 2× 표준 크기 mmap: 표준 + 추가

2 스크롤백 정리 및 재사용 메타데이터: std_size mmap: 표준 + 추가 버그: 메타데이터는 std_size로 초기화됐지만 mmap은 그대로!

3 페이지 해제 메타데이터: std_size mmap: 유출됨 표준 크기, 풀에 있다고 가정. munmap이 호출되지 않음!

표준 비표준 유출됨

결국 여러 상황(예: 사용자가 터미널을 닫을 때 등)에서 해당 페이지를 해제하게 됩니다. 그 시점에 페이지 메모리가 표준 크기 이내라고 판단해 풀에 속한 것으로 간주하고, 절대 munmap을 호출하지 않게 됩니다. 전형적인 누수입니다.

이제 보면 꽤나 명백해 보이지만, 문제는 비표준 페이지가 설계상 드물게만 발생한다는 점입니다. 우리 설계와 최적화의 목표는 표준 페이지가 일반적인 경우이며 빠른 경로를 제공한다는 것입니다. 아주 특정한 시나리오에서만 비표준 페이지가 생성되고, 보통은 대량으로 만들어지지도 않습니다.

하지만 Claude Code의 등장이 상황을 바꿨습니다. 어떤 이유에서인지 Claude Code의 CLI는 다중 코드포인트 그래핌 출력을 많이 생성해 Ghostty가 비표준 페이지를 정기적으로 사용하도록 만듭니다. 게다가 Claude Code는 주 화면을 사용하면서 상당한 양의 스크롤백 출력을 만들어냅니다. 이 요소들이 결합해 대규모 누수를 촉발하는 완벽한 조건을 만들었습니다.

이 버그는 Claude Code의 잘못이 아니라는 점을 분명히 하고 싶습니다. Claude Code는 단지 오랜 버그를 드러내는 방식으로 Ghostty를 사용하고 있을 뿐입니다.


수정

수정 방법은 개념적으로 단순합니다. 비표준 페이지를 절대 재사용하지 않는 것입니다. 스크롤백 정리 중에 비표준 페이지를 만나면 제대로 파괴하고(munmap 호출) 풀에서 새로운 표준 크기 페이지를 할당합니다.

수정의 핵심은 아래 스니펫에 담겨 있지만, 다른 회계 처리를 바로잡기 위한 추가 작업도 필요했습니다:

if (first.data.memory.len > std_size) {
    self.destroyNode(first);
    break :prune;
}

비표준 페이지를 재사용하면서 큰 메모리 크기를 그대로 유지하는 방법도 있었지만, 다른 데이터가 나오기 전까지는 표준 페이지가 일반적인 경우라는 가정 하에 표준 풀 페이지로 되돌리는 것이 타당하다고 판단했습니다.

다른 사용자들은 더 복잡한 전략(예: 비표준 페이지가 얼마나 자주 사용되는지에 대한 지표를 유지하고 그에 따라 가정을 조정하는 방식)을 제안했지만, 그런 변경을 하기 전에는 더 많은 연구가 필요합니다. 이번 변경은 단순하면서 버그를 고치고 현재 가정에 부합합니다.


VM 태그로 누수 찾기

수정의 일환으로 Mach 커널이 제공하는 macOS의 가상 메모리 태그 지원을 추가했습니다. 이를 통해 PageList 메모리 할당에 특정 식별자를 태깅할 수 있고, 이 태그는 다양한 도구에서 표시됩니다.

inline fn pageAllocator() Allocator {
    // In tests we use our testing allocator so we can detect leaks.
    if (builtin.is_test) return std.testing.allocator;

    // On non-macOS we use our standard Zig page allocator.
    if (!builtin.target.os.tag.isDarwin()) return std.heap.page_allocator;

    // On macOS we want to tag our memory so we can assign it to our
    // core terminal usage.
    const mach = @import("../os/mach.zig");
    return mach.taggedPageAllocator(.application_specific_1);
}

이제 macOS에서 메모리를 디버깅할 때 Ghostty의 PageList 메모리는 다른 모든 메모리와 뒤섞이지 않고 특정 태그와 함께 표시됩니다. 덕분에 누수를 식별하고 PageList와 연관 짓는 것이 매우 쉬워졌고, 태그된 메모리가 제대로 해제되는 것을 확인해 수정이 제대로 동작함을 검증할 수도 있었습니다.


Ghostty에서 누수 방지하기

Ghostty 프로젝트에서는 메모리 누수를 찾아 예방하기 위해 많은 노력을 기울이고 있습니다:

  • 디버그 빌드와 단위 테스트에서는 누수를 감지하는 Zig 할당자를 사용합니다.
  • CI에서는 매 커밋마다 전체 단위 테스트 스위트에 대해 valgrind를 실행해 누수뿐 아니라 정의되지 않은 메모리 사용 같은 문제도 찾습니다.
  • 특히 Swift 코드베이스의 누수를 찾기 위해 macOS GUI를 macOS Instruments로 정기적으로 실행합니다.
  • 단위 테스트로 커버되지 않는 GTK 코드 경로의 누수를 찾기 위해 모든 GTK 관련 PR을 Valgrind(전체 GUI)로 실행합니다.

지금까지는 이 방식이 매우 잘 동작했지만, 아쉽게도 이번 누수는 테스트에서 재현하지 못한 매우 특정한 조건에서만 발생했기 때문에 잡아내지 못했습니다. 병합된 PR에는 향후 재발을 방지하기 위해 누수를 재현하는 테스트가 포함되어 있습니다.


결론

이번 건은 지금까지 Ghostty에서 알려진 가장 큰 메모리 누수였으며, 한 명 이상의 사용자에게서 확인된 유일한 누수 제보이기도 합니다. 앞으로도 메모리 관련 제보를 계속 모니터링하고 대응하겠지만, 재현이야말로 메모리 누수를 진단하고 해결하는 핵심이라는 점을 기억해 주세요!

안정적으로 재현할 수 있는 방법을 마침내 제공해 직접 문제를 분석할 수 있게 도와준 @grishy님께 큰 감사를 전합니다. 그분의 분석도 저와 같은 결론에 도달했고, 재현 사례 덕분에 두 분석을 각각 독립적으로 검증할 수 있었습니다.

자세한 진단 정보와 함께 이슈를 제보해 주신 모든 분들께도 감사드립니다. 특히 footprint 출력과 VM 영역 카운팅에 관한 커뮤니티의 분석은 PageList가 원인임을 가리키는 중요한 단서가 되었습니다.

각주

  1. 이 글은 AI 없이 작성되었습니다. 다이어그램 일부 작성에 AI의 도움을 받았지만, 모두 사람이 정확성을 검토했습니다. 본문 텍스트는 AI로 생성되지 않았습니다.

  2. 이 이유는 이 블로그 글에서는 중요하지 않지만, 그 자체로 흥미로운 세부 사항입니다.

이 글은 muse-spark-1.2-contributor 모델을 사용해 번역했습니다.

댓글