Steven
Stevenโ€ขโ€ข7 menit baca

Lahir dan Diperbaiki dalam Dua Belas Menit

Changelog GeekBye v1.8.10 bilang ia memperbaiki sebuah crash saat mengedit pintasan keyboard. Memang begitu โ€” tapi crash itu diperkenalkan dan diperbaiki dalam pull request yang sama, terpaut dua belas menit, dan tak pernah mencapai satu pun pengguna. Kisah sebenarnya adalah kaskade keandalan yang menghasilkannya: sebuah perubahan kecil dan benar tentang kapan pintasan ditangguhkan, dan bug teardown React yang jatuh dari satu baris yang dimaksudkan untuk membuat editor lebih aman.

Engineering
React
Desktop
GeekBye Releases
Lahir dan Diperbaiki dalam Dua Belas Menit

Inilah kalimat paling jujur yang bisa kutulis tentang GeekBye v1.8.10: crash yang terkenal ia perbaiki tak pernah terjadi pada siapa pun. Changelog bilang "Fixed a crash while editing keyboard shortcuts and switching windows mid-edit," yang benar, dan terbaca seperti sebuah bug yang dirilis, menggigit pengguna, dan ditambal dalam tindak lanjut. Bukan. Crash itu diperkenalkan oleh satu commit dan dihapus oleh commit lain dalam pull request yang sama, terpaut dua belas menit, di pagi Jumat yang sama. Ia hidup sepenuhnya di dalam sebuah branch. Catatan rilis menggambarkan sebuah bekas luka yang terbentuk dan sembuh sebelum siapa pun di luar repo bisa melihatnya.

Itu bukan kegagalan changelog; itu adalah bentuk jujur dari kerja iteratif, dan layak diceritakan karena dua belas menit itu memuat sebuah bug React yang sungguh-sungguh mendidik. Tapi untuk memahami crash-nya kamu harus memahami untuk apa rilis ini sebenarnya โ€” karena crash itu adalah kerusakan kolateral dari memperbaiki sesuatu yang lain.

Tentang apa rilis ini sebenarnya

GeekBye membiarkanmu mengikat ulang pintasan keyboard-nya di Pengaturan. Saat kamu merekam kombinasi baru, aplikasi harus menghentikan pintasan global-nya dari terpicu โ€” kalau tidak, menekan Cmd+B untuk menetapkannya justru akan men-toggle jendela alih-alih ditangkap. Jadi editor menangguhkan pintasan global lewat IPC: renderer memanggil window.electronAPI.setShortcutsSuspended(true), dan ShortcutsHelper milik main process merespons dengan memanggil unregisterAll(), menjatuhkan setiap registrasi globalShortcut sehingga penekanan tombol mencapai input penangkap alih-alih memicu sebuah aksi. Saat melanjutkan ia memanggil registerGlobalShortcuts() untuk mengembalikannya.

Bug yang memulai semua ini (issue #233) adalah soal cakupan penangguhan itu. Kode lama menangguhkan pintasan sepanjang waktu halaman pengaturan pintasan terbuka โ€” bukan hanya saat kamu aktif merekam sebuah tombol. Sebagian besar waktu itu tak terlihat. Tapi jika kamu membuka halaman itu, mulai melakukan sesuatu yang lain, dan menavigasi menjauh tanpa pernah menyelesaikan sebuah pengeditan, aplikasi bisa tertinggal dengan semua pintasan globalnya tak terdaftar โ€” mati diam-diam โ€” sampai kamu kembali. Commit yang memperbaikinya berterus terang soal gejalanya: menangguhkan terlalu luas berisiko "making the app appear broken if the user forgot they had the page open."

Perbaikannya adalah penyempitan satu baris: tangguhkan hanya ketika sebuah baris benar-benar sedang diedit โ€” dikunci pada !!editingShortcut, state hook yang menyimpan id baris yang kamu rekam โ€” alih-alih sepanjang kunjungan. Perubahan bagus. Tapi "hanya tangguhkan saat mengedit" memunculkan sebuah pertanyaan langsung: apa yang dihitung sebagai menyelesaikan sebuah pengeditan? Menekan sebuah tombol menyelesaikannya. Menekan Escape membatalkannya. Tapi bagaimana jika kamu hanyaโ€ฆ mengklik menjauh, atau berpindah ke aplikasi lain sepenuhnya, di tengah penangkapan? Jika tak ada yang membatalkan pengeditan, kamu tertinggal merekam sebuah pintasan ke dalam jendela yang tak terfokus, dan pintasan tetap ditangguhkan. Jadi PR yang sama menambahkan sebuah katup pengaman: batalkan pengeditan ketika fokus pergi. Dan itulah baris yang crash.

Bug dua belas menit

Katup pengaman itu adalah sebuah useEffect di ShortcutsSettings.tsx yang, selama sebuah baris sedang diedit, mendengarkan blur:

const handleBlur = () => cancelEditing()
window.addEventListener('blur', handleBlur)
inputRef.current?.addEventListener('blur', handleBlur) // the landmine

Dua listener. Satu pada window โ€” terpicu saat kamu berpindah ke aplikasi lain. Satu dilampirkan langsung, sebagai listener DOM mentah, ke <input> penangkap โ€” dimaksudkan untuk terpicu saat kamu mengklik menjauh ke elemen lain. Mereka tampak berlebihan dan tak berbahaya. Mereka bukan.

Telusuri urutannya untuk "switching windows mid-edit," yang persis adalah frasa changelog:

  1. Kamu sedang merekam sebuah pintasan; <input> terfokus. Kamu Cmd+Tab ke aplikasi lain.
  2. Blur jendela terpicu. handleBlur menjalankan cancelEditing(), yang menyetel editingShortcut kembali ke null.
  3. Menyetel state itu meng-unmount <input> โ€” baris keluar dari mode edit, jadi input dihapus dari DOM.
  4. Menghapus sebuah elemen yang terfokus dari DOM secara sinkron mengirim sebuah event blur pada elemen itu. Listener inputRef mentah menangkapnya dan memanggil cancelEditing() lagi โ€” kali ini di tengah fase commit React, saat pohon komponen sedang dirobohkan.
  5. Memanggil sebuah state setter pada sebuah fiber yang sedang di tengah teardown menyandung salah satu invarian internal React, dan ia melempar: "Should have a queue". Tampilan pengaturan crash.

Bug-nya bukan listener jendela dan bukan pula benar-benar konsep "batalkan saat blur." Melainkan bahwa salah satu dari dua listener itu adalah sebuah addEventListener mentah pada sebuah elemen yang dikelola React. React tak tahu tentang listener itu, tak membersihkannya saat unmount, dan โ€” yang krusial โ€” browser memicu blur selama penghapusan DOM yang sedang dilakukan React itu sendiri, jadi handler masuk kembali ke logika state-mu pada saat paling buruk yang mungkin. Pesan commit perbaikannya mengatakannya lebih tepat daripada yang bisa kulakukan: "when cancelEditing unmounts the input, the blur fires and calls cancelEditing again during React's render cycle."

Perbaikannya

bf28a50, dua belas menit setelah crash-nya lahir, melakukan dua hal kecil.

Pertama, ia menghapus listener input mentah dan menyimpan hanya yang jendela, yang merupakan satu-satunya yang memang perlu menjadi sebuah listener DOM manual (tak ada React onBlur untuk "seluruh jendela kehilangan fokus"):

const handleWindowBlur = () => cancelEditing()
window.addEventListener('blur', handleWindowBlur)
return () => {
  window.removeEventListener('blur', handleWindowBlur)
}

Kedua, ia memindahkan kasus klik-menjauh โ€” "pengguna mengklik elemen lain" โ€” ke prop onBlur milik React sendiri pada input, dan menundanya satu tik:

onBlur={() => {
  // Deferred to avoid state updates during React's render cycle.
  setTimeout(() => cancelEditing(), 0)
}}

Kedua bagian itu penting. Menggunakan prop onBlur React alih-alih sebuah listener mentah berarti React memiliki siklus hidup handler dan tak akan meninggalkannya menggantung. Dan setTimeout(โ€ฆ, 0) menjamin bahwa bahkan ketika blur ini memang dipicu oleh sebuah unmount, panggilan cancelEditing() mendarat pada tik berikutnya โ€” setelah render dan commit saat ini selesai โ€” sehingga ia tak pernah bisa memperbarui state pada sebuah komponen yang masih dirobohkan. Jika komponennya sudah hilang saat itu, panggilan yang ditunda adalah no-op yang tak berbahaya.

Itulah keseluruhan perbaikannya. Hapus listener yang tak bisa dilihat React; biarkan React menjadwalkan yang tersisa.

Mengapa ini jenis membosankan yang baik

Ada godaan untuk mengarsipkan ini sebagai "sepele" โ€” sebuah setTimeout, sebuah baris yang dihapus โ€” dan melanjutkan. Tapi bentuknya adalah salah satu cara paling umum aplikasi desktop React crash, dan layak diinternalisasi:

  • Crash hanya muncul di sebuah batas siklus hidup (unmount), dipicu oleh sebuah event yang tak dijadwalkan framework (sebuah blur native selama penghapusan DOM). Ia tak akan muncul di sebuah tes klik-lewat; ia butuh jalur spesifik "fokus pergi saat mengedit."
  • Akar penyebabnya adalah mencampur dua model kepemilikan โ€” event DOM mentah dan event sintetis, terjadwal milik React โ€” pada elemen yang sama. Saat handler-mu memutasi state yang bisa meng-unmount elemen itu, jalur mentah menjadi re-entrant.
  • Perbaikannya bukan "tambahkan sebuah bendera penjaga" (meski sebuah ref isMounted akan menyembunyikannya); melainkan menghapus listener mentah sepenuhnya, yang merupakan perubahan lebih kecil dan yang benar. Pengurangan lagi.

Dan meta-pelajarannya, yang terus diputari seluruh seri ini: baca diff-nya, bukan catatan rilisnya. "Fixed a crash while editing keyboard shortcuts" akurat, tapi ia menyembunyikan bahwa crash itu ditimbulkan sendiri, di pagi yang sama, dan tak pernah dirilis โ€” dan bahwa rilis yang sebenarnya adalah kemenangan ketepatan yang tenang tentang kapan menangguhkan sebuah pintasan global.

Apa lagi yang dirilis

Dua hal lagi ikut menumpang di v1.8.10, keduanya layak satu baris. Sistem pintasan juga memperoleh penyembuhan-diri lintas tidur/bangun: macOS bisa diam-diam menjatuhkan registrasi pintasan global sebuah aplikasi ketika mesin tidur, jadi sebuah handler resume kini memverifikasi dan mendaftarkannya ulang, yang berarti pintasanmu masih bekerja setelah laptop bangun tanpa peluncuran ulang. Dan di sisi AI, klien menjadi lebih pintar soal batas laju โ€” permintaan streaming yang menabrak sebuah 429 kini mundur secara eksponensial dan mereset penghitungnya ketika kamu berhenti merekam, alih-alih menunggu sebuah penundaan tetap. Tiga perbaikan keandalan pada subsistem yang berbeda, salah satunya adalah crash yang tak pernah menjadi crash.

Untuk bab sebelumnya dalam kisah v1, mencetak rapat ke PDF tanpa library PDF (v1.8.9); dan untuk keseluruhan alurnya, anatomi merilis software sampai sempurna.

Artikel Terkait

Tiga Kata Kerja yang Menjaga Web Audio Tetap Hidup
Steven
Stevenโ€ขโ€ข8 menit baca

Tiga Kata Kerja yang Menjaga Web Audio Tetap Hidup

Dua point release GeekBye, terpaut dua bulan dan di dua berkas berbeda, mengajari kode audio kami pelajaran yang sama dari ujung yang berlawanan: berhenti memperlakukan AudioContext milik browser sebagai barang sekali pakai. Satu rilis belajar untuk resume() sebuah context yang diam-diam disuspend macOS di tengah perekaman; yang lain belajar untuk suspend() alih-alih close() agar sesi beruntun berhenti menabrak plafon kira-kira enam context milik Chromium. Resume, suspend, close โ€” itulah keseluruhan plotnya.

Engineering
Audio
Desktop
Membedakan Panggilan dari Aplikasi yang Terbuka
Steven
Stevenโ€ขโ€ข8 menit baca

Membedakan Panggilan dari Aplikasi yang Terbuka

GeekBye bisa memperhatikan bahwa kamu telah bergabung ke sebuah rapat video dan menawarkan untuk merekamnya. Deteksinya ternyata paruh yang mudah โ€” sebuah binary Swift yang membaca judul jendela setiap sepuluh detik. Paruh yang sulit adalah presisi: tidak memicu saat Zoom sekadar terbuka, tidak menawari sebuah rapat yang sudah kamu rekam, dan tidak membisukan mikrofon di panggilan yang sebenarnya kamu ikuti. Tiga rilis, dan masing-masing adalah sebuah penjaga yang harus belajar untuk tidak mengalahkan dirinya sendiri.

Engineering
macOS
Desktop
Mengeluarkan Backend dari Jalur Unggah
Steven
Stevenโ€ขโ€ข8 menit baca

Mengeluarkan Backend dari Jalur Unggah

GeekBye merekam layarmu dan menyimpan videonya ke Google Drive-mu. Versi pertama mengirim setiap rekaman melalui server milik GeekBye sendiri dalam perjalanannya; satu rilis kemudian, file itu pergi langsung dari mesinmu ke Drive, dan backend diturunkan menjadi hanya memegang satu pointer. Bagian menariknya adalah betapa sedikit kode yang sebenarnya dimuat versi 'langsung, resumable' itu โ€” karena kemampuan-melanjutkan datang dari menghapus sebuah proxy, bukan dari menulis satu.

Engineering
Architecture
Desktop