Jika layanan Rust Axum Anda tiba-tiba mulai mengembalikan error beruntun setelah satu request gagal, salah satu penyebab yang sering terlewat adalah Mutex poisoning pada shared state. Masalah ini muncul ketika sebuah thread panic saat masih memegang std::sync::Mutex, lalu lock berikutnya mengembalikan PoisonError karena data di dalam critical section mungkin berada dalam kondisi tidak konsisten.

Pada artikel ini, kita bahas studi kasus debugging backend Rust Axum: Debug Panic Mutex Poisoning pada State Bersama dari sudut pandang operasional: gejala di staging/produksi, cara reproduksi bug, root cause teknis, mengapa sulit dilacak, dan bagaimana memperbaikinya secara bertahap tanpa langsung melakukan refactor besar.

Konteks Arsitektur Singkat

Bayangkan sebuah service Axum yang menyimpan state bersama di memori untuk keperluan cache sederhana, rate limiter, atau registry sesi. Struktur yang sering ditemui kurang lebih seperti ini:

use axum::{extract::State, routing::post, Json, Router};
use serde::{Deserialize, Serialize};
use std::{collections::HashMap, sync::{Arc, Mutex}};

#[derive(Default)]
struct AppState {
    sessions: Mutex<HashMap<String, Session>>,
}

#[derive(Clone, Debug, Serialize, Deserialize)]
struct Session {
    user_id: String,
    counter: u64,
}

type SharedState = Arc<AppState>;

Pola ini valid untuk kasus tertentu, tetapi menjadi rawan saat:

  • handler memegang lock terlalu lama,
  • ada operasi yang bisa panic di dalam critical section,
  • error handling bercampur dengan asumsi bahwa lock selalu berhasil,
  • state mutable memuat beberapa tanggung jawab sekaligus.

Dalam Axum, banyak request akan diproses secara konkuren. Jika satu request merusak state lock dengan panic saat memegang std::sync::Mutex, request lain yang mencoba mengambil lock akan mulai gagal juga. Dari luar, gejalanya terlihat seperti kerusakan sistemik, padahal pemicunya bisa satu jalur kode yang sangat spesifik.

Gejala di Staging atau Produksi

Pola error yang terlihat

Gejala umum biasanya bukan langsung “mutex poisoning” yang jelas di dashboard. Yang lebih sering terlihat:

  • satu endpoint tertentu mulai memicu lonjakan 500,
  • request pertama gagal karena panic,
  • request berikutnya ikut gagal walaupun inputnya valid,
  • setelah restart service, masalah hilang sementara.

Contoh log yang realistis:

thread 'tokio-runtime-worker' panicked at 'called `Option::unwrap()` on a `None` value', src/handler.rs:87:18

ERROR request{method=POST uri=/sessions/refresh}: handler failed: panicked while processing request

ERROR request{method=POST uri=/sessions/refresh}: failed to acquire session lock: poisoned lock: another task failed inside

ERROR request{method=GET uri=/sessions/me}: internal server error

Atau jika kodenya menggunakan lock().unwrap() di banyak tempat:

thread 'tokio-runtime-worker' panicked at 'called `Result::unwrap()` on an `Err` value: PoisonError { .. }', src/state.rs:42:39

Di sinilah panic menjadi berantai. Panic pertama terjadi karena bug bisnis. Setelah itu, lock menjadi poisoned. Lalu handler-handler lain yang masih memakai .lock().unwrap() ikut panic juga, walaupun mereka tidak memiliki bug awal yang sama.

Mengapa ini terlihat seperti insiden acak

Masalah ini sulit dibaca dari gejala saja karena:

  • request yang memicu root cause belum tentu request yang paling banyak gagal,
  • handler lain bisa menjadi korban sekunder karena mengakses state yang sama,
  • setelah service restart, state kembali bersih sehingga bug tampak “hilang”,
  • jika logging minim, yang terlihat hanya panic di PoisonError, bukan penyebab awalnya.

Cara Mereproduksi Bug

Reproduksi lokal sangat membantu karena poisoning baru terasa jelas jika panic terjadi saat lock masih dipegang. Contoh handler bermasalah:

use axum::{extract::State, Json};
use serde::Deserialize;
use std::sync::Arc;

#[derive(Deserialize)]
struct RefreshRequest {
    session_id: String,
    force_bug: bool,
}

async fn refresh_session(
    State(state): State<Arc<AppState>>,
    Json(payload): Json<RefreshRequest>,
) -> String {
    let mut sessions = state.sessions.lock().unwrap();

    let session = sessions.get_mut(&payload.session_id).unwrap();

    if payload.force_bug {
        panic!("simulasi panic saat lock masih dipegang");
    }

    session.counter += 1;
    format!("counter={}", session.counter)
}

Urutan reproduksi:

  1. Siapkan satu session di dalam state.
  2. Kirim request dengan force_bug=true.
  3. Request tersebut panic ketika lock masih aktif.
  4. Kirim request berikutnya ke endpoint yang sama atau endpoint lain yang memakai mutex yang sama.
  5. Jika kode masih memakai lock().unwrap(), request berikutnya akan panic karena PoisonError.

Contoh pengujian sederhana dengan curl:

curl -X POST http://localhost:3000/sessions/refresh \
  -H 'content-type: application/json' \
  -d '{"session_id":"abc","force_bug":true}'

curl -X POST http://localhost:3000/sessions/refresh \
  -H 'content-type: application/json' \
  -d '{"session_id":"abc","force_bug":false}'

Request kedua bisa gagal walaupun input valid, bukan karena data salah, tetapi karena mutex sudah poisoned oleh request sebelumnya.

Root Cause Teknis

Apa itu Mutex poisoning

Pada std::sync::Mutex, jika sebuah thread panic saat masih memegang lock, mutex ditandai sebagai poisoned. Saat thread lain mencoba mengambil lock, hasilnya menjadi Result::Err(PoisonError<MutexGuard<T>>).

Alasannya masuk akal: panic di tengah mutasi state bisa meninggalkan data dalam kondisi setengah berubah. Library standar memilih memberi sinyal eksplisit bahwa konsistensi data patut diragukan.

Mengapa bug ini terjadi di Axum

Axum sendiri bukan penyebab poisoning. Penyebab utamanya adalah kombinasi beberapa hal:

  • std::sync::Mutex dipakai sebagai shared mutable state,
  • ada panic di dalam area yang dilindungi lock,
  • handler lain mengasumsikan lock tidak pernah gagal, biasanya dengan .unwrap(),
  • critical section terlalu besar sehingga lebih banyak operasi berisiko dilakukan saat lock aktif.

Panic pertama bisa datang dari banyak sumber:

  • unwrap() pada Option atau Result,
  • akses indeks yang tidak valid,
  • assertion yang gagal,
  • panic eksplisit dari jalur kode defensif,
  • bug parsing atau transformasi data yang seharusnya menjadi error biasa.

Mengapa sulit dilacak

Poisoning sering menjadi secondary failure. Root cause asli terjadi lebih dulu, tetapi observabilitas justru menangkap efek berikutnya:

  • panic awal hanya muncul sekali,
  • log berikutnya didominasi oleh PoisonError,
  • stack trace pada request korban mengarah ke lock().unwrap(), bukan lokasi panic pertama,
  • jika traffic tinggi, request lain cepat menimpa konteks log.

Akibatnya, tim bisa salah fokus: memperbaiki semua titik PoisonError tanpa menemukan panic awal yang menyebabkan mutex poisoned.

Langkah Diagnosis: Logging, Backtrace, dan Review Alur Konkurensi

1. Cari panic pertama, bukan hanya panic lanjutan

Saat melihat PoisonError, anggap itu gejala. Fokus pencarian harus ke panic paling awal pada rentang waktu yang sama.

Hal yang perlu dicari di log:

  • pesan panicked at ...,
  • lokasi file dan baris,
  • request id, session id, user id, atau correlation id yang terkait,
  • handler mana yang sedang berjalan saat panic pertama terjadi.

Jika belum ada request-scoped logging, tambahkan. Minimal, setiap handler menulis metadata penting sebelum mengambil lock dan sesudah melepaskannya.

2. Aktifkan backtrace

Saat debugging lokal atau staging, jalankan service dengan backtrace aktif:

RUST_BACKTRACE=1 cargo run

Jika membutuhkan detail lebih lengkap:

RUST_BACKTRACE=full cargo run

Backtrace penting untuk membedakan:

  • lokasi panic awal di dalam critical section,
  • lokasi panic lanjutan akibat lock().unwrap() pada mutex yang sudah poisoned.

3. Tambahkan logging di sekitar lock

Jangan hanya log saat error. Log transisi penting di sekitar shared state akan membantu merekonstruksi urutan kejadian.

use tracing::{error, info, warn};

async fn refresh_session(
    State(state): State<Arc<AppState>>,
    Json(payload): Json<RefreshRequest>,
) -> Result<String, AppError> {
    info!(session_id = %payload.session_id, "akan mengambil lock sessions");

    let mut sessions = match state.sessions.lock() {
        Ok(guard) => guard,
        Err(err) => {
            error!(session_id = %payload.session_id, "sessions lock poisoned");
            return Err(AppError::Internal("state sessions tidak konsisten".into()));
        }
    };

    info!(session_id = %payload.session_id, "lock sessions berhasil diambil");

    let session = sessions
        .get_mut(&payload.session_id)
        .ok_or_else(|| AppError::NotFound("session tidak ditemukan".into()))?;

    session.counter += 1;
    Ok(format!("counter={}", session.counter))
}

Dengan pola ini, Anda bisa melihat apakah panic terjadi sebelum lock, sesudah lock, atau saat memproses data di dalamnya.

4. Review alur konkurensi, bukan hanya satu fungsi

Masalah shared state jarang selesai dengan membaca satu handler saja. Periksa:

  • siapa saja yang mengakses mutex yang sama,
  • apakah ada beberapa endpoint yang membaca/menulis struktur yang sama,
  • apakah ada operasi lambat di dalam lock, seperti serialisasi besar, validasi kompleks, atau panggilan I/O,
  • apakah terdapat nested lock atau urutan lock yang berpotensi memperumit debugging.

Tujuan review ini adalah memahami seberapa luas blast radius dari satu mutex yang poisoned.

Perbaikan Bertahap

1. Perkecil critical section

Perbaikan pertama yang paling praktis adalah memastikan lock hanya dipegang selama mutasi state yang benar-benar perlu. Jangan letakkan logika yang berisiko panic atau mahal di dalam lock jika bisa dipindahkan ke luar.

Sebelum:

async fn refresh_session(
    State(state): State<Arc<AppState>>,
    Json(payload): Json<RefreshRequest>,
) -> Result<String, AppError> {
    let mut sessions = state.sessions.lock().unwrap();

    let session = sessions
        .get_mut(&payload.session_id)
        .ok_or_else(|| AppError::NotFound("session tidak ditemukan".into()))?;

    let parsed_counter: u64 = payload.session_id.parse().unwrap();
    session.counter += parsed_counter;

    Ok(format!("counter={}", session.counter))
}

Masalahnya: parsing yang bisa gagal justru dilakukan saat lock aktif.

Sesudah:

async fn refresh_session(
    State(state): State<Arc<AppState>>,
    Json(payload): Json<RefreshRequest>,
) -> Result<String, AppError> {
    let increment: u64 = payload
        .session_id
        .parse()
        .map_err(|_| AppError::BadRequest("session_id tidak valid untuk increment".into()))?;

    let new_value = {
        let mut sessions = state
            .sessions
            .lock()
            .map_err(|_| AppError::Internal("state sessions tidak konsisten".into()))?;

        let session = sessions
            .get_mut(&payload.session_id)
            .ok_or_else(|| AppError::NotFound("session tidak ditemukan".into()))?;

        session.counter += increment;
        session.counter
    };

    Ok(format!("counter={}", new_value))
}

Dengan ini, operasi yang bisa gagal dipindahkan sebelum lock. Critical section menjadi lebih pendek dan lebih aman.

2. Hindari panic di dalam lock

Untuk handler produksi, unwrap(), expect(), dan assertion di dalam critical section sebaiknya dianggap red flag, kecuali Anda benar-benar sedang menangani invariant internal yang sudah dibuktikan.

Ganti panic-prone code dengan error biasa:

  • Option::unwrap() -> ok_or_else(...)?
  • Result::unwrap() -> map_err(...)?
  • assertion input -> validasi request dan kembalikan 4xx

Ini bukan hanya soal gaya. Dalam konteks shared state, panic dapat mengubah kegagalan lokal menjadi kegagalan berantai.

3. Tangani PoisonError dengan aman, bila relevan

Menangani PoisonError tidak selalu berarti “abaikan dan lanjutkan”. Keputusan yang benar tergantung apakah data di dalam mutex masih dapat dipercaya.

Pola konservatif:

let mut sessions = state
    .sessions
    .lock()
    .map_err(|_| AppError::Internal("state sessions poisoned".into()))?;

Pola ini cocok jika:

  • state sangat penting untuk konsistensi,
  • mutasi sebelumnya bisa meninggalkan data setengah jadi,
  • lebih aman gagal cepat daripada melanjutkan dengan state yang diragukan.

Dalam beberapa kasus, Anda mungkin memilih memulihkan guard dengan into_inner() dari PoisonError lalu melakukan validasi atau reset data. Namun ini hanya aman jika Anda benar-benar paham invariant state tersebut.

let mut sessions = match state.sessions.lock() {
    Ok(guard) => guard,
    Err(poisoned) => {
        warn!("sessions lock poisoned, mencoba recovery terbatas");
        poisoned.into_inner()
    }
};

Trade-off penting: recovery seperti ini bisa menjaga service tetap hidup, tetapi berisiko melanjutkan dengan state yang tidak konsisten. Gunakan hanya jika ada strategi validasi, rebuild, atau reset yang jelas.

4. Refactor state agar lebih tahan gagal

Kalau satu mutex melindungi struktur besar dan dipakai banyak endpoint, blast radius-nya besar. Refactor yang umum:

  • pecah state besar menjadi beberapa lock yang lebih spesifik,
  • pisahkan data yang sering dibaca dari data yang sering ditulis,
  • simpan invariant kompleks di layer yang lebih terkontrol,
  • hindari menjadikan mutex global sebagai tempat semua mutasi aplikasi.

Contoh refactor sederhana:

#[derive(Default)]
struct AppState {
    sessions: Mutex<HashMap<String, Session>>,
    metrics: Mutex<MetricsState>,
}

Lebih baik daripada satu mutex besar yang membungkus semuanya:

#[derive(Default)]
struct AppState {
    inner: Mutex<GlobalState>,
}

#[derive(Default)]
struct GlobalState {
    sessions: HashMap<String, Session>,
    metrics: MetricsState,
    cache: HashMap<String, String>,
}

Dengan pemisahan ini, panic pada mutasi sesi tidak otomatis mengganggu akses metrik atau cache yang tidak terkait.

5. Pertimbangkan desain yang mengurangi shared mutable state

Jika state makin rumit, sering kali solusi jangka panjang bukan “menangani mutex lebih hati-hati”, melainkan mengurangi kebutuhan lock global:

  • pindahkan state persisten ke database,
  • gunakan message passing untuk mutasi tertentu,
  • simpan cache yang bisa direbuild tanpa invariant kritis,
  • gunakan struktur konkurensi lain hanya jika memang sesuai dengan pola akses.

Tujuannya bukan menghindari mutex sepenuhnya, tetapi memastikan mutex tidak menjadi titik kegagalan tunggal untuk banyak request.

Cuplikan Sebelum dan Sesudah

Sebelum: rawan panic berantai

async fn get_me(
    State(state): State<Arc<AppState>>,
    user_id: String,
) -> String {
    let sessions = state.sessions.lock().unwrap();
    let session = sessions.get(&user_id).unwrap();
    serde_json::to_string(session).unwrap()
}

Masalah:

  • lock().unwrap() panic saat poisoned,
  • get(...).unwrap() panic jika session tidak ada,
  • serialisasi dilakukan saat lock masih dipegang.

Sesudah: lebih defensif dan blast radius lebih kecil

async fn get_me(
    State(state): State<Arc<AppState>>,
    user_id: String,
) -> Result<String, AppError> {
    let session = {
        let sessions = state
            .sessions
            .lock()
            .map_err(|_| AppError::Internal("state sessions poisoned".into()))?;

        sessions
            .get(&user_id)
            .cloned()
            .ok_or_else(|| AppError::NotFound("session tidak ditemukan".into()))?
    };

    serde_json::to_string(&session)
        .map_err(|_| AppError::Internal("gagal serialisasi session".into()))
}

Perbaikan yang terjadi:

  • poisoning ditangani sebagai error terkontrol,
  • data yang dibutuhkan di-clone lalu lock dilepas lebih cepat,
  • serialisasi dipindah ke luar critical section,
  • panic dari unwrap() dihilangkan dari jalur normal.

Trade-off yang Perlu Dipahami

  • Menangani PoisonError dengan fail-fast lebih aman untuk konsistensi, tetapi bisa meningkatkan error rate sampai service di-restart atau state dipulihkan.
  • Memaksa recovery dari poisoned lock bisa menjaga availability, tetapi Anda harus siap menghadapi state yang mungkin korup secara logis.
  • Memperkecil critical section hampir selalu baik, tetapi kadang membutuhkan clone tambahan atau perubahan alur data.
  • Memecah state menjadi beberapa lock mengurangi blast radius, tetapi menambah kompleksitas desain dan potensi urutan lock yang harus dikelola hati-hati.
  • Menghilangkan panic-prone code meningkatkan robustness, tetapi memerlukan disiplin error handling yang konsisten di seluruh handler.

Checklist Pencegahan

  • Hindari unwrap(), expect(), dan assertion yang bisa panic di dalam critical section.
  • Pastikan operasi parsing, validasi, dan serialisasi dilakukan di luar lock bila memungkinkan.
  • Tangani hasil lock() sebagai Result, bukan asumsi pasti sukses.
  • Tambahkan logging sebelum dan sesudah pengambilan lock, terutama untuk state yang sering diakses.
  • Aktifkan backtrace saat investigasi staging atau reproduksi lokal.
  • Review shared state mana saja yang dipakai lintas endpoint dan tentukan blast radius-nya.
  • Pecah mutex besar menjadi lock yang lebih kecil jika satu state melayani terlalu banyak tanggung jawab.
  • Tulis test untuk jalur error, bukan hanya jalur sukses.
  • Pertimbangkan apakah mutable in-memory state memang perlu, atau sebaiknya dipindahkan ke storage/komponen lain.

Penutup

Dalam kasus Rust Axum: Debug Panic Mutex Poisoning pada State Bersama, bug utamanya sering bukan mutex itu sendiri, melainkan panic saat lock sedang dipegang. Poisoning kemudian memperluas dampak kegagalan ke request-request lain yang mengakses state yang sama.

Pendekatan yang paling efektif biasanya bertahap: temukan panic pertama dengan log dan backtrace, kecilkan critical section, hilangkan panic-prone code di dalam lock, tangani PoisonError dengan keputusan yang sadar trade-off, lalu refactor state agar satu kegagalan tidak merusak terlalu banyak jalur request. Dengan begitu, Anda bukan hanya memperbaiki satu bug, tetapi juga meningkatkan ketahanan arsitektur layanan secara keseluruhan.