Kebocoran socket (socket leak) pada arsitektur WebSocket sering kali tidak langsung terdeteksi saat pengujian lokal. Di lingkungan produksi dengan ribuan koneksi konkuren, socket leak memicu peningkatan penggunaan memori secara bertahap, lonjakan file descriptor (FD), hingga memunculkan kegagalan sistemik dengan pesan OS error 24: Too many open files (EMFILE). Artikel ini mengulas akar masalah zombie connection pada Actix Web dan implementasi solusinya menggunakan siklus heartbeat.

Gejala Kegagalan: Dari FD Spike Hingga Crash Pod

Masalah kebocoran koneksi WebSocket biasanya menunjukkan pola kerusakan yang terprediksi:

  • Akumulasi File Descriptor: Metrik sistem menunjukkan metrik FD naik linier tanpa pernah turun signifikan, bahkan di luar jam sibuk ketika trafik pengguna mereda.
  • Kegagalan Alokasi Socket (EMFILE): Saat batas sistem (ulimit -n) terlampaui, worker thread Actix Web gagal memanggil syscall accept4(). Server menolak koneksi HTTP baru, liveness probe Kubernetes mulai gagal, dan log dipenuhi pesan kesalahan I/O OS.
  • OOMKilled atau Container Restart: Setiap koneksi WebSocket yang tertahan mengalokasikan memori buffer dan state actor. Ketika ratusan ribu actor terbengkalai di heap, pod kehabisan alokasi RAM dan dimatikan oleh Linux OOM Killer.

Akar Masalah: TCP Half-Open Connection

Protokol TCP mengandalkan pertukaran paket kontrol (four-way handshake dengan flag FIN atau segmen RST) untuk terminasi koneksi yang bersih. Masalah muncul ketika klien terputus secara abnormal (ungraceful termination), seperti:

  • Koneksi jaringan seluler terputus tiba-tiba (masuk terowongan atau sinyal hilang).
  • Kabel jaringan dicabut atau perangkat klien mati mendadak (kehabisan baterai).
  • Middlebox (NAT gateway, firewall korporat) menghapus state mapping dari connection tracking table secara sepihak tanpa mengirim paket RST ke server.

Pada skenario ini, kernel Linux di sisi server tidak menerima indikasi bahwa koneksi telah mati. Socket tetap berada pada status ESTABLISHED. Karena tidak ada operasi penulisan data yang gagal (yang memicu error Broken pipe atau ECONNRESET), runtime Actix Web menganggap koneksi masih aktif. Akibatnya, instance WebSocket actor tetap hidup di memory loop dan file descriptor-nya tidak pernah ditutup.

Solusi: Heartbeat Ping-Pong pada Actix Web Actor

Protokol WebSocket (RFC 6455) menyediakan frame kontrol khusus: Ping (opcode 0x9) dan Pong (opcode 0xA). Pendekatan yang andal adalah mendelegasikan tanggung jawab liveness check ke layer aplikasi via Actix Actor. Server mengirimkan frame Ping secara berkala dan memantau respons Pong dari klien. Jika respons tidak diterima dalam batas waktu toleransi, actor menghentikan dirinya sendiri via ctx.stop(), yang secara otomatis menutup socket underlying.

Implementasi Kode

Berikut adalah implementasi handler WebSocket dengan pelacakan timestamp heartbeat menggunakan actix-web-actors:

use std::time::{Duration, Instant};
use actix::prelude::*;
use actix_web::{web, Error, HttpRequest, HttpResponse};
use actix_web_actors::ws;

const HEARTBEAT_INTERVAL: Duration = Duration::from_secs(5);
const CLIENT_TIMEOUT: Duration = Duration::from_secs(10);

pub struct WsSession {
    last_heartbeat: Instant,
}

impl WsSession {
    pub fn new() -> Self {
        Self {
            last_heartbeat: Instant::now(),
        }
    }

    fn start_heartbeat(&self, ctx: &mut ws::WebsocketContext<Self>) {
        ctx.run_interval(HEARTBEAT_INTERVAL, |act, ctx| {
            // Evaluasi apakah klien melewati ambang batas timeout
            if Instant::now().duration_since(act.last_heartbeat) > CLIENT_TIMEOUT {
                log::warn!("Koneksi WebSocket heartbeat timeout, menghentikan actor");
                // ctx.stop() memicu lifecycle stopped(), menutup socket, dan melepas FD
                ctx.stop();
                return;
            }

            // Kirim ping kontrol frame ke klien
            ctx.ping(b"");
        });
    }
}

impl Actor for WsSession {
    type Context = ws::WebsocketContext<Self>;

    fn started(&mut self, ctx: &mut Self::Context) {
        // Daftarkan interval timer saat actor pertama kali dimulai
        self.start_heartbeat(ctx);
    }
}

impl StreamHandler<Result<ws::Message, ws::ProtocolError>> for WsSession {
    fn handle(&mut self, msg: Result<ws::Message, ws::ProtocolError>, ctx: &mut Self::Context) {
        match msg {
            Ok(ws::Message::Ping(msg)) => {
                self.last_heartbeat = Instant::now();
                ctx.pong(&msg);
            }
            Ok(ws::Message::Pong(_)) => {
                // Perbarui timestamp liveness saat pong diterima dari klien
                self.last_heartbeat = Instant::now();
            }
            Ok(ws::Message::Text(text)) => {
                // Tangani payload aplikasi
                ctx.text(text);
            }
            Ok(ws::Message::Binary(bin)) => {
                ctx.binary(bin);
            }
            Ok(ws::Message::Close(reason)) => {
                ctx.close(reason);
                ctx.stop();
            }
            _ => ctx.stop(),
        }
    }
}

pub async fn ws_route(req: HttpRequest, stream: web::Payload) -> Result<HttpResponse, Error> {
    ws::start(WsSession::new(), &req, stream)
}

Catatan: Browser modern yang mengimplementasikan Web API WebSocket akan merespons frame Ping dari server dengan frame Pong secara otomatis di level engine jaringan tanpa memerlukan kode JavaScript tambahan.

Verifikasi dan Debugging Socket

Untuk memvalidasi efektivitas penanganan zombie socket, lakukan observasi langsung pada level kernel dan sistem operasi Linux.

1. Menghitung File Descriptor Aktif

Periksa jumlah FD yang saat ini dialokasikan oleh proses Actix Web:

# Dapatkan PID dari proses server
PID=$(pgrep actix_server)

# Hitung total file descriptor yang terbuka
ls -1 /proc/$PID/fd | wc -l

# Tampilkan hanya socket TCP yang terhubung
lsof -a -p $PID -i TCP

2. Analisis Status Socket dengan ss

Gunakan utilitas ss untuk memantau koneksi yang tertahan:

ss -tan state established '( sport = :8080 or dport = :8080 )'

Uji skenario disconnect paksa dengan mengaktifkan mode pesawat pada perangkat pengujian atau gunakan utilitas iptables untuk memblokir paket tanpa pengiriman FIN:

# Simulasikan koneksi putus tiba-tiba dari klien
sudo iptables -A INPUT -p tcp --dport 8080 -s <CLIENT_IP> -j DROP

Setelah 10 detik (durasi CLIENT_TIMEOUT), jalankan kembali perintah lsof. Entri socket yang terhubung dengan IP target harus hilang dari daftar proses.

Monitoring Metrik Produksi

Hindari hanya mengandalkan deteksi error pada log. Pasang instrumentasi berbasis Prometheus untuk mendapatkan visibilitas real-time:

  • Gauge Koneksi Aktif: Tambahkan counter atomik atau gauge yang di-increment pada Actor::started() dan di-decrement pada Actor::stopped(). Jika nilai gauge terus naik saat metrik request traffic mendatar, indikasi leak masih ada.
  • Alerting FD Exhaustion: Konfigurasikan alert pada node exporter ketika process_open_fds / process_max_fds > 0.8. Tindakan mitigasi dapat diambil sebelum server mencapai status failure mutlak.