Gejala Sistem: Ledakan File Descriptor dan Error EMFILE

Saat backend menangani ribuan koneksi persisten (seperti WebSocket, gRPC, atau raw TCP) dari klien mobile, kebocoran resource sering tidak bersumber dari alokasi memori internal runtime, melainkan dari level socket OS. Gejala klinis yang biasa teramati pada monitoring backend meliputi:

  • Socket Pool Jenuh: Peningkatan konstan pada jumlah koneksi aktif tanpa diimbangi lonjakan traffic transaksi yang valid.
  • Lonjakan File Descriptor (FD): Backend mulai menghasilkan log EMFILE: too many open files atau ENFILE: file table overflow saat memproses handshake baru.
  • Reverse Proxy Timeout: Nginx atau Envoy di depan klaster backend memunculkan status 502 Bad Gateway atau 504 Gateway Timeout akibat socket worker habis.
  • Connection Refused: Klien baru gagal melakukan inisiasi TCP handshake karena backlog antrean SYN/accept sistem operasi telah penuh.

Akar Masalah: Karakteristik TCP Half-Open pada Klien Mobile

TCP adalah protokol berstatus full-duplex yang membutuhkan pertukaran kontrol paket (FIN-ACK atau RST) untuk menutup koneksi secara bersih. Keadaan half-open terjadi ketika salah satu sisi koneksi terputus tanpa memberi tahu sisi lainnya. Sisi yang tertinggal (dalam hal ini backend) tetap menganggap koneksi dalam status ESTABLISHED.

Pada aplikasi React Native, kondisi ini umum terjadi akibat perilaku sistem operasi mobile:

  1. Suspension dan OOM Killer: Saat aplikasi beralih ke background di iOS atau Android, OS membekukan eksekusi thread JavaScript. Jika OS kehabisan memori, sistem akan mengirim sinyal SIGKILL langsung ke proses aplikasi. Runtime React Native tidak memiliki kesempatan mengeksekusi callback penutupan socket.
  2. Radio Resource Control (RRC) State: Transisi radio seluler dari aktif ke mode low-power atau pemutusan jaringan mendadak (masuk lift, subway, area blank spot) memutuskan link fisik secara tiba-tiba tanpa sempat melakukan 4-way TCP handshake.
  3. NAT Mapping Expiration: Carrier-Grade NAT (CGNAT) pada operator seluler menghapus translasi IP/port publik ke perangkat jika tidak ada lalu lintas data dalam durasi tertentu. Paket tidak dapat lagi diteruskan ke perangkat, sementara backend tetap menganggap socket aktif.
Linux default kernel mengonfigurasi tcp_keepalive_time selama 7200 detik (2 jam). Jika aplikasi mobile mati mendadak, socket server akan menggantung dan memakan alokasi file descriptor selama setidaknya dua jam secara cuma-cuma.

Langkah Isolasi: Audit Socket Menggunakan ss dan lsof

Untuk mengonfirmasi bahwa penumpukan FD disebabkan oleh half-open TCP dan bukan kebocoran memori aplikasi, lakukan isolasi di server Linux target.

1. Hitung Penggunaan File Descriptor Proses Backend

Ambil PID dari proses backend, lalu periksa penggunaan file descriptor terhadap limit sistem:

# Identifikasi PID proses backend
pgrep -f "node|go|server"

# Hitung socket terbuka milik proses
lsof -n -P -p <PID> | grep -c "sock"

# Periksa soft & hard limit FD pada proses
cat /proc/<PID>/limits | grep "Max open files"

2. Analisis Status TCP Socket Menggunakan ss

Perintah ss jauh lebih efisien daripada netstat lama untuk memeriksa state kernel TCP:

# Tampilkan koneksi ESTABLISHED pada port backend beserta timer keepalive
ss -ton state established '( sport = :8080 )'

# Filter socket yang tidak memiliki pergerakan timer atau timer default 2 jam
ss -ti '( sport = :8080 )'

Perhatikan kolom timer:(keepalive,...) pada output. Jika timer tidak aktif, atau terpasang pada nilai jam (7200s), socket backend tidak akan pernah menutup secara otomatis jika paket klien hilang tanpa jejak.

Solusi Sisi Server

Penanganan di backend berfokus pada deteksi dini socket mati dan pembersihan agresif terhadap koneksi zombie.

1. Optimasi Kernel TCP Keepalive

Ubah konfigurasi kernel Linux untuk memangkas masa hidup koneksi tanpa aktivitas melalui /etc/sysctl.conf:

# Waktu idle sebelum probe pertama dikirim (detik)
net.ipv4.tcp_keepalive_time = 300

# Interval antar pengiriman probe jika tidak ada respons (detik)
net.ipv4.tcp_keepalive_intvl = 15

# Jumlah kegagalan probe sebelum koneksi diputus paksa (RST)
net.ipv4.tcp_keepalive_probes = 4

Terapkan tanpa restart: sysctl -p. Konfigurasi di atas memutus socket zombie dalam waktu maksimal 360 detik (300 + 4 × 15) bukan 7200 detik.

2. Konfigurasi Timeout pada Reverse Proxy (Nginx)

Jika menggunakan Nginx sebagai proxy di depan service WebSocket atau HTTP streaming, batasi idle timeout:

location /ws/ {
    proxy_pass http://backend_upstream;
    proxy_http_version 1.1;
    proxy_set_header Upgrade $http_upgrade;
    proxy_set_header Connection "Upgrade";

    # Putus koneksi jika klien tidak mengirim traffic selama 60 detik
    proxy_read_timeout 60s;
    proxy_send_timeout 60s;
}

3. Heartbeat / Ping-Pong Application-Layer

TCP Keepalive hanya memverifikasi layer transport, bukan responsivitas layer aplikasi. Terapkan ping-pong berkala di layer aplikasi (contoh WebSocket server Node.js menggunakan package ws):

import { WebSocketServer, WebSocket } from 'ws';

const wss = new WebSocketServer({ port: 8080 });

function heartbeat(this: WebSocket & { isAlive?: boolean }) {
  this.isAlive = true;
}

wss.on('connection', (ws: WebSocket & { isAlive?: boolean }) => {
  ws.isAlive = true;
  ws.on('pong', heartbeat);
});

const interval = setInterval(() => {
  wss.clients.forEach((ws: WebSocket & { isAlive?: boolean }) => {
    if (ws.isAlive === false) {
      // Socket tidak merespons pong sebelumnya: terminasi paksa FD
      return ws.terminate();
    }
    ws.isAlive = false;
    ws.ping();
  });
}, 30000);

wss.on('close', () => {
  clearInterval(interval);
});

Penanganan Sisi Klien React Native

Aplikasi React Native harus secara proaktif menutup koneksi saat thread aplikasi akan dibekukan oleh sistem atau saat jaringan terputus.

Integrasi AppState dan NetInfo

Gunakan listener status aplikasi dan status jaringan untuk mengontrol siklus hidup socket:

import React, { useEffect, useRef } from 'react';
import { AppState, AppStateStatus } from 'react-native';
import NetInfo, { NetInfoState } from '@react-native-community/netinfo';

export const useManagedSocket = (socketUrl: string) => {
  const socketRef = useRef<WebSocket | null>(null);
  const appStateRef = useRef<AppStateStatus>(AppState.currentState);

  const disconnectSocket = () => {
    if (socketRef.current) {
      // 1000 = Normal Closure, memberitahu backend untuk melepas FD segera
      socketRef.current.close(1000, 'Client going background or offline');
      socketRef.current = null;
    }
  };

  const connectSocket = () => {
    if (!socketRef.current || socketRef.current.readyState === WebSocket.CLOSED) {
      socketRef.current = new WebSocket(socketUrl);
      socketRef.current.onopen = () => {
        // Reset state atau inisialisasi autentikasi jika diperlukan
      };
    }
  };

  useEffect(() => {
    // 1. Tangani perubahan status aplikasi (Foreground <-> Background)
    const appStateSub = AppState.addEventListener('change', (nextAppState: AppStateStatus) => {
      if (
        appStateRef.current === 'active' &&
        nextAppState.match(/inactive|background/)
      ) {
        disconnectSocket();
      } else if (
        appStateRef.current.match(/inactive|background/) &&
        nextAppState === 'active'
      ) {
        connectSocket();
      }
      appStateRef.current = nextAppState;
    });

    // 2. Tangani perubahan status jaringan
    const netInfoSub = NetInfo.addEventListener((state: NetInfoState) => {
      if (!state.isConnected || !state.isInternetReachable) {
        disconnectSocket();
      } else if (appStateRef.current === 'active') {
        connectSocket();
      }
    });

    // Inisialisasi awal saat mount
    connectSocket();

    return () => {
      disconnectSocket();
      appStateSub.remove();
      netInfoSub();
    };
  }, [socketUrl]);
};

Trade-off dan Evaluasi

  • Baterai vs Deteksi Cepat: Menyetel ping interval terlalu agresif (misal di bawah 10 detik) mencegah chip radio mobile masuk ke mode low-power, menguras baterai ponsel pengguna. Interval 30-45 detik adalah titik kompromi optimal.
  • Reconnect Churn (Thundering Herd): Jika ribuan klien reconnect bersamaan saat jaringan pulih, backend bisa mengalami lonjakan CPU. Pastikan klien React Native mengimplementasikan reconnect dengan exponential backoff disertai jitter.
  • Reverse Proxy Timeout vs Long Polling: Penyesuaian proxy_read_timeout yang terlalu pendek dapat memutus query HTTP regular yang membutuhkan waktu proses lama. Pastikan endpoint socket dipisahkan ke routing blok lokasi tersendiri.