Epidemiologi core dump: memperbaiki bug berusia 18 tahun
Menggunakan analisis tingkat populasi untuk men-debug crash rumit di infrastruktur data kami.
Model dan agen OpenAI makin bergantung pada infrastruktur data yang skalabel untuk mencari data relevan saat inferensi: ketika model sedang memikirkan pertanyaan Anda. Sebagian layanan ini ditulis dalam C++, yang memberi kontrol tingkat rendah atas sistem sehingga kami dapat memaksimalkan performa dan meminimalkan penggunaan memori. Manfaat efisiensi itu penting saat kami melakukan penskalaan, tetapi C++ tidak memiliki keamanan memori, sehingga bug dapat menyebabkan crash dengan menulis ke alamat memori yang salah atau tidak ada.
Beberapa bulan lalu kami mengamati sejumlah crash dari dalam layanan Rockset, bagian khusus dari infrastruktur data ChatGPT kami yang penting bagi banyak plugin data dan pencarian dalam percakapan. Pada tiap crash ini, sebuah fungsi C++ normal tampak selesai lalu kembali ke alamat palsu, sehingga kernel menghentikan program karena penunjuk instruksi tidak lagi menunjuk ke kode. Terkadang slot alamat pengembalian dalam stack frame bernilai NULL. Terkadang register CPU penunjuk stack itu sendiri tampak meleset 8 byte, seolah-olah %rsp entah bagaimana telah dikurangi di tengah eksekusi normal. Dalam kedua kasus, crash terjadi saat kembali.
Ini bukan mode kegagalan yang normal untuk kode aplikasi. Penulisan menyimpang yang hanya mengenai alamat pengembalian tersimpan memang mungkin, tetapi sangat kecil kemungkinannya. Bug yang membuat %rsp meleset 8 tanpa melibatkan inline assembly, setcontext, atau longjmp (tidak satu pun kami gunakan) bahkan lebih aneh, karena kode terkompilasi hanya menyesuaikan register itu secara langsung di prolog dan epilog fungsi. Setiap hipotesis yang terpikir oleh kami (atau ChatGPT) memiliki bukti kuat yang membantahnya, sehingga bug itu tampak mustahil.
Apa yang kami kira satu masalah akhirnya ternyata dua bug yang tidak berkaitan, yang kebetulan ditemukan pada saat yang sama. Pertama, kerusakan perangkat keras senyap pada satu host Azure, ketika CPU tidak menghitung dengan benar. Kedua, race condition berusia 18 tahun di GNU libunwind, bug yang tidak terdeteksi dalam pustaka sumber terbuka yang banyak digunakan.
Tulisan ini menceritakan bagaimana kami mengidentifikasi dan memperbaiki crash yang tampak tak dapat dijelaskan dengan berpikir seperti epidemiolog dan membangun set data berkualitas tinggi tentang seluruh populasi crash.
Pertama, mari membahas Rockset lebih dalam. Ini adalah sistem data cloud-native untuk pencarian dan analitik real-time yang kami gunakan untuk banyak kasus penggunaan internal di OpenAI, seperti konektor sinkronisasi (Rockset diakuisisi oleh OpenAI pada 2024). Pembaruan streaming digunakan untuk menjaga indeks basis pengetahuan sebuah workspace tetap mutakhir sehingga ChatGPT dapat mencari informasi relevan saat menjawab pertanyaan atau melakukan tindakan.
Lapisan eksekusi Rockset ditulis dalam C++. Bahasa C++ memberi akses tingkat rendah ke CPU, yang baik untuk performa dan efisiensi, tetapi berarti bug aplikasi dapat memicu akses memori tidak valid dan segfault. Untuk membantu melacaknya, kami menggunakan fatal signal handler dari folly untuk mencatat stack trace saat crash terjadi, dan kami mengunggah core dump terkait (snapshot status program saat crash) ke Azure blob storage untuk analisis kemudian. Semua leaf pemrosesan kueri Rockset direplikasi, sehingga meminimalkan dampak crash pada klien. Namun, setiap segfault menunjukkan bug yang perlu diperbaiki agar memenuhi target keandalan dan kualitas kami.
Pendekatan awal kami adalah memperlakukan core ini seperti masalah debugging konvensional: memeriksa beberapa core dump dengan sangat teliti, menyusun hipotesis, lalu menyingkirkannya satu per satu.
Sebagian besar crash terjadi dalam metode bernama DocumentTree::updateDocument. Dalam crash ini, tampak bahwa updateDocument telah memanggil suatu fungsi tak dikenal X, stack menjadi rusak saat X aktif, lalu X kembali ke alamat yang bukan kode yang dapat dieksekusi. Dalam beberapa kasus, frame X yang baru saja di-pop tampak valid kecuali alamat pengembalian tersimpannya bernilai NULL. Dalam kasus lain, penunjuk stack itu sendiri tampak salah, tetapi frame valid berikutnya masih tampak sebagai updateDocument.
Kami tidak tahu kapan stack mulai rusak, sehingga ruang pencariannya sangat besar. updateDocument adalah metode besar yang banyak mengalami inlining, sehingga jumlah kandidat untuk X sangat banyak.
Apakah ini bug dalam kode C++ kami? Masalah compiler atau linkage? Masalah di salah satu pustaka runtime kami? Bug kernel Linux terkait pengiriman sinyal atau context switching? Sesuatu yang bahkan lebih langka? Jika ini penulisan menyimpang, mengapa tidak tertangkap oleh lingkungan staging ASAN kami?
Kami mencoba memakai log tingkat aplikasi untuk mengidentifikasi semua kemunculan masalah ini, tetapi bug kerusakan stack sulit diklasifikasikan dari log saja karena stack trace yang dicatat juga rusak atau hilang. Kami tidak berhasil membuat kueri log yang bebas dari positif palsu sekaligus negatif palsu. Kami memeriksa lebih banyak core secara manual dan menemukan beberapa contoh tambahan, tetapi proses itu terlalu menguras tenaga untuk menghasilkan set data yang dapat dipercaya.
Pada tahap penyelidikan ini, kami (keliru) menyingkirkan kemungkinan bug perangkat keras, karena kami melihat crash di beberapa wilayah dan beberapa tipe perangkat keras, jadi kami masih mencari penyebab yang murni perangkat lunak. Selama beberapa hari, kami menyelami satu crash dengan %rsp yang tidak sejajar, merekonstruksi riwayat sebelum crash menggunakan isi stack dan register. Ini menghasilkan beberapa petunjuk yang mungkin, tetapi karena kami tidak melepas kesimpulan awal bahwa semua bug punya penyebab yang sama, kami tetap buntu.
Sebelum sampai ke titik balik penyelidikan, penting untuk menjelaskan jenis informasi yang kami ambil dari file core.
Rockset dikompilasi dengan -fno-omit-frame-pointer, sehingga stack frame aktif selalu dapat dijangkau melalui %rbp, dan pemanggil membentuk linked list penunjuk frame.
Di Linux x86_64, AMD64 System V ABI juga mencadangkan 128 byte di bawah %rsp sebagai red zone. Area itu tersedia bagi kode userspace dan, yang penting, kernel berjanji tidak akan menimpanya saat mengirim sinyal, sebagai bagian dari kontrak ABI.
Red zone memegang peranan penting dalam debugging kami atas crash setelah kembali, karena red zone menyimpan sebagian informasi dari sebelum kembali. Ketika SIGSEGV terpicu, fatal signal handler milik folly berjalan pada stack thread yang crash. Stack frame yang tidak lagi aktif (karena fungsinya sudah kembali) akan ditimpa oleh signal handler, kecuali 128 byte terakhir. Itulah mengapa kami bisa mengatakan hal seperti “stack frame X yang baru saja di-pop tampak valid, kecuali alamat pengembaliannya NULL.” Red zone mempertahankan sebagian frame yang tidak aktif, atau terkadang hanya ekor dari satu frame tidak aktif.
Kami menemukan satu crash stack tidak sejajar yang melibatkan fungsi-fungsi sangat kecil. Itu memungkinkan kami melihat bahwa %rsp menjadi tidak sejajar selama eksekusi fungsi yang relatif sederhana, dan bahwa beberapa panggilan berikutnya masih berhasil. Program baru crash ketika fungsi aktif akhirnya mencoba kembali. Tidak satu pun jalur kode itu menggunakan exception, inline assembly, setcontext, atau longjmp, jadi jika penunjuk stack benar-benar berubah seperti yang ditunjukkan core, tidak ada bug masuk akal di kode userspace yang menjelaskan masalahnya.
Itu mendorong kami mengarah ke kernel.
Rockset menggunakan sinyal lebih agresif dibanding kebanyakan program. Eksekusi kueri dipecah menjadi banyak tugas ringan yang saling bertukar data. Ini penting untuk menangani beban kerja QPS tinggi secara efisien, tetapi membuat pencatatan CPU per kueri menjadi rumit karena pekerjaan untuk banyak kueri dimultipleks ke thread pool yang sama.
Solusi kami adalah sesuatu yang kami sebut coarse_thread_cputime_clock, yang mendekati clock_gettime(CLOCK_THREAD_CPUTIME_ID, ...) dengan biaya cukup murah untuk disampel di setiap batas tugas. API timer_create dapat digunakan untuk menjadwalkan pengiriman sinyal periodik berdasarkan beberapa konsep berlalunya waktu, termasuk akumulasi waktu CPU. Kami menjadwalkan sinyal (SIGUSR2) agar dikirim setiap beberapa milidetik waktu CPU, lalu signal handler memperbarui nilai lokal thread. Meski banyak tugas tidak melihat coarse clock bergerak maju saat dieksekusi, menjumlahkan semua delta menghasilkan estimasi tanpa bias atas waktu CPU aktual untuk sebuah kueri.
Karena kami sangat sering mengirim sinyal, bug kernel langka terkait context switching atau pengiriman sinyal tampak mungkin. Kami menghabiskan waktu membaca laporan bug, kode sumber kernel, dan patch kernel khusus Azure. Kami mencoba stress test. Kami tidak berhasil menemukan apa pun yang tampak terkait.
Pada titik itu kami memutuskan untuk mundur sejenak dan mencoba pendekatan lain.
Ada dua cara umum untuk men-debug masalah seperti ini.
Yang pertama adalah bertindak seperti dokter: fokus pada satu pasien, menjalankan banyak tes, dan mencoba mendiagnosis satu kasus dari bukti yang rinci.
Yang lain lebih seperti epidemiolog: melihat seluruh populasi dan bertanya apakah ada pola yang tidak bisa diungkap oleh satu kasus saja. Apakah bug mulai pada rilis tertentu? Apakah hal ini berkorelasi dengan satu SKU perangkat keras (model CPU dan server tertentu), satu wilayah, atau satu versi kernel? Apakah ada beberapa klaster berbeda yang tersembunyi di dalam sesuatu yang tampak seperti satu sindrom?
Kami sebagian besar berada dalam mode dokter. Perubahan kuncinya adalah memutuskan bahwa kami perlu mengumpulkan data populasi berkualitas tinggi.
Upaya kami sebelumnya untuk menemukan semua instans masalah secara otomatis gagal karena kami mencoba menggunakan pencarian teks pada log. Core dump itu sendiri menyimpan jauh lebih banyak informasi, tetapi meninjaunya secara manual tidak dapat diskalakan. Kami memutuskan untuk berinvestasi membangun pipeline yang dapat menganalisis core dump secara otomatis.
Kami meminta ChatGPT menulis skrip yang mengunduh prefix tiap file core, mengekstrak register, memfilter positif palsu yang diketahui menggunakan log, dan otomatis memberi label crash sebagai kembali-to-null, misaligned-stack, atau lainnya. Lalu kami menjalankan skrip itu secara paralel pada setiap core dump Rockset produksi dari tahun sebelumnya.
Inilah titik baliknya.
Begitu kami memiliki set data yang bersih, korelasi langsung muncul. Apa yang selama ini kami anggap satu bug aneh sebenarnya adalah dua populasi crash yang terpisah.
Core kembali-to-null tersebar di banyak klaster dan wilayah geografis. Frekuensinya meningkat belakangan ini, tetapi tidak ada tanggal mulai yang tegas dan tidak ada batas infrastruktur yang jelas.
Crash misaligned-stack tampak sama sekali berbeda. Semuanya berasal dari satu wilayah, memiliki tanggal mulai yang jelas, dan tidak pernah terjadi pada node yang sudah berjalan lama. Meski melibatkan beberapa VM Azure (mesin virtual yang dihosting di cloud), polanya tampak seperti satu mesin fisik dengan perangkat keras buruk yang menimbulkan masalah bagi VM mana pun yang kebetulan ditempatkan di sana.
Saat itulah kami menyadari bahwa secara mental kami telah mencampuradukkan dua bug. Karena kami mencampur contoh tandingan dari kedua bug, kami tidak bisa menemukan satu penjelasan yang koheren.
Berbekal daftar node Kubernetes dan timestamp yang bersih, kami dapat melacak crash misaligned-stack kembali ke satu host fisik, yang mudah dimasukkan ke denylist.
Kami tidak berhasil mereproduksi kerusakan register pada host itu di lingkungan terkontrol, bahkan setelah beberapa minggu stress testing. Namun setelah host bermasalah itu dikeluarkan dari layanan, crash misaligned-stack menghilang.
Menghapus host yang buruk bukan solusi permanen, dalam arti tidak mencegah terjadinya kembali masalah yang sama. Namun, kami dapat mengubah software agar jika masalah serupa berulang, hal ini mudah terdeteksi dan ditangani. Kami menyempurnakan fatal signal handler agar menyertakan status register, sehingga kami dapat mendeteksi kekambuhan hanya dari log (tanpa perlu core dump). Kami mengubah control plane sehingga VM biasanya digunakan ulang alih-alih didaur ulang, yang membuat deteksi node buruk jauh lebih mudah pada lapisan infrastruktur kami. Kami juga memperbarui runbook kami (dan model mental tim kami) untuk memasukkan kemungkinan ini.
Setelah crash dari host buruk dipisahkan, core kembali-to-null yang tersisa menjadi jauh lebih mudah dipahami. Sebelumnya kami telah menyingkirkan unwinding exception karena mengira punya contoh tandingan: crash di jalur kode yang jelas tidak menggunakan exception. Namun semua contoh tandingan itu berasal dari klaster kerusakan perangkat keras.
Setelah kami meninjau kembali core yang tersisa dengan hal itu dalam pikiran, kami menemukan bahwa kesimpulan ini justru terbalik: semua crash terjadi selama unwinding exception.
Ketika C++ melempar exception, runtime harus menemukan blok catch mana yang harus menerimanya dan destructor atau cleanup handler mana yang harus dijalankan di sepanjang jalan. Compiler menghasilkan metadata ini, tetapi pencocokan sebenarnya terjadi secara dinamis pada runtime.
Unwinding exception sebenarnya tidak dilakukan oleh fungsi yang memanggil throw, melainkan oleh fungsi helper yang dipanggil oleh kode terkompilasi yang dihasilkan. Rutin runtime itu memeriksa stack, mengambil metadata tentang fungsi yang ditemukan di stack, mencari cleanup handler dan blok catch secara dinamis, lalu mentransfer kontrol ke salah satu lokasi tersebut. Mentransfer kontrol mencakup unwinding semua stack frame di antaranya (termasuk milik fungsi helper).
Secara operasional, ini jauh lebih mirip longjmp atau fiber switch daripada pemanggilan dan kembali normal. Register callee-save harus dipulihkan, begitu juga register stack frame %rbp dan %rsp.
Binary kami tertaut ke dua pustaka yang berisi implementasi fungsi untuk melakukan unwinding exception C++: libgcc dan GNU libunwind. Definisi GNU libunwind-lah yang dipilih oleh dynamic linker. Itu mengejutkan kami; kami memperkirakan implementasi libgcc yang menang karena aturan symbol versioning; namun pemeriksaan binary yang berjalan menunjukkan bukan begitu kasusnya.
Pada titik ini hipotesis kerja kami berubah, saat kami melonggarkan satu lagi asumsi yang kami buat ketika mengira hanya ada satu bug.
Mungkin kami tidak melihat kembali fungsi biasa ke NULL. Mungkin kami melihat transfer unwind—secara efektif pemulihan register ala setcontext—di mana penunjuk instruksi tujuan sudah menjadi NULL sebelum kontrol ditransfer. Dengan kata lain, data yang salah dari pustaka unwind, bukan slot alamat pengembalian yang salah di stack.
Itu mempersempit masalah secara drastis. Entah GNU libunwind menghitung status tujuan yang salah, atau GNU libunwind menghitung status yang benar tetapi sesuatu merusaknya sebelum dapat diterapkan.
Kami membaca sumber GNU libunwind dan menemukan bahwa GNU libunwind menyintesis ucontext_t di stack, mengisi status register yang diinginkan untuk frame cleanup handler, lalu menyerahkan penunjuk ke struct itu kepada rutin assembly internal: _Ux86_64_setcontext.
Pada titik ini kami memiliki semua potongannya.
ucontext_t yang disintesis berada di salah satu stack frame yang di-unwind oleh _Ux86_64_setcontext, selama eksekusi fungsi itu. Apakah _Ux86_64_setcontext membaca dari struct setelah mengubah %rsp, ketika struct itu tidak lagi menjadi bagian dari stack aktif? Itu akan membuatnya rentan ditimpa oleh pengiriman sinyal, seperti SIGUSR2 kami yang sering.
Jawabannya ya.
Berikut enam instruksi terakhir dari _Ux86_64_setcontext dalam versi GNU libunwind yang kami gunakan, yang sebagian besar berupa instruksi mov yang memuat dari memori ke register tujuan:
(%rdi menunjuk ke ucontext_t yang dialokasikan di stack, dan makro UC_MCONTEXT_* hanya diekspansi menjadi offset tetap tempat register tertentu disimpan.)
Instruksi pertama adalah awal jendela race. Instruksi itu memperbarui %rsp agar menunjuk ke dasar baru stack aktif. Begitu ini terjadi, struct yang ditunjuk oleh %rdi tidak lagi menjadi bagian dari stack aktif (atau red zone), dan tidak lagi terlarang bagi kernel.
Biasanya ini tidak menimbulkan masalah, tetapi jika sinyal tiba tepat pada momen yang benar (salah?), kernel akan membangun signal frame di %rsp-128. Itu dapat menimpa memori yang ditunjuk oleh %rdi.
Jika itu terjadi sebelum instruksi berikutnya membaca UC_MCONTEXT_GREGS_RIP(%rdi), maka penunjuk instruksi yang dipulihkan dapat rusak. Dalam crash kami, nilainya menjadi NULL.
Itulah bug-nya.
Assembly ini juga menjelaskan salah satu pengamatan yang membingungkan kami: mengapa fungsi X memiliki NULL di slot alamat pengembalian stack frame sebelumnya.
setcontext ditulis untuk memulihkan semua register, termasuk %rdi, sehingga tidak bisa menggunakan register itu untuk membaca UC_MCONTEXT_GREGS_RIP(%rdi) pada momen terakhir transfer kontrol. Sebagai gantinya, setcontext membaca nilai itu lebih awal, menyimpannya ke stack, memulihkan beberapa register lagi, lalu menggunakan retq untuk membaca nilai tersimpan dan mentransfer kontrol.
Apa yang di core tampak seperti “sebuah fungsi kembali ke NULL” sebenarnya adalah “unwinder menyintesis alamat pengembalian target di stack, tetapi target itu rusak sebelum transfer selesai.” Kami semula mengira kerusakan slot alamat pengembalian pasti terjadi di tempat, karena kami tidak tahu ada tempat yang sengaja menulis data (yang dapat rusak) ke slot alamat pengembalian.
Yang membuat bug ini terasa absurd adalah betapa sempitnya jendela race ini. Dalam race condition seperti ini, peristiwa eksternal (sinyal) harus terjadi di antara dua langkah yang diambil thread lain. Semakin dekat kedua langkah itu, semakin kecil kemungkinan race condition terjadi.
Dalam kasus ini, jendela rentannya benar-benar hanya selebar satu instruksi! Sinyal harus dikirim setelah %rsp diubah, tetapi sebelum instruksi berikutnya memuat %rip. Beberapa instruksi sederhana seperti ini dapat dijalankan per siklus pada CPU modern yang superskalar dan out-of-order, sehingga jendela race kira-kira seratus pikodetik.
Ketika menemukan race ini, reaksi pertama kami adalah bahwa pasti terlalu langka untuk menjelaskan tingkat crash yang diamati. Kami melihat lebih dari belasan crash kembali-to-null per hari di seluruh fleet. Mungkinkah race satu instruksi selama pembersihan exception benar-benar menjelaskan itu?
Kami beralih ke estimasi Fermat. Jika jendela rentan berada pada orde detik dan SIGUSR2 tiba setiap detik waktu CPU, maka setiap handler pembersihan exception atau blok catch memiliki probabilitas kira-kira untuk kalah dalam race.
Rockset menggunakan exception sebagai bagian dari mekanisme backpressure ingest internalnya. Satu host yang kelebihan beban dapat melempar exception pada orde per detik. Itu berarti waktu rata-rata antar kegagalan pada host yang menggunakan backpressure adalah detik, atau satu crash setiap beberapa jam. Pada skala fleet, itu lebih dari cukup untuk menjelaskan frekuensi crash yang diamati.
Bug GNU libunwind sudah tua—lebih dari 18 tahun, hadir di versi x86_64 pertama yang mendukung unwinding exception C++.
Jadi mengapa baru terlihat sekarang?
Tingkat crash kira-kira sebanding dengan berapa banyak exception yang dilempar dan berapa banyak sinyal yang dikirim. Ini juga bergantung pada seberapa banyak stack yang dikonsumsi signal handler.
Rockset tidak biasa pada ketiga sumbu itu. Kami melempar exception dengan laju tinggi sebagai bagian dari kontrol overload normal; kami mengirim SIGUSR2 sangat sering karena coarse_thread_cputime_clock; dan awal tahun ini kami membuat handler SIGUSR2 memakai lebih banyak stack dengan menambahkan panggilan ke timer_getoverrun, agar kami bisa memperhitungkan sinyal yang digabung.
Perubahan terakhir itu tampaknya penting. Jika handler memakai stack cukup sedikit, handler mungkin tidak mencapai dan menimpa memori ucontext_t yang basi. Sebelum perubahan itu, kami sama sekali tidak mengamati crash ini. Setelah perubahan, lajunya tetap rendah sampai kami meningkatkan beban untuk beberapa kasus penggunaan yang menekan mekanisme backpressure.
Dengan kata lain, bug libunwind selalu ada, tetapi hasil perkalian laju exception, laju sinyal, dan penggunaan stack handler kami baru belakangan ini melewati ambang sehingga terlihat secara operasional.
Mekanisme ini juga menjelaskan kebetulan bahwa baik bug perangkat keras maupun bug libunwind sama-sama crash terutama di dalam DocumentTree::updateDocument. Crash dari libunwind sangat condong ke metode ini, karena metode itu selalu aktif pada saat kami melempar exception untuk menerapkan backpressure ingest. Metode itu juga sangat terpilih untuk crash akibat ketidaksejajaran %rsp karena node perangkat keras buruk tersebut berasal dari SKU yang kami gunakan untuk ingest massal, yang menghabiskan sebagian besar waktu CPU-nya di metode itu.
Mitigasi langsung kami adalah beralih dari GNU libunwind ke unwinder milik libgcc. Itu sendiri merupakan trade-off yang baik: implementasi libgcc telah mendapat banyak perbaikan untuk mengurangi kontensi lock, yang penting saat melakukan penskalaan ke VM besar.
Kami juga mengirim reproducer mandiri dan perbaikan(terbuka di jendela baru) ke upstream GNU libunwind, serta memverifikasi bahwa unwinder lain tidak memiliki masalah serupa.
Perjalanan debugging ini mengajarkan kami banyak hal tentang detail spesifik dynamic linking, metadata unwind DWARF, pengiriman sinyal Linux, System V ABI, dan mekanisme exception C++. Namun pelajaran utamanya lebih sederhana daripada semua itu.
Langkah terpenting bukanlah membaca assembly dengan cerdik atau pengetahuan mendalam tentang detailnya. Melainkan membangun set data berkualitas tinggi. Tanpa set data ini, kami mencampur dua fenomena berbeda menjadi satu cerita dan mencoba bernalar keluar dari kebingungan itu. Begitu kami memiliki data populasi yang akurat dan lengkap, struktur masalah menjadi jelas: satu populasi crash berasal dari host yang buruk, dan yang lain dari race di libunwind. Begitu datanya membaik, debugging menjadi lebih mudah.
Untuk sistem infrastruktur seperti Rockset, hal itu sangat penting. Penyelidikan ini memperkuat komitmen kami pada instrumentasi mendalam, investigasi otomatis, dan perbaikan berkelanjutan pada perangkat operasional kami. Keandalan bukan hanya soal memperbaiki bug setelah terjadi—melainkan membangun data, alur kerja, dan keterampilan yang mengubah masalah mustahil menjadi masalah yang dapat didiagnosis dan diselesaikan.
Penulis
By Nathan Bronson, Member of Technical Staff


