Safari’s JavaScript engine มีข้อบกพร่องที่ซ่อนอยู่: เมื่อสคริปต์เริ่มต้น (entry script) ของ Web Worker แบบ module ถูกนำไป import ไว้ที่อื่นใน bundle ตัว Safari จะรันสคริปต์เริ่มต้นนั้นเป็นครั้งที่สอง การรันซ้ำซ้อนนี้ทำให้สถานะแบบ singleton พัง และส่งผลเสียต่อ worker ที่ต้องพึ่งพา shared caches หรือออบเจกต์แบบ single-instance อย่างเงียบๆ
ปัญหานี้ปรากฏขึ้นระหว่างการพัฒนาแอปประมวลผลวิดีโอผ่านเบราว์เซอร์ที่ใช้ Web Workers ในการถอดรหัสไฟล์ ProRes โดย Chrome และ Firefox ทำงานได้ปกติโดยไม่มีปัญหา แต่ Safari กลับโหลดวิดีโอไม่สำเร็จอย่างต่อเนื่อง ใน console รายงานเพียงข้อผิดพลาดทั่วไปว่า “cannot read video” ในขณะที่ต้นเหตุที่แท้จริงคือโค้ดเริ่มต้น (initialization code) ของ worker ทำงานสองครั้ง และทำให้มีโมดูลเดียวกันสองชุดที่แยกจากกันอยู่ในหน่วยความจำ
How the bug manifests
Bundler สมัยใหม่ (Vite, Rollup และอื่นๆ) มักจะดึง utility ที่ใช้ร่วมกันเข้าไปไว้ในไฟล์ entry ของ worker เพื่อให้ chunk ที่ถูก lazy-load สามารถ import โค้ดนั้นกลับมาจาก entry point ได้ ในเบราว์เซอร์ที่ทำตามมาตรฐานพฤติกรรมของ module loader เมื่อ entry module ถูกสร้างขึ้น (instantiated) ตัว loader จะส่งออบเจกต์โมดูลเดิมกลับไปให้กับการ import ครั้งต่อๆ ไป ซึ่งช่วยป้องกันการรันซ้ำเป็นครั้งที่สอง
Safari ทำงานต่างจากความคาดหมายนั้น เมื่อ chunk ที่ถูก lazy-load ทำการ import ไฟล์ entry ของ worker ตัว Safari จะมองว่าการ import นั้นเป็นการร้องขอโมดูลใหม่ และรันสคริปต์เริ่มต้นซ้ำอีกครั้ง ผลลัพธ์ที่ได้คือมี instance แยกกันสองชุดสำหรับทุกตัวแปร, class หรือ singleton ที่ถูกกำหนดไว้ในนั้น
What breaks when the entry runs twice
- Singletons และ caches จะไม่แชร์ข้อมูลกันอีกต่อไป โดย copy หนึ่งจะเห็น cache ว่างเปล่า ในขณะที่อีก copy หนึ่งกำลังเติมข้อมูลลงไป
- Registries (เช่น รายการ message handlers) จะถูกแบ่งออกระหว่างสอง instance ทำให้ฝั่งหนึ่งว่างเปล่าโดยปริยาย
- Event listeners จะถูกแนบสองครั้ง ซึ่งอาจทำให้เกิดการจัดการซ้ำซ้อนหรือหน่วยความจำบวม (memory bloat)
- WebAssembly (WASM) modules จะถูกโหลดสองครั้ง ทำให้สิ้นเปลืองแบนด์วิดท์และเวลาในการเริ่มต้นทำงาน
- ความล้มเหลวนี้เกิดขึ้นอย่างเงียบๆ โดยไม่มีการโยน uncaught exception ออกมา มีเพียง logic ส่วนปลาย (downstream logic) ที่ต้องพึ่งพาสถานะที่หายไปเท่านั้นที่จะทำงานผิดพลาด
Detecting the issue in a project
การใช้ grep ตรวจสอบ built assets อย่างรวดเร็วสามารถบอกได้ว่ามี chunk ใดที่ import ไฟล์ entry ของ worker หรือไม่:
grep -l 'from"./your.worker-' dist/assets/*.js
หากคำสั่งแสดงรายชื่อไฟล์ใดๆ แสดงว่าการ import เหล่านั้นน่าจะเป็นตัวกระตุ้นให้เกิดบั๊กการรันซ้ำสองครั้งใน Safari
Practical workarounds
Extract shared code from the worker entry.
ตั้งค่า bundler ให้วาง library ที่ใช้ร่วมกันไว้ใน chunk ของตัวเอง (เช่น การใช้manualChunksของ Rollup) จากนั้นทั้ง worker และโมดูลที่ถูก lazy-load จะ import library จากไฟล์ที่สามนั้นแทน ซึ่งจะช่วยลดความจำเป็นในการ import ไฟล์ entry ของ workerUse a thin entry file.
ลดสคริปต์เริ่มต้นของ worker ให้เหลือเพียงบรรทัดเดียวที่ทำหน้าที่ re-export การทำงานจริง:// worker-entry.js import("./main.js");ตราบใดที่ไม่มี bundle อื่น import
worker-entry.jsตัว Safari จะไม่เห็นการร้องขอ import ครั้งที่สอง ดังนั้น entry จะทำงานเพียงครั้งเดียวเท่านั้น
ทั้งสองวิธีช่วยให้โค้ดเริ่มต้นของ worker มีสถานะเป็น singleton ตลอดทั้งแอปพลิเคชัน
Why the bug matters
Web Workers เป็นรูปแบบที่นิยมใช้ในการย้ายการประมวลผลหนักๆ เช่น การเข้ารหัสวิดีโอ, การประมวลผลภาพ, หรือการเข้ารหัสลับ (cryptography) ออกจาก main thread การแยกกันของสถานะ (state split) อย่างเงียบๆ สามารถเปลี่ยนฟีเจอร์ที่ทำงานได้อย่างสมบูรณ์ให้กลายเป็นความล้มเหลวที่เกิดขึ้นเป็นพักๆ (intermittent failure) ซึ่งจะปรากฏเฉพาะบน Safari ซึ่งเป็นเบราว์เซอร์เริ่มต้นบนอุปกรณ์เดสก์ท็อปและมือถือจำนวนมาก เนื่องจากข้อผิดพลาดแสดงออกมาเป็นความล้มเหลวในการโหลดสื่อแบบทั่วไป นักพัฒนาอาจต้องเสียเวลาหลายชั่วโมงในการไล่ตามอาการที่ผิดพลาด
บั๊กนี้ยังชี้ให้เห็นถึงความเสี่ยงที่กว้างกว่า นั่นคือการพึ่งพาความหมาย (semantics) ของ module loader ที่ไม่ได้ถูกนำไปใช้เหมือนกันในทุกเบราว์เซอร์ เมื่อกลยุทธ์การปรับแต่ง (optimization) ของ bundler ตั้งสมมติฐานว่าจะมีโมดูลที่ใช้ร่วมกันเพียง instance เดียว การเบี่ยงเบนใดๆ ก็ตามอาจทำให้สมมติฐานนั้นพังลงได้
Counter-point and open questions
พฤติกรรมของ Safari สอดคล้องกับกฎการหาโมดูล (module resolution rules) ของตัวมันเอง ซึ่งแตกต่างจาก spec เล็กน้อยในกรณีขอบเขต (edge cases) ที่เกี่ยวข้องกับ workers นักพัฒนาบางคนแย้งว่า bundler ควรหลีกเลี่ยงการวางโค้ดที่ใช้ร่วมกันไว้ในไฟล์ entry ของ worker โดยสิ้นเชิง ซึ่งจะทำให้ปัญหานี้เป็นเรื่องของวินัยในการ build มากกว่าจะเป็นข้อบกพร่องของเบราว์เซอร์ ในขณะที่คนอื่นๆ ชี้ให้เห็นว่าการเบี่ยงเบนของ Safari ไม่ได้ถูกระบุไว้ในเอกสาร ทำให้เหล่านักพัฒนาไม่มีวิธีที่เชื่อถือได้ในการคาดการณ์ล่วงหน้า
Apple ยังไม่ได้ยอมรับปัญหานี้ต่อสาธารณะ และยังไม่มีกำหนดการแก้ไขที่แน่ชัด จนกว่า Safari จะเปลี่ยน loader ภาระจึงยังคงอยู่ที่นักพัฒนาในการปรับโครงสร้าง bundle ใหม่ หรือเพิ่ม logic การตรวจจับเข้าไปใน CI pipelines ของตนเอง
What to watch next
- การอัปเดตเบราว์เซอร์ – คอยติดตามบันทึกการเปลี่ยนแปลง (release notes) ของ Safari สำหรับการกล่าวถึงการจัดการ module-worker
- แพตช์จากชุมชนผู้ใช้ Bundler – Vite, Rollup และเครื่องมืออื่นๆ อาจมีการเพิ่มคำเตือนหรือกลยุทธ์การทำ automatic chunking เพื่อหลีกเลี่ยงรูปแบบที่ทำให้เกิดบั๊กนี้
- แนวทางการทดสอบ – การนำไฟล์สื่อที่ใช้งานจริงมาใช้ร่วมกับการทดสอบ worker แบบ full-stack บน Safari ก่อนการปล่อยใช้งาน จะช่วยให้ตรวจพบความล้มเหลวที่เกิดขึ้นเงียบๆ (silent failure) ได้ตั้งแต่เนิ่นๆ
บทสรุป
หากผู้ใช้ Safari ของคุณพบปัญหาความล้มเหลวที่เกี่ยวข้องกับ worker โดยหาสาเหตุไม่ได้ ให้ตรวจสอบว่ามี bundle ที่ไม่ใช่ worker ตัวใดนำเข้า (import) entry script ของ worker หรือไม่ บั๊กจากการทำงานซ้ำซ้อน (double execution) จะทำลายสถานะแบบ singleton อย่างเงียบๆ แต่การย้ายโค้ดที่ใช้ร่วมกัน (shared code) ออกจาก entry point หรือการปรับปรุง entry ให้เหลือเพียงการ re-export สั้นๆ (thin re-export) จะช่วยให้ระบบกลับมาทำงานได้อย่างถูกต้องโดยไม่ต้องรอการแก้ไขจากเบราว์เซอร์
