Worker agent AI yang mengotomasi interaksi antarmuka perangkat bergerak (seperti pola pada lycorp-jp/sim-use) menjalankan ribuan perintah CLI per jam. Dalam skala produksi, worker sering mengalami degradasi performa bertahap hingga berhenti merespons (hung). Masalah ini umumnya dipicu oleh zombie process dan orphan process dari adb (Android Debug Bridge) serta xcrun simctl (iOS Simulator) akibat unhandled timeout saat eksekusi aksi atau UI assertion gagal.

Gejala: Thread Exhaustion dan Resource Starvation

Saat worker agent mengevaluasi state UI menggunakan Vision-Language Models (VLM) atau pohon hierarki DOM/Accessibility, kegagalan assertion sering kali memicu exception pada thread runtime (misalnya Python asyncio atau Node.js). Jika exception ini mengabaikan lifecycle child process, sub-proses ADB atau simctl akan tetap berjalan di background tanpa controller.

Dampak langsung pada sistem host meliputi:

  • Thread Exhaustion: Thread pool worker habis karena tertahan menunggu I/O pipe stdout/stderr dari child process yang tidak pernah selesai.
  • File Descriptor (FD) Leak: Socket koneksi daemon ADB dan pipe streaming video frame simulator tetap terbuka, menghabiskan alokasi limit ulimit -n sistem.
  • Proses Menumpuk (Zombie & Orphan): Perintah seperti adb exec-out screencap -p atau xcrun simctl io booted screenshot tetap aktif dan membebani CPU host secara kontinu.

Investigasi: Dump File Descriptor dan Process Tree

Untuk mengonfirmasi terjadinya kebocoran file descriptor dan sub-proses yang menggantung, periksa process tree dan alokasi FD pada PID worker agent.

# 1. Identifikasi process tree worker dan child process yang tertinggal
pstree -p <WORKER_PID>

# 2. Cek akumulasi child process adb dan simctl
ps aux | grep -E '(adb|simctl)' | grep -v grep

# 3. Hitung jumlah file descriptor yang terbuka pada proses worker
ls -l /proc/<WORKER_PID>/fd | wc -l

# 4. Dump detail FD untuk mendeteksi socket atau pipe bocor
lsof -p <WORKER_PID> | grep -E '(PIPE|FIFO|unix|IPv4)'

Output lsof yang menunjukkan ratusan handle bertipe FIFO atau unix socket dalam status CLOSE_WAIT atau tanpa pembaca membuktikan bahwa child process spawned terputus dari event loop induknya tanpa proses termination yang bersih.

Root Cause: Unhandled Timeout pada UI Assertion

Masalah berakar pada implementasi eksekusi command standar yang tidak mengisolasi sub-proses ke dalam process group tersendiri. Ketika library otomasi membungkus command dengan timeout level aplikasi (misalnya asyncio.wait_for(task, timeout=10)), timeout tersebut membatalkan coroutine induk tetapi tidak otomatis membunuh native OS process yang sudah di-fork.

Sub-proses terus memegang handle socket adb server atau daemon simulator. Ketika worker mencoba menjalankan assertion baru, command berikutnya antre di belakang lock daemon yang sama, memicu cascading failure di seluruh pipeline worker.

Solusi 1: Process Supervisor dengan POSIX Process Group

Gunakan POSIX session ID atau process group baru (setpgrp=True / os.setsid). Dengan memisahkan sub-proses ke group ID baru, sinyal SIGTERM dan SIGKILL dapat dikirim ke seluruh proses tree sub-proses tersebut (menggunakan tanda minus pada PID: -pgid), bukan hanya ke shell wrapper pembungkusnya.

import asyncio
import os
import signal

async def run_driver_command(cmd: list[str], timeout_sec: float) -> bytes:
    proc = await asyncio.create_subprocess_exec(
        *cmd,
        stdout=asyncio.subprocess.PIPE,
        stderr=asyncio.subprocess.PIPE,
        preexec_fn=os.setsid  # Buat process group tersendiri
    )
    pgid = os.getpgid(proc.pid)

    try:
        stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=timeout_sec)
        if proc.returncode != 0:
            raise RuntimeError(f"Command failed: {stderr.decode()}")
        return stdout
    except asyncio.TimeoutError:
        # Terminasi graceful seluruh process group
        os.killpg(pgid, signal.SIGTERM)
        try:
            # Tunggu grace period sebelum eksekusi paksa
            await asyncio.wait_for(proc.wait(), timeout=2.0)
        except asyncio.TimeoutError:
            # Eksekusi SIGKILL bila process abai terhadap SIGTERM
            os.killpg(pgid, signal.SIGKILL)
            await proc.wait()
        raise TimeoutError(f"Command {cmd[0]} melampaui limit {timeout_sec}s")
    finally:
        # Pastikan pipe stream tertutup bersih
        if proc.stdout and not proc.stdout.at_eof():
            proc.stdout.feed_eof()
        if proc.stderr and not proc.stderr.at_eof():
            proc.stderr.feed_eof()

Solusi 2: Healthcheck Socket ADB Sebelum Spawn Command

Menjalankan perintah langsung ke daemon yang sedang deadlocked akan langsung memblokir thread eksekusi. Implementasikan socket probe ringan langsung ke port ADB daemon (default: 5037) sebelum menjalankan eksekusi CLI.

import socket

def assert_adb_alive(host: str = "127.0.0.1", port: int = 5037, timeout: float = 1.0) -> None:
    """Validasi ketersediaan daemon socket ADB sebelum spawn sub-proses."""
    sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    sock.settimeout(timeout)
    try:
        sock.connect((host, port))
        # Kirim query versi ADB host protocol
        sock.sendall(b"000Chost:version")
        status = sock.recv(4)
        if status != b"OKAY":
            raise ConnectionError(f"ADB daemon merespons invalid status: {status!r}")
    except (socket.timeout, ConnectionRefusedError) as exc:
        raise ConnectionError(f"ADB daemon tidak merespons pada {host}:{port}") from exc
    finally:
        sock.close()

Catatan untuk iOS Simulator: Pada environment macOS, simctl bergantung pada sistem RPC launchd. Untuk memverifikasi status simulator sebelum eksekusi, gunakan probing perintah sinkronus ringan via status listing: xcrun simctl list devices | grep "(Booted)" dengan timeout ketat via native subprocess wrapper.

Best Practices Mencegah Resource Leak pada Automation Worker

  1. Gunakan Process Isolation: Selalu assign preexec_fn=os.setsid pada sistem UNIX untuk mengelompokkan sub-proses ke dalam satu PGID mandiri.
  2. Tutup File Descriptor: Pastikan flag close_fds=True aktif saat fork child process agar subprocess tidak mewarisi alokasi open socket milik host worker.
  3. Terapkan Context Cancellation: Ikat lifecycle child process langsung dengan lifecycle request HTTP/RPC dari Agent framework utama (seperti gRPC Context atau asyncio.TaskGroup).
  4. Monitor FD Metrics: Pasang alert jika metrik sistem process_open_fds pada worker pod/instance melampaui ambang batas 70% dari limit OS.