Uji API spawn proses Unix sering menjadi flaky karena proses anak tidak hidup dalam dunia yang sepenuhnya dikendalikan test: ada signal, file descriptor, environment, scheduler kernel, timeout, dan kondisi CI yang berubah-ubah. Strategi yang aman adalah memisahkan keputusan bisnis dari mekanisme spawn, menguji kontrak proses secara deterministik, lalu menjalankan sedikit integration test yang benar-benar terisolasi.

Artikel ini terinspirasi dari diskusi “Moving beyond fork() + exec()”, tetapi fokusnya bukan merangkum berita. Fokusnya adalah pola engineering yang bisa diterapkan saat kode Anda memanggil fork/exec, posix_spawn, atau wrapper runtime seperti subprocess, child_process, exec.Command, dan API sejenis.

Mengapa test spawn proses Unix mudah flaky?

Menjalankan proses terlihat sederhana: susun argv, set environment, panggil executable, tunggu exit code. Dalam praktiknya, banyak detail Unix yang membuat test tidak stabil jika tidak dikontrol.

  • Race signal: proses bisa selesai sebelum test mengirim SIGTERM, atau signal dikirim sebelum handler proses anak siap. Test yang bergantung pada urutan waktu semacam ini biasanya gagal sesekali.
  • Environment tidak bersih: PATH, HOME, LANG, TZ, variabel proxy, atau konfigurasi tool di mesin developer bisa berbeda dari CI.
  • File descriptor bocor: proses anak dapat mewarisi descriptor yang tidak sengaja terbuka. Ini bisa membuat pipe tidak pernah EOF, socket tetap hidup, atau file terkunci.
  • Timeout berbasis sleep: sleep 100 ms mungkin cukup di laptop, tetapi gagal saat runner CI lambat atau oversubscribed.
  • Zombie process: proses anak yang sudah exit tetapi tidak di-wait akan menjadi zombie. Dalam test suite panjang, ini dapat menumpuk dan mengganggu test lain.
  • Perbedaan kernel dan CI: limit proses, implementasi shell, permission, mount namespace, cgroup, dan policy container dapat mengubah perilaku tanpa mengubah kode aplikasi.

Prinsip utamanya: unit test jangan bergantung pada scheduler kernel, timing signal, atau executable eksternal. Hal-hal itu diuji dalam integration test yang kecil, eksplisit, dan mudah dibersihkan.

Gunakan wrapper abstraction untuk membatasi permukaan spawn

Jangan sebar pemanggilan spawn langsung di seluruh kode bisnis. Buat abstraksi kecil yang mewakili kontrak proses: executable, argv, environment tambahan, working directory, timeout, dan output yang dibutuhkan. Dengan cara ini, mayoritas test dapat menggunakan fake runner tanpa menjalankan proses Unix sungguhan.

Contoh berikut memakai Go karena API exec-nya cukup eksplisit, tetapi pola yang sama berlaku untuk bahasa lain.

type ProcessSpec struct {
    Path    string
    Args    []string
    Env     map[string]string
    Dir     string
    Timeout time.Duration
}

type Result struct {
    ExitCode int
    Stdout   []byte
    Stderr   []byte
}

type ProcessRunner interface {
    Run(ctx context.Context, spec ProcessSpec) (Result, error)
}

Implementasi production dapat memakai API spawn bawaan runtime. Detail seperti drain stdout/stderr, propagation context, dan penggabungan environment hanya ada di satu tempat.

type ExecRunner struct{}

func (ExecRunner) Run(ctx context.Context, spec ProcessSpec) (Result, error) {
    if spec.Timeout > 0 {
        var cancel context.CancelFunc
        ctx, cancel = context.WithTimeout(ctx, spec.Timeout)
        defer cancel()
    }

    cmd := exec.CommandContext(ctx, spec.Path, spec.Args...)
    cmd.Dir = spec.Dir
    cmd.Env = append(os.Environ(), envList(spec.Env)...)

    var stdout, stderr bytes.Buffer
    cmd.Stdout = &stdout
    cmd.Stderr = &stderr

    err := cmd.Run()
    return Result{
        ExitCode: exitCode(err),
        Stdout:   stdout.Bytes(),
        Stderr:   stderr.Bytes(),
    }, err
}

Keuntungan desain ini adalah test untuk kode bisnis tidak perlu memanggil /bin/sh, tidak perlu sleep, dan tidak perlu membaca PID. Test cukup memastikan kode menyusun ProcessSpec yang benar dan merespons hasil proses dengan benar.

Pola unit test: fake process runner, bukan proses nyata

Fake runner merekam request spawn dan mengembalikan hasil yang dikontrol test. Ini berguna untuk menguji cabang sukses, exit code gagal, stderr tertentu, timeout, atau error tanpa ketergantungan pada sistem operasi.

type FakeRunner struct {
    Calls  []ProcessSpec
    Result Result
    Err    error
}

func (f *FakeRunner) Run(ctx context.Context, spec ProcessSpec) (Result, error) {
    select {
    case <-ctx.Done():
        return Result{}, ctx.Err()
    default:
    }

    f.Calls = append(f.Calls, spec)
    return f.Result, f.Err
}

Dengan fake seperti ini, test dapat memverifikasi kontrak spawn secara langsung.

func TestBuildsGitCommand(t *testing.T) {
    fake := &FakeRunner{Result: Result{ExitCode: 0}}
    svc := NewRepoService(fake)

    err := svc.Clone(context.Background(), RepoRequest{
        URL: "https://example.invalid/repo.git",
        Dir: "/tmp/work",
    })
    if err != nil {
        t.Fatal(err)
    }

    got := fake.Calls[0]
    if got.Path != "/usr/bin/git" {
        t.Fatalf("path = %s", got.Path)
    }
    if !reflect.DeepEqual(got.Args, []string{"clone", "https://example.invalid/repo.git", "/tmp/work"}) {
        t.Fatalf("args = %#v", got.Args)
    }
}

Fake runner bukan pengganti integration test. Ia memastikan kode Anda membuat permintaan spawn yang benar dan menangani hasilnya. Apakah runtime benar-benar meneruskan argv/env/cwd ke kernel perlu diuji terpisah, tetapi jumlah test-nya jauh lebih sedikit.

Contract test untuk argv, env, cwd, dan file descriptor

Contract test menjawab pertanyaan: “Jika kita meminta runner menjalankan proses dengan spesifikasi tertentu, apakah proses anak benar-benar melihat argv, environment, dan working directory yang benar?” Test ini berada di antara unit test dan integration test.

Uji argv tanpa shell jika shell bukan bagian kontrak

Kesalahan umum adalah memakai sh -c di semua test. Ini menambah quoting, expansion, PATH lookup, dan perilaku shell ke dalam test, padahal kode production mungkin tidak bergantung pada shell. Jika yang diuji adalah argv, jalankan executable helper secara langsung.

  • Gunakan path absolut untuk executable helper.
  • Hindari PATH lookup kecuali memang itu kontrak yang ingin diuji.
  • Uji argumen yang mengandung spasi, karakter khusus, dan string kosong jika aplikasi mendukungnya.

Uji environment sebagai delta, bukan seluruh environment mesin

Environment global dari CI sering berubah. Karena itu, test sebaiknya memverifikasi variabel yang memang disetel oleh aplikasi, bukan membandingkan seluruh os.Environ(). Untuk integration test yang butuh environment bersih, buat allowlist minimal seperti PATH, TZ, atau variabel yang benar-benar diperlukan.

Uji cwd dengan direktori sementara

Gunakan direktori sementara per test, bukan direktori repository. Ini mencegah test bergantung pada file lokal developer dan memudahkan cleanup otomatis. Pastikan test tidak memakai relative path yang diam-diam berubah ketika dijalankan paralel.

Perhatikan file descriptor

File descriptor yang bocor adalah penyebab klasik test menggantung. Misalnya parent masih memegang sisi tulis pipe, sehingga child atau reader tidak pernah menerima EOF. Dalam kode production, pastikan descriptor yang tidak perlu ditandai close-on-exec atau ditutup sebelum spawn. Dalam test, tambahkan skenario yang memastikan proses selesai ketika stdin ditutup dan stdout/stderr selalu dibaca sampai tuntas.

Integration test yang terisolasi dan sedikit

Integration test tetap diperlukan karena ada perilaku yang hanya muncul saat bertemu kernel: signal, exit status, pipe, permission, dan process group. Namun test jenis ini harus sedikit, terisolasi, dan tidak bergantung pada timing sempit.

Checklist integration test yang stabil:

  • Buat workspace per test: pakai direktori sementara dan hapus setelah test selesai.
  • Gunakan executable helper kecil: helper dapat mencetak argv/env/cwd, menunggu stdin, atau menangani signal. Jangan gunakan tool besar seperti git, curl, atau tar kecuali memang sedang menguji integrasi dengan tool tersebut.
  • Set environment eksplisit: jangan bergantung pada HOME, locale, atau konfigurasi user.
  • Drain stdout dan stderr: proses bisa berhenti jika pipe penuh dan parent tidak membaca output.
  • Selalu wait: setelah start, pastikan ada jalur yang memanggil wait, baik sukses, error, maupun timeout.
  • Jangan paralelkan test yang memakai resource global: misalnya nama socket tetap, port tetap, atau file lock global.

Jika proses yang diuji membuat child lagi, pertimbangkan process group untuk cleanup. Membunuh hanya PID utama tidak selalu membersihkan grandchild. Implementasinya berbeda antar bahasa dan OS, jadi sembunyikan detail ini di wrapper platform-specific dan uji dengan integration test kecil.

Timeout deterministik: hindari sleep sebagai sinkronisasi

Timeout yang baik membatasi durasi test; timeout yang buruk menjadi sumber flaky. Pola yang sering salah adalah “start proses, sleep sebentar, lalu kirim signal”. Masalahnya, sleep tidak membuktikan proses sudah siap. Di CI yang lambat, proses mungkin belum memasang signal handler saat signal dikirim.

Gunakan sinkronisasi eksplisit:

  • Proses helper menulis byte ready ke stdout atau pipe setelah siap menerima signal.
  • Parent menunggu marker tersebut sebelum melanjutkan test.
  • Untuk unit test, gunakan fake runner atau clock yang diinjeksi, bukan menunggu waktu nyata.
  • Untuk integration test, tetapkan timeout yang cukup untuk mencegah hang, tetapi jangan gunakan timeout sebagai bukti keberhasilan.

Bedakan dua jenis timeout. Timeout perilaku adalah bagian dari kontrak aplikasi, misalnya proses harus dibatalkan setelah 5 detik. Timeout proteksi test hanya pagar agar CI tidak menggantung. Jangan mencampur keduanya tanpa sengaja.

Cleanup PID dan pencegahan zombie process

Setiap proses yang berhasil di-start harus memiliki pemilik yang bertanggung jawab melakukan wait. Ini tetap berlaku ketika test gagal di tengah jalan. Daftarkan cleanup segera setelah proses dibuat, bukan di akhir test.

Praktik yang aman:

  • Simpan PID hanya setelah start berhasil.
  • Pasang cleanup menggunakan mekanisme test framework, misalnya cleanup hook.
  • Kirim terminasi lembut terlebih dahulu jika proses mendukungnya, lalu fallback ke kill jika tidak berhenti.
  • Selalu panggil wait untuk mengambil status exit dan mencegah zombie.
  • Tangani kasus proses sudah exit: error seperti “no such process” saat kill biasanya bukan kegagalan test jika wait menunjukkan proses memang sudah selesai.

Untuk proses yang membuat subprocess, cleanup perlu mempertimbangkan process group atau mekanisme supervisor. Tanpa itu, test bisa lulus tetapi meninggalkan proses yatim yang mengganggu job CI berikutnya.

Observability log untuk debugging CI

Test spawn yang gagal di CI sering sulit direproduksi. Karena itu, wrapper process runner sebaiknya menghasilkan log terstruktur yang cukup untuk diagnosis, tetapi tetap aman dari kebocoran secret.

Informasi yang berguna untuk dicatat:

  • path executable dan argv yang sudah direduksi jika mengandung token atau password;
  • working directory;
  • nama variabel environment yang disetel, bukan selalu nilainya;
  • PID, waktu mulai, durasi, exit code, dan signal jika tersedia;
  • tail stdout/stderr dengan batas ukuran;
  • apakah proses selesai normal, timeout, dibatalkan context, atau gagal start.

Log ini membuat perbedaan besar saat menghadapi perbedaan kernel, container, atau limit resource di CI. Misalnya, dari log Anda bisa melihat bahwa executable tidak ditemukan karena PATH berbeda, proses mati karena signal, atau stderr menunjukkan permission error pada direktori sementara.

Kesalahan umum yang perlu dihindari

  • Menguji terlalu banyak lewat proses nyata: ini memperlambat suite dan memperbesar permukaan flaky. Pakai fake untuk logika bisnis.
  • Membandingkan environment penuh: CI sering menambah variabel baru. Bandingkan variabel yang relevan saja.
  • Menggunakan shell tanpa alasan: shell menambah quoting dan expansion. Gunakan direct argv jika memungkinkan.
  • Tidak membaca stderr: selain kehilangan informasi debug, pipe stderr yang penuh dapat membuat proses macet.
  • Melupakan wait setelah kill: kill bukan cleanup lengkap. Tanpa wait, zombie tetap bisa muncul.
  • Timeout terlalu agresif: test menjadi sensitif terhadap beban CI. Gunakan readiness signal, bukan sleep pendek.

Rencana praktis menerapkan strategi ini

  1. Inventarisasi semua lokasi kode yang memanggil API spawn secara langsung.
  2. Buat interface runner kecil yang membawa path, argv, env, cwd, timeout, dan opsi output.
  3. Pindahkan pemanggilan runtime seperti fork/exec, posix_spawn, atau wrapper subprocess ke satu implementasi production.
  4. Ubah unit test menjadi fake runner dan contract assertion atas ProcessSpec.
  5. Tambahkan beberapa integration test terisolasi untuk argv/env/cwd, stdout/stderr, timeout, signal, dan cleanup.
  6. Tambahkan cleanup PID yang selalu wait serta logging terstruktur untuk proses yang dijalankan test.
  7. Jalankan test di lingkungan CI yang mendekati production, tetapi jangan bergantung pada detail incidental dari CI tersebut.

Dengan struktur ini, uji API spawn proses Unix menjadi lebih stabil karena sebagian besar perilaku diuji secara deterministik, sementara interaksi nyata dengan kernel diuji secara terbatas dan terkendali. Hasilnya bukan hanya test yang lebih jarang flaky, tetapi juga kode process management yang lebih mudah diaudit dan di-debug.