Gejala Masalah: Lonjakan Memori dan UI Freeze

Saat membangun antarmuka terminal (TUI) interaktif dengan framework Bubble Tea di Golang, responsivitas I/O adalah prioritas utama. Masalah performa sering muncul pada skenario navigasi direktori cepat (rapid file traversal). Ketika pengguna menekan tombol navigasi atas/bawah secara beruntun, konsumsi memori (RSS) melonjak drastis dan antarmuka mengalami frame drop atau UI freeze sementara.

Framework Bubble Tea mengeksekusi fungsi tea.Cmd di dalam goroutine terpisah. Jika lifecycle goroutine tersebut tidak dikelola dengan benar saat pembacaan filesystem (I/O traversal), goroutine lama akan tetap aktif di background meski hasilnya sudah tidak dibutuhkan lagi.

Reproduksi dan Analisis Root Cause

Penyebab utama dari masalah ini adalah kombinasi dari dua pola anti-pattern: penggunaan unbuffered channel di dalam asynchronous task dan ketiadaan pembatalan operasi via context.Context saat state TUI berganti.

Contoh Implementasi Bermasalah (Vulnerable Code)

Berikut pola kode yang memicu goroutine leak pada Bubble Tea command:

func fetchDirectoryContents(path string) tea.Cmd {
    return func() tea.Msg {
        ch := make(chan []os.DirEntry) // Unbuffered channel

        go func() {
            entries, _ := os.ReadDir(path)
            // Terjebak selamanya jika receiver tidak membaca channel ini
            ch <- entries
        }()

        // Jika timeout atau UI berpindah state sebelum baca selesai:
        select {
        case res := <-ch:
            return DirReadMsg{Entries: res}
        case <-time.After(100 * time.Millisecond):
            // Receiver keluar, goroutine di atas block saat ch <- entries
            return ErrTimeoutMsg{}
        }
    }
}

Ketika pembacaan filesystem membutuhkan waktu lebih dari 100ms, fungsi tea.Cmd mengembalikan ErrTimeoutMsg. Goroutine internal mencoba mengirim data ke unbuffered channel ch, namun tidak ada receiver yang tersisa. Akibatnya, goroutine tersebut terblokir secara permanen di memori.

Identifikasi Menggunakan Runtime pprof

Untuk membuktikan kebocoran goroutine, pasang endpoint pprof atau dumping stack trace goroutine secara lokal ke terminal:

import (
    "os"
    "runtime/pprof"
)

func dumpGoroutines() {
    f, _ := os.Create("goroutine.dump")
    defer f.Close()
    pprof.Lookup("goroutine").WriteTo(f, 2)
}

Gunakan visualisasi CLI pprof untuk menganalisis tumpukan stack:

go tool pprof -top goroutine.dump

Output pprof menunjukkan pola penumpukan goroutine pada channel write:

Flat  Flat%   Sum%     Cum   Cum%
542  98.19% 98.19%     542 98.19%  runtime.gopark
  0   0.00% 98.19%     542 98.19%  runtime.chansend
  0   0.00% 98.19%     542 98.19%  main.fetchDirectoryContents.func1.1

Ratusan goroutine tertahan di runtime.chansend menunggu penerima yang tidak pernah ada.

Solusi: Context Cancellation dan Bounded Worker Pool

Perbaikan membutuhkan tiga langkah: membatalkan operasi aktif saat terjadi pergantian direktori, menghindari channel unbuffered yang menggantung, dan mengimplementasikan graceful teardown model.

1. Model Refactoring dengan Context Cancellation

Simpan referensi context.CancelFunc pada Bubble Tea model untuk membatalkan proses pembacaan file sebelumnya:

type Model struct {
    currentPath string
    entries     []os.DirEntry
    cancelIO    context.CancelFunc
}

func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
    switch msg := msg.(type) {
    case tea.KeyMsg:
        switch msg.String() {
        case "down", "j":
            // Batalkan I/O sebelumnya yang belum selesai
            if m.cancelIO != nil {
                m.cancelIO()
            }

            ctx, cancel := context.WithCancel(context.Background())
            m.cancelIO = cancel
            m.currentPath = "/target/path"

            return m, readDirCmd(ctx, m.currentPath)

        case "q", "ctrl+c":
            if m.cancelIO != nil {
                m.cancelIO()
            }
            return m, tea.Quit
        }
    case DirReadMsg:
        m.entries = msg.Entries
        return m, nil
    }
    return m, nil
}

2. Implementasi Safe tea.Cmd

Gunakan bounded buffer atau kirim hasil langsung tanpa blocking anonymous goroutine:

type DirReadMsg struct {
    Entries []os.DirEntry
    Err     error
}

func readDirCmd(ctx context.Context, path string) tea.Cmd {
    return func() tea.Msg {
        type result struct {
            entries []os.DirEntry
            err     error
        }

        // Bounded channel kapasitas 1 mencegah sender goroutine terblokir
        done := make(chan result, 1)

        go func() {
            entries, err := os.ReadDir(path)
            done <- result{entries: entries, err: err}
        }()

        select {
        case <-ctx.Done():
            // Selesai jika dibatalkan oleh navigasi baru atau quit
            return nil
        case res := <-done:
            return DirReadMsg{Entries: res.entries, Err: res.err}
        }
    }
}

Verifikasi Unit Test Menggunakan Goleak

Gunakan library go.uber.org/goleak untuk mendeteksi goroutine leak secara otomatis di dalam pipeline automated testing.

package main

import (
    "context"
    "testing"
    "time"

    "go.uber.org/goleak"
)

func TestReadDirCmd_NoGoroutineLeak(t *testing.T) {
    defer goleak.VerifyNone(t)

    ctx, cancel := context.WithCancel(context.Background())

    cmd := readDirCmd(ctx, ".")

    // Simulasikan pembatalan cepat (rapid navigation)
    cancel()

    // Eksekusi tea.Cmd
    _ = cmd()

    // Beri jeda singkat untuk runtime scheduler menyelesaikan teardown
    time.Sleep(10 * time.Millisecond)
}

Jika masih ada goroutine yang tertinggal di background, goleak.VerifyNone akan menggagalkan unit test dan menampilkan ID goroutine beserta baris kode sumber yang bocor.

Ringkasan

Goroutine leak pada Bubble Tea TUI umumnya berakar dari tea.Cmd yang memanggil channel blocking tanpa memperhitungkan siklus hidup navigasi UI. Terapkan strategi berikut untuk menjaga aplikasi TUI tetap efisien:

  • Selalu gunakan context.WithCancel untuk memutus async task saat state model berpindah.
  • Gunakan channel dengan buffer berkapasitas cukup (minimal make(chan T, 1)) bila pengiriman data terjadi dari goroutine terpisah ke blok select.
  • Pastikan cleanup logic terpanggil saat menangani key binding keluar seperti tea.Quit.
  • Pasang goleak pada unit test paket command untuk memvalidasi pembersihan goroutine.