Race condition pada backend pengendali DSPi biasanya muncul ketika beberapa request HTTP mengirim perintah audio ke satu koneksi serial secara bersamaan. Dua frame dapat saling menyisip, tertukar urutannya, atau terpotong saat ditulis ke perangkat. Akibatnya, preset gagal diterapkan, perangkat menunggu frame yang tidak pernah lengkap, dan backend berakhir dengan timeout.

Solusi utamanya adalah memastikan hanya satu komponen yang menulis ke koneksi serial pada satu waktu. Terapkan pola single writer menggunakan mutex atau queue, gunakan framing yang dapat dideteksi dengan jelas, pasang timeout pada setiap transaksi, dan buat operasi preset bersifat idempoten. Contoh di bawah menggunakan service HTTP sebagai pengendali dan serial bridge sebagai jalur komunikasi. Detail protokol harus disesuaikan dengan firmware serta dokumentasi perangkat pada repositori WeebLabs/DSPi; contoh frame di artikel ini bersifat ilustratif, bukan klaim tentang format internal repositori tersebut.

Skenario bug pada backend pengendali DSPi

Anggap backend menyediakan endpoint berikut:

PUT /api/presets/vocal
PUT /api/presets/bass

Setiap request mengubah beberapa parameter audio melalui Raspberry Pi Pico atau Pico 2 yang bertindak sebagai jembatan antara backend dan DSPi. Implementasi awal sering terlihat sederhana:

app.put('/api/presets/:name', async (req, res) => {
  const frame = buildPresetFrame(req.params.name);
  await serialPort.write(frame);
  const response = await waitForSerialResponse();
  res.json(response);
});

Masalahnya, handler HTTP dapat berjalan paralel. Jika request vocal dan bass tiba hampir bersamaan, keduanya memanggil serialPort.write() dan waitForSerialResponse() pada resource yang sama. Library serial mungkin aman untuk pemanggilan individual, tetapi itu tidak otomatis membuat urutan transaksi—tulis frame, tunggu ACK, cocokkan respons—menjadi aman.

Gejala yang dapat diamati

  • Preset berhasil diterapkan jika request dikirim satu per satu, tetapi gagal saat dikirim bersamaan.
  • Log backend menunjukkan timeout meskipun perangkat menerima sebagian data.
  • Preset yang aktif berbeda dari preset pada respons HTTP.
  • Frame serial memiliki byte awal atau akhir yang hilang, atau dua payload muncul dalam satu buffer.
  • Restart service atau reset Pico sementara menghilangkan masalah.

Gejala ini sering disalahartikan sebagai masalah DSP, kabel, atau baud rate. Langkah pertama yang lebih efektif adalah membuktikan apakah payload berubah sebelum dikirim dan apakah byte yang diterima perangkat sama dengan byte yang dicatat backend.

Reproduksi race condition dengan request bersamaan

Uji dari sisi HTTP

Kirim dua request secara paralel dengan body atau endpoint yang berbeda. Contoh berikut memakai dua proses curl:

(curl -sS -X PUT http://localhost:3000/api/presets/vocal & \
 curl -sS -X PUT http://localhost:3000/api/presets/bass & \
 wait)

Ulangi pengujian beberapa kali, bukan hanya sekali. Race condition bergantung pada penjadwalan thread, event loop, buffer serial, dan waktu respons perangkat sehingga sering tidak muncul pada eksekusi pertama.

Untuk pengujian yang lebih terkontrol, buat skrip yang mengirim banyak pasangan request dengan correlation ID berbeda. Pastikan setiap request mencatat waktu mulai, waktu selesai, nama preset, ID transaksi, dan hasil respons. Jangan hanya mencatat teks seperti preset applied karena log tersebut tidak cukup untuk menghubungkan request dengan frame serial.

Tracing payload dan log serial

Tambahkan trace ID pada setiap lapisan. Nilai yang sama harus terlihat pada log HTTP, queue, frame yang dibangun, transaksi serial, dan respons dari perangkat.

2025-01-10T10:00:00.101Z http request_id=8f1 preset=vocal event=received
2025-01-10T10:00:00.102Z serial request_id=8f1 bytes=... event=queued
2025-01-10T10:00:00.103Z serial request_id=8f1 bytes=... event=written
2025-01-10T10:00:00.119Z serial request_id=8f1 ack=ok event=response

Log payload dalam bentuk hexadecimal atau base64 agar byte non-printable tidak merusak log. Hindari mencatat hanya panjang payload. Dua frame yang sama-sama memiliki panjang 12 byte tetap dapat berisi perintah berbeda.

Jika tersedia, gunakan sniffer atau fitur logging pada serial bridge untuk membandingkan tiga hal: frame yang dibuat backend, byte yang benar-benar ditulis ke port serial, dan byte yang diterima firmware. Perbedaan di antara ketiganya membantu memisahkan bug pembentukan payload dari bug konkurensi.

Root cause: transaksi serial tidak dianggap sebagai satu operasi

Race condition terjadi karena operasi yang secara logis harus atomik dipisah oleh scheduler:

  1. Backend membangun frame A.
  2. Backend mulai menulis frame A.
  3. Request kedua membangun dan menulis frame B.
  4. Respons ACK dari perangkat diterima oleh handler yang salah.

Selain itu, pemanggilan write() tidak selalu berarti seluruh byte sudah diproses perangkat. Buffer OS, buffer library, dan buffer firmware dapat memiliki batas berbeda. Jika aplikasi langsung mengirim payload berikutnya tanpa aturan eksklusivitas dan framing yang jelas, receiver dapat melihat gabungan data seperti [akhir A][awal B] dalam satu pembacaan.

Mutex hanya menyelesaikan sebagian masalah jika yang dikunci hanya pemanggilan write(). Unit yang perlu dilindungi adalah seluruh transaksi: memperoleh giliran, menulis satu frame lengkap, menunggu ACK yang sesuai, menangani timeout, lalu melepaskan giliran.

Perbaikan: single writer dengan queue

Pola yang aman adalah memisahkan handler HTTP dari penulis serial. Handler memasukkan pekerjaan ke queue, sedangkan satu worker saja yang memproses queue. Dengan demikian, tidak ada dua transaksi yang menulis ke port pada waktu yang sama.

class SerialCommandQueue {
  constructor(serialPort, options = {}) {
    this.serialPort = serialPort;
    this.timeoutMs = options.timeoutMs ?? 1000;
    this.pending = [];
    this.running = false;
  }

  enqueue(command) {
    return new Promise((resolve, reject) => {
      this.pending.push({ command, resolve, reject });
      this.#drain();
    });
  }

  async #drain() {
    if (this.running) return;
    this.running = true;

    while (this.pending.length > 0) {
      const job = this.pending.shift();
      try {
        const result = await this.#execute(job.command);
        job.resolve(result);
      } catch (error) {
        job.reject(error);
      }
    }

    this.running = false;
  }

  async #execute(command) {
    const frame = encodeFrame(command); // Sesuaikan dengan protokol DSPi
    await this.serialPort.write(frame);
    return waitForMatchingResponse(this.serialPort, command.id, this.timeoutMs);
  }
}

Implementasi produksi perlu memperhatikan detail API serial yang digunakan, terutama apakah write() mengembalikan Promise, membutuhkan callback, atau hanya menyalin data ke buffer. Fungsi waitForMatchingResponse() juga harus membaca stream secara incremental dan tidak menganggap satu event data selalu sama dengan satu frame.

Alternatif mutex

Mutex cocok jika aplikasi hanya membutuhkan eksklusivitas dan jumlah operasi tidak besar. Kunci harus mencakup proses menulis serta menunggu respons:

await serialMutex.runExclusive(async () => {
  const frame = encodeFrame(command);
  await writeFully(serialPort, frame);
  return await waitForMatchingResponse(serialPort, command.id, 1000);
});

Queue biasanya lebih mudah dikembangkan karena dapat menyediakan backpressure, prioritas, pembatalan, dan metrik panjang antrean. Mutex lebih sederhana, tetapi pemanggil harus menangani timeout dan pelepasan kunci dengan benar. Apa pun pilihannya, jangan membuat satu mutex per request; semua request harus berbagi pengunci yang sama untuk satu koneksi serial.

Framing, timeout, dan pencocokan respons

Gunakan framing yang eksplisit

Serial adalah aliran byte, bukan aliran pesan. Karena itu, backend dan firmware memerlukan aturan untuk menemukan batas frame. Pilih format yang memang didukung firmware, misalnya delimiter dengan escaping, panjang payload, atau kombinasi header, panjang, sequence number, dan checksum.

HEADER | LENGTH | SEQUENCE | COMMAND | PAYLOAD | CHECKSUM

Contoh tersebut hanya pola umum. Jangan menambahkan header atau checksum di backend jika firmware DSPi tidak memahaminya. Jika protokol yang tersedia menggunakan delimiter, pastikan delimiter di dalam payload di-escape. Jika menggunakan panjang payload, validasi panjang maksimum sebelum mengalokasikan buffer.

Pasangkan respons dengan transaksi

ACK tanpa ID transaksi sulit dibedakan ketika beberapa operasi menunggu respons. Jika protokol mendukung sequence number, masukkan ID tersebut ke frame dan cocokkan saat respons diterima. Jika protokol tidak menyediakan ID, single writer tetap mencegah dua transaksi aktif, tetapi parser harus tetap memvalidasi jenis respons dan statusnya.

Timeout harus diterapkan pada setiap transaksi, bukan hanya pada seluruh HTTP request. Setelah timeout, tentukan keadaan koneksi dengan jelas: bersihkan parser, buang byte sampai delimiter berikutnya bila protokol mengizinkan, atau tutup dan buka ulang koneksi serial. Jangan langsung mengirim frame baru pada stream yang mungkin masih berisi respons tertunda.

Idempotensi dan perilaku retry

Timeout tidak selalu berarti perangkat tidak menjalankan perintah. Perangkat dapat sudah menerapkan preset, tetapi ACK hilang. Jika backend otomatis mengirim ulang perintah tanpa desain idempoten, efek samping tertentu dapat terjadi dua kali.

Untuk operasi pengaturan preset, gunakan model set state alih-alih toggle state. Mengirim “gunakan preset vocal” berulang kali seharusnya menghasilkan keadaan yang sama. Simpan sequence number atau operation ID jika firmware mampu mengabaikan duplikasi. Pada sisi HTTP, kunci idempotensi dapat mencegah dua retry dari klien menghasilkan pekerjaan yang tidak perlu.

  • Gunakan retry terbatas dengan backoff, bukan loop tanpa batas.
  • Bedakan timeout sebelum frame terkirim dari timeout setelah frame terkirim.
  • Laporkan status tidak pasti jika backend tidak dapat memastikan apakah perangkat sudah berubah.
  • Hindari retry otomatis untuk perintah yang memiliki efek kumulatif kecuali protokol mendukung deduplikasi.

Pengujian regresi

Uji konkurensi

Setelah perbaikan, jalankan request paralel dengan payload berbeda dan verifikasi bahwa setiap respons memiliki preset yang sesuai dengan request-nya. Uji juga beberapa worker HTTP dan beberapa burst request agar queue benar-benar digunakan.

for i in $(seq 1 50); do
  (curl -sS -X PUT http://localhost:3000/api/presets/vocal > /tmp/vocal-$i.json) &
  (curl -sS -X PUT http://localhost:3000/api/presets/bass  > /tmp/bass-$i.json) &
done
wait

Jalankan pengujian dengan serial bridge nyata jika memungkinkan. Mock yang terlalu sederhana dapat menyembunyikan masalah partial write dan fragmentasi pembacaan.

Uji parser dan partial frame

  • Bagi satu frame menjadi beberapa potongan byte sebelum diberikan kepada parser.
  • Gabungkan beberapa frame dalam satu buffer pembacaan.
  • Kirim checksum atau panjang yang salah.
  • Putuskan koneksi setelah sebagian frame terkirim.
  • Pastikan timeout membersihkan transaksi dan queue tetap dapat memproses pekerjaan berikutnya.

Tambahkan assertion bahwa satu command hanya memiliki satu hasil akhir: sukses, gagal karena respons perangkat, timeout, atau koneksi putus. Status internal seperti pending tidak boleh tertinggal selamanya setelah exception.

Checklist debugging praktis

  1. Reproduksi dengan dua request HTTP yang berjalan bersamaan.
  2. Tambahkan correlation ID dan catat payload sebelum encoding serta setelah menjadi byte.
  3. Bandingkan byte yang dibuat backend dengan byte yang keluar dari serial bridge.
  4. Pastikan hanya satu writer mengakses satu koneksi serial.
  5. Kunci seluruh transaksi atau masukkan transaksi ke queue, bukan hanya operasi write().
  6. Validasi framing, partial read, partial write, checksum, dan batas ukuran frame.
  7. Pasang timeout dan prosedur pemulihan koneksi setelah timeout.
  8. Tambahkan sequence number atau mekanisme pencocokan respons bila protokol mendukungnya.
  9. Rancang retry berdasarkan idempotensi perintah.
  10. Jadikan pengujian request paralel dan parser sebagai bagian dari regresi.

Dalam konteks DSPi pada Raspberry Pi Pico atau Pico 2, detail pin, firmware, baud rate, serta format perintah harus diverifikasi dari dokumentasi dan kode yang benar-benar digunakan. Namun, prinsip concurrency-nya tetap sama: satu stream serial harus memiliki satu pemilik penulisan yang terkoordinasi, parser harus memahami bahwa data datang sebagai aliran byte, dan setiap timeout harus menghasilkan keadaan sistem yang dapat dipulihkan.