
การตรวจสอบเพื่อความปลอดภัยที่ทำให้แอปของเราปิดไม่ได้
ระบบอัปเดตอัตโนมัติเป็นฟีเจอร์ที่ยากที่สุดที่เราเคยปล่อยออกมา — หกรุ่นในสี่วันเพื่อหยุดไม่ให้มันทำแอปพัง บั๊กที่แย่ที่สุดคือบั๊กที่เราสร้างขึ้นเองตอนที่พยายามจะระมัดระวัง: การตรวจสอบ "เพื่อความปลอดภัย" ที่กินเวลา 500 มิลลิวินาที ซึ่งเปลี่ยนการอัปเดตที่ล้มเหลวให้กลายเป็นโปรเซสที่คุณปิดไม่ได้เลยจริง ๆ
นักพัฒนาแอปเดสก์ท็อปทุกคนประเมินระบบอัปเดตอัตโนมัติต่ำเกินไปอย่างน้อยหนึ่งครั้ง มันดูเหมือนปัญหาที่แก้ได้แล้ว — ไลบรารีดาวน์โหลดเวอร์ชันใหม่มาแล้วรีสตาร์ตแอปของคุณ จากนั้นคุณก็ปล่อยมันออกไป และคุณก็ได้เรียนรู้ว่า "รีสตาร์ตแอปของคุณ" เป็นหนึ่งในสิ่งที่อันตรายที่สุดที่โปรแกรมจะถูกสั่งให้ทำ เพราะมันเกิดขึ้นในช่วงเวลาที่แอปของคุณกำลังรื้อถอนตัวเองและมีขอบเขตความผิดพลาดน้อยที่สุด
ระบบอัปเดตอัตโนมัติของ GeekBye ใช้เวลา หกรุ่นในสี่วัน — v1.5.14 ถึง v1.5.19 — กว่าจะเสถียร นี่คือเรื่องราวของบั๊กที่แย่ที่สุดในช่วงนั้น ซึ่งเราก่อขึ้นเองจากการพยายามจะระมัดระวัง
หกรุ่น กับรุ่นเดียวที่สำคัญ
จุดเริ่มต้นนั้นธรรมดามาก v1.5.14 แก้บั๊กระดับพิมพ์ผิดที่น่าอาย: ฟีดอัปเดตชี้ไปที่ชื่อ repo บน GitHub ที่ไม่มีอยู่จริง ดังนั้นตัวอัปเดตจึงกำลังตรวจสอบหน้า 404 v1.5.15 เพิ่มปุ่ม "Check for Updates" แบบทำเองและข้อความ error ที่ใช้ได้จริง จากนั้นบั๊ก quitAndInstall ก็เริ่มต้นขึ้น และรุ่นต่าง ๆ ก็มาอย่างรวดเร็ว — เพราะเมื่อกลไกอัปเดตของคุณพัง คุณจะไม่สามารถปล่อยตัวแก้ไข ผ่าน กลไกอัปเดตนั้นได้ ทุกการวนซ้ำคือการเสี่ยงติดตั้งใหม่ด้วยมือ
รุ่นที่สำคัญคือ v1.5.18 เนื้อหาทั้งหมดของมันคือคอมมิตเดียวที่มีชื่อซึ่งยังทำให้ผมหน้าเบ้อยู่: restore original quitAndInstall behavior to prevent unkillable app.
"การระมัดระวัง" ทำให้แอปพังได้อย่างไร
นี่คือฉากตั้งต้น เมื่อดาวน์โหลดอัปเดตเสร็จ quitAndInstall ของ Electron ควรจะปิดแอปแล้วสลับเอาเวอร์ชันใหม่เข้ามา ในรุ่นก่อนหน้านี้ มีคนหนึ่ง — อย่างมีเหตุผล — กังวลว่าการปิดแบบ ไม่มีเงื่อนไข นั้นเสี่ยง จะเป็นอย่างไรถ้าการติดตั้งเกิด error ขึ้น? มันจะปลอดภัยกว่าไหมถ้าปิดก็ต่อเมื่อทุกอย่างดูแข็งแรงดี?
โค้ดจึงงอกการ์ดที่ดูสมเหตุสมผลออกมา:
autoUpdater.quitAndInstall(false, true)
setTimeout(() => {
if (this.updateDownloaded)
app.quit() // only quit if the update is still "good"
else console.log('Error detected — keeping app open')
}, 500)
ตรรกะคือ: สั่งติดตั้ง รอครึ่งวินาที แล้วบังคับ app.quit() ขั้นสุดท้ายก็ต่อเมื่อแฟล็ก updateDownloaded ยังเป็น true อยู่ — มิฉะนั้นก็เปิดแอปทิ้งไว้เพื่อไม่ให้ผู้ใช้ค้างคา
กับดักอยู่ห่างไปเพียงบรรทัดเดียว ในตัวจัดการ error ตัวจัดการนั้นตั้งค่า this.updateDownloaded = false ลองนึกภาพการติดตั้งที่ล้มเหลว: อีเวนต์ error ทำงานและล้างแฟล็ก แต่ quitAndInstall ได้ เริ่ม การรื้อถอนไปแล้ว — มันได้ปิดหน้าต่างและลบตัวฟังคำสั่งปิดของแอปออกไป จากนั้นตัวจับเวลา 500ms ก็ตื่นขึ้น ตรวจสอบแฟล็กที่ตอนนี้เป็น false ตัดสินใจว่า "ตรวจพบ error ให้เปิดแอปทิ้งไว้" และ ข้าม app.quit()
ตอนนี้คุณมีสภาพที่แย่ที่สุดเท่าที่จะเป็นไปได้ โดยเฉพาะบน macOS macOS ไม่ปิดแอปเพียงเพราะหน้าต่างสุดท้ายของมันปิดลง — นั่นคือพฤติกรรม window-all-closed ที่แอป Mac ทุกตัวพึ่งพา ดังนั้นโปรเซสจึงยังมีชีวิตอยู่ แต่มัน ไม่มีหน้าต่าง ไม่มีเส้นทางแถบเมนู และตัวฟังคำสั่งปิดของมันถูกรื้อออกไปแล้ว ไม่มีอะไรให้คลิก Cmd-Q ไม่มีอะไรให้สื่อสารด้วย ทางออกเดียวคือ Force Quit จาก Activity Monitor การตรวจสอบ "เพื่อความปลอดภัย" ได้เปลี่ยนการอัปเดตที่ล้มเหลว — ความน่ารำคาญที่กู้คืนได้ — ให้กลายเป็นซอมบี้ที่คุณฆ่าไม่ได้
วิธีแก้: การรื้อถอนต้องไม่มีเงื่อนไข
การแก้ไขใน v1.5.18 นั้นน่าเบื่อจนเกือบก้าวร้าว ซึ่งนั่นแหละคือประเด็น มันลบความฉลาดออกไป:
- ลบตัวฟัง
window-all-closedและbefore-quitที่อาจเข้ามาแทรกแซง - ทำลาย ทุกหน้าต่าง —
window.destroy()ไม่ใช่window.close()การ close อาจถูกตัวจัดการยับยั้งได้ ส่วน destroy ทำไม่ได้ เมื่อคุณตั้งใจจะปิดตัวลง คุณไม่ถามอย่างสุภาพ - เรียก
quitAndInstall - เรียก
app.quit()แบบไม่มีเงื่อนไข
ไม่มีแฟล็ก ไม่มีตัวจับเวลา ไม่มี "เปิดทิ้งไว้เผื่อไว้" เพราะความจริงเกี่ยวกับเส้นทางการปิดตัวลงก็คือการปิดที่ทำได้ครึ่ง ๆ กลาง ๆ นั้นแย่กว่าผลลัพธ์แบบใดแบบหนึ่ง การปิดจนสุดก็ดี การเปิดทิ้งไว้ทั้งหมดก็ดี สภาพเดียวที่คุณต้องไม่ไปถึงเด็ดขาดคือ รื้อถอนไปแล้วแต่ยังทำงานอยู่ — และนั่นคือสภาพที่การปิดแบบมีเงื่อนไขจะทิ้งคุณไว้ได้พอดี
อีกสองบทเรียนที่สัปดาห์เดียวกันสอน
บั๊กปิดไม่ได้คือพาดหัว แต่การวิ่งวุ่นหกรุ่นนั้นทำให้อีกสองนิสัยแข็งแกร่งขึ้นซึ่งคุ้มค่าที่จะลอกไป
กรอง telemetry ของ crash ด้วยลายเซ็นที่ตรงเป๊ะ ไม่ใช่คีย์เวิร์ดกว้าง ๆ ระหว่างการวิ่งวุ่น เราพบว่าระบบรายงาน error ของเราถูกตั้งค่าให้ทิ้งอะไรก็ตามที่มีคำอย่าง permission, token, หรือ microphone — ความพยายามที่จะตัด noise ซึ่งกำลัง กลืน crash จริง ที่บังเอิญกล่าวถึงคำเหล่านั้นอย่างเงียบ ๆ เราฉีกตัวกรองแบบเหมารวมทิ้งไปและแทนที่ด้วยสตริงการปฏิเสธที่ตรงเป๊ะ (ข้อความเฉพาะที่ macOS ส่งออกมาเมื่อ permission ถูกปฏิเสธ) และรหัสเครือข่ายชั่วคราวเฉพาะอย่าง ERR_NETWORK_CHANGED การลด noise กับการซ่อนบั๊กคือปุ่มเดียวกันที่หมุนไปคนละทาง ถ้าคุณกรองตามความรู้สึก คุณจะกรองสิ่งที่คุณจำเป็นต้องเห็นออกไป
ทุกเส้นทางอัตโนมัติต้องมีช่องทางหนีฉุกเฉินแบบทำเอง การอัปเดตอัตโนมัติเป็นความพยายามให้ดีที่สุดโดยธรรมชาติ — เครือข่ายกระตุก การติดตั้งล้มเหลว ดังนั้นแต่ละโหมดความล้มเหลวจึงได้รับตัวสำรองที่มนุษย์ทำได้: ปุ่ม "Check for Updates" แบบทำเอง การลองใหม่แบบ exponential-backoff ตัวจับเวลาตรวจสอบซ้ำ และ — สงวนไว้เฉพาะสำหรับกรณีที่ความพยายาม ทำเอง ของผู้ใช้ล้มเหลว — ข้อความภาษาชาวบ้านว่า "ลบแอปแล้วติดตั้งใหม่จากเว็บไซต์" เส้นทางอัตโนมัติคือความสะดวก เส้นทางแบบทำเองคือหลักประกัน
บทสรุป
- การ์ดรอบ ๆ การกระทำที่ย้อนกลับไม่ได้นั้นอันตรายกว่าตัวการกระทำเอง การปิดแบบมีเงื่อนไขพยายามป้องกันไม่ให้การอัปเดตที่แย่ปิดแอป แต่กลับสร้างสภาพที่แย่กว่าทั้งการปิดหรือไม่ปิด เส้นทางการปิดตัวลงและการติดตั้งควรไม่มีเงื่อนไขและ idempotent — ไม่ควรถูกกั้นด้วยแฟล็กที่เปลี่ยนแปลงได้ซึ่งตัวจัดการอื่นสามารถพลิกออกจากใต้เท้าคุณได้
- บน macOS "ไม่มีหน้าต่าง" ไม่ได้แปลว่า "ไม่มีแอป" ตรรกะการรื้อถอนใด ๆ ต้องคำนึงถึงแพลตฟอร์มที่โปรเซสไร้หน้าต่างยังคงทำงานต่อไป ทดสอบเส้นทางความล้มเหลว บนระบบปฏิบัติการจริง ไม่ใช่แค่เส้นทางที่ราบรื่น
- ฟีเจอร์ที่คุณปล่อยผ่านระบบอัปเดตนั้นทดสอบผ่านระบบอัปเดตไม่ได้ ความไม่สมมาตรนั้นคือเหตุผลว่าทำไมการอัปเดตอัตโนมัติจึงสมควรได้รับโค้ดที่หวาดระแวง ไม่มีเงื่อนไข และตรวจสอบด้วยมืออย่างหนักหน่วง คุณจะได้แก้มันด้วยวิธีง่าย ๆ ก็ หลังจาก ที่มันทำงานได้แล้วเท่านั้น
นี่คือบทแรกสุดของงานด้านความน่าเชื่อถือที่สุดท้ายกลายเป็น GeekBye v2 สำหรับที่ที่เส้นทางนั้นพาไป ดูได้ที่ เวอร์ชัน 2 จริง ๆ ต้องใช้อะไรบ้าง (v2.0.0) และเส้นทางทั้งหมดใน กายวิภาคของการปล่อยซอฟต์แวร์ให้สมบูรณ์แบบ