Safari’s JavaScript engine có một lỗi tiềm ẩn: khi script khởi tạo (entry script) của một Web Worker theo kiểu module được import ở bất kỳ đâu khác trong bundle, Safari sẽ thực thi script đó lần thứ hai. Việc chạy trùng lặp này làm phá vỡ trạng thái singleton, âm thầm phá hoại các worker vốn dựa vào các cache dùng chung hoặc các đối tượng đơn nhất (single-instance objects).

Vấn đề này nảy sinh trong khi tôi đang phát triển một ứng dụng xử lý video trên trình duyệt sử dụng Web Workers để giải mã các tệp ProRes. Chrome và Firefox xử lý mã nguồn mà không gặp sự cố nào, nhưng Safari liên tục không thể tải được video. Console chỉ báo lỗi chung chung là “cannot read video”, trong khi nguyên nhân thực sự là mã khởi tạo của worker chạy hai lần và để lại hai bản sao độc lập của cùng một module trong bộ nhớ.

Cách lỗi này biểu hiện

Các trình đóng gói (bundlers) hiện đại (Vite, Rollup, v.v.) thường đưa các tiện ích dùng chung vào file entry của worker để các chunk được tải chậm (lazy-loaded) có thể import lại mã đó từ điểm khởi đầu. Trong các trình duyệt tuân thủ hành vi của module loader tiêu chuẩn, một khi module entry đã được khởi tạo, loader sẽ trả về cùng một đối tượng module cho bất kỳ lệnh import tiếp theo nào, ngăn chặn việc thực thi lần thứ hai.

Safari đi chệch khỏi kỳ vọng đó. Khi một chunk được tải chậm import file entry của worker, Safari coi lệnh import đó là một yêu cầu module mới và thực thi lại script entry. Kết quả là có hai thực thể (instance) riêng biệt của mọi biến, lớp (class), hoặc singleton được định nghĩa ở đó.

Những gì sẽ bị hỏng khi entry chạy hai lần

  • Singletons và các cache không còn chia sẻ dữ liệu; một bản sao thấy cache trống trong khi bản sao kia lại đổ dữ liệu vào.
  • Các registry (ví dụ: danh sách các trình xử lý tin nhắn) bị chia tách giữa hai thực thể, khiến một bên thực tế bị trống.
  • Các trình lắng nghe sự kiện (event listeners) bị gắn hai lần, có khả năng gây ra việc xử lý trùng lặp hoặc làm tăng dung lượng bộ nhớ (memory bloat).
  • Các module WebAssembly (WASM) được tải hai lần, gây lãng phí băng thông và thời gian khởi tạo.
  • Lỗi xảy ra một cách âm thầm: không có ngoại lệ (exception) nào không được bắt bị ném ra, chỉ có logic hạ nguồn (downstream logic) phụ thuộc vào trạng thái bị thiếu là hoạt động sai lệch.

Cách phát hiện vấn đề trong một dự án

Một lệnh grep nhanh vào các asset đã build có thể tiết lộ liệu có chunk nào import file entry của worker hay không:

grep -l 'from"./your.worker-' dist/assets/*.js

Nếu lệnh này liệt kê bất kỳ tệp nào, các lệnh import đó có khả năng đang kích hoạt lỗi chạy hai lần trong Safari.

Các giải pháp khắc phục thực tế

  1. Trích xuất mã dùng chung khỏi worker entry.
    Cấu hình bundler để đặt các thư viện chung vào một chunk riêng (ví dụ: sử dụng manualChunks của Rollup). Cả worker và bất kỳ module được tải chậm nào sau đó sẽ import thư viện từ file thứ ba này, loại bỏ nhu cầu import điểm khởi đầu của worker.

  2. Sử dụng một file entry mỏng.
    Giảm script entry của worker xuống còn một dòng duy nhất để re-export phần triển khai thực tế:

    // worker-entry.js
    import("./main.js");
    

    Miễn là không có bundle nào khác import worker-entry.js, Safari sẽ không bao giờ thấy yêu cầu import thứ hai, do đó entry chỉ chạy một lần.

Cả hai cách tiếp cận đều giữ cho mã khởi tạo của worker là duy nhất (single-tonic) trong toàn bộ ứng dụng.

Tại sao lỗi này lại quan trọng

Web Workers là một mô hình phổ biến để chuyển các tác vụ tính toán nặng—mã hóa video, xử lý hình ảnh, mã hóa (cryptography)—ra khỏi luồng chính (main thread). Một sự chia tách trạng thái âm thầm có thể biến một tính năng đang hoạt động hoàn hảo thành một lỗi chập chờn chỉ xuất hiện trên Safari, trình duyệt mặc định trên một phần lớn các thiết bị desktop và di động. Vì lỗi xuất hiện dưới dạng lỗi tải phương tiện chung chung, các nhà phát triển có thể mất hàng giờ để chạy theo những triệu chứng sai lệch.

Lỗi này cũng làm nổi bật một rủi ro rộng hơn: việc dựa vào các ngữ nghĩa của module-loader mà không được triển khai đồng nhất giữa các trình duyệt. Khi chiến lược tối ưu hóa của một bundler giả định rằng có một thực thể module dùng chung duy nhất, bất kỳ sự sai lệch nào cũng có thể phá vỡ giả định đó.

Quan điểm đối lập và các câu hỏi mở

Hành vi của Safari phù hợp với các quy tắc phân giải module (module resolution rules) của riêng nó, vốn khác biệt một chút so với đặc tả (spec) trong các trường hợp biên liên quan đến workers. Một số nhà phát triển lập luận rằng các bundler nên tránh việc đặt mã dùng chung vào file entry của worker ngay từ đầu, biến vấn đề này thành một vấn đề về kỷ luật khi build thay vì là một lỗi của trình duyệt. Những người khác chỉ ra rằng sự sai lệch của Safari không được tài liệu hóa, khiến các nhà phát triển không có cách đáng tin cậy để dự đoán nó.

Apple vẫn chưa công khai thừa nhận vấn đề này, và hiện chưa có lộ trình khắc phục nào được biết đến. Cho đến khi Safari thay đổi loader của mình, trách nhiệm vẫn thuộc về các nhà phát triển trong việc cấu trúc lại các bundle của họ hoặc thêm logic phát hiện vào các pipeline CI của họ.

Những gì cần theo dõi tiếp theo

  • Cập nhật trình duyệt – Hãy theo dõi các ghi chú phát hành của Safari để xem có bất kỳ đề cập nào về việc xử lý module-worker hay không.
  • Các bản vá từ cộng đồng Bundler – Vite, Rollup và các công cụ khác có thể đưa ra các cảnh báo hoặc chiến lược chia nhỏ (chunking) tự động để tránh mô hình gây ra lỗi này.
  • Quy trình kiểm thử – Việc kết hợp các tệp phương tiện thực tế và thực hiện kiểm thử worker toàn diện (full-stack) trên Safari trước khi phát hành có thể giúp phát hiện sớm các lỗi âm thầm.

Bài học rút ra

Nếu người dùng Safari của bạn gặp phải các lỗi liên quan đến worker không thể giải thích được, hãy kiểm tra xem có bất kỳ bundle không phải worker nào đang import script đầu vào (entry script) của worker hay không. Lỗi thực thi kép (double execution bug) sẽ âm thầm phá vỡ trạng thái singleton, nhưng việc chuyển mã dùng chung ra khỏi điểm đầu vào (entry point) hoặc thu gọn entry thành một bản re-export mỏng sẽ khôi phục lại hành vi chính xác mà không cần chờ đợi bản sửa lỗi từ trình duyệt.