dev.to

Safari runs a module worker's entry file twice if anything imports it

Safari에서 모듈 워커의 엔트리 파일이 두 번 실행되는 버그 — 다른 모듈이 이를 import할 때

Safari에서 모듈 워커(module worker)의 엔트리 파일을 다른 모듈이 import하면 엔트리가 새 모듈로 다시 로드·실행되는 WebKit 버그가 보고되었습니다. Vite·Rollup 같은 번들러가 워커 엔트리와 지연 로딩 청크 간 공유 코드를 엔트리 쪽으로 옮기는 흔한 코드 스플리팅 형태에서 발생하며, 레지스트리·싱글톤 같은 상태가 이중화되어 아무 에러 없이 조용히 실패합니다. 제보자는 WebKit bug 324459로 등록했고, manualChunks 분리 또는 얇은 로더 엔트리로 우회할 수 있습니다.

본문

브라우저에서 완전히 동작하는 영상 도구 모음 Oh So Video를 만드는 팀이 출시 전 Safari와 Firefox에서 모든 도구를 자동화된 E2E로 돌리고 결과 파일을 ffmpeg로 검증하던 중, Safari에서만 ProRes 파일이 전부 거부되는 문제를 발견했습니다. 표시된 메시지는 '이 브라우저는 ProRes 영상을 읽을 수 없습니다. Chrome, Edge 또는 컴퓨터의 Safari를 시도해 보세요'였는데, 정작 그것은 컴퓨터 Safari였습니다. Chrome과 Firefox는 5GB 4K 파일까지 정상 변환했습니다.

원인 — 디코더가 다른 사본에 등록되다

이 팀은 Mediabunny의 ProRes 확장으로 디코딩하는데, 이 확장은 사용자가 ProRes 파일을 선택할 때만 지연 로딩되며, 로드 시 Mediabunny에 디코더를 등록합니다. Mediabunny는 영상을 읽을 수 있는지 판단하기 전에 이 레지스트리를 확인합니다. 그런데 Safari에서는 레지스트리가 비어 있었습니다. 디코더가 '다른 사본의 Mediabunny'에 등록된 것입니다.

Vite 6.4와 Rollup 4로 빌드된 결과물에서 Mediabunny는 encode 워커의 엔트리 파일 안에 들어가 있었고, 지연 로딩되는 ProRes 청크는 import{...}from"./encode.worker-XXXX.js" 형태로 엔트리에서 다시 import했습니다. Chrome과 Firefox에서는 이 import가 이미 실행 중인 워커 엔트리 모듈을 그대로 반환하지만, Safari에서는 엔트리 파일을 새 모듈로 다시 로드하고 실행해 별도의 상태를 만듭니다. ProRes 디코더는 그 두 번째 사본에 등록되었고, 레지스트리를 검사하는 코드는 그것을 보지 못했습니다.

최소 재현과 테스트 행렬

번들러도 동적 import도 없이, w.js(워커 엔트리)와 helper.js 두 파일만의 평범한 import 사이클로 재현됩니다. Chrome과 Firefox는 { evals: 1, same: true }를 반환하는 반면 Safari는 { evals: 2, same: false }를 반환합니다. 엔트리가 두 번 실행되고 helper.js는 두 번째 사본을 붙들고 있다는 뜻입니다.

Safari 26.5(macOS 26.5.1), Firefox 156, Chrome 153에서 여러 변형을 시험한 결과, 워커 안에서 '워커 자신의 엔트리 파일을 무언가가 import하는' 경우에만 Safari에서만 두 번 실행됐습니다. 엔트리가 자기 자신을 동적 import하는 경우, 번들러 형태(지연 청크가 엔트리를 import), 정적 사이클(entry → helper → entry), 절대 URL로 import하는 경우 모두 동일했습니다. 반면 페이지(메인 스레드)에서 같은 형태, 엔트리와 청크가 별도 공유 모듈을 import하는 경우, 엔트리가 import("./main.js")만 하는 얇은 로더인 경우에는 정상이었습니다. 일반 Safari와 WebDriver 환경 모두에서 확인됐고, 다른 개발자가 Safari 26.3에서도 같은 문제를 겪었다고 하니 새로운 회귀도 아닙니다.

스펙과의 관계

HTML Standard는 각 워커에 모듈 맵(module map)을 두고, 모듈 워커의 톱레벨 스크립트도 이를 통해 URL 키로 가져오도록 규정합니다. 모듈 맵의 존재 목적이 'import된 모듈 스크립트가 Document 또는 워커당 한 번만 fetch·parse·evaluate되도록 보장'하는 것이므로, 같은 URL의 후속 import는 같은 모듈을 돌려줘야 합니다. Chrome과 Firefox가 그렇게 동작합니다. 단 키는 정확한 URL이므로 w.js?v=2로 시작하고 w.js로 import하면 legit하게 두 모듈이 됩니다. 팀은 해당 WebKit 버그나 이를 커버하는 WPT를 찾지 못해 WebKit bug 324459로 신규 등록했습니다.

영향과 점검 방법

코드 스플리팅으로 빌드된 워커라면 누구나 이 형태에 빠질 수 있습니다. 번들러는 워커 엔트리와 지연 청크 사이의 공유 코드를 흔히 엔트리 청크로 옮기고, 청크가 그것을 다시 import하기 때문입니다. Safari에서는 이 경우 레지스트리·캐시·싱글톤·'이미 초기화됨' 플래그·WASM 인스턴스가 이중화되고, 톱레벨 코드가 두 번 실행되어 리스너가 두 번 달리거나 onmessage가 재할당되거나 요청이 두 번 나갑니다. 무엇도 throw하지 않고, Safari에서만 조용히 무언가가 동작하지 않습니다.

빌드를 점검하려면 워커의 지연 로딩 청크에서 엔트리 파일명을 import하는지 찾으면 됩니다. 예: grep -l 'from"./your.worker-' dist/assets/*.js. 엔트리 자신이 아닌 파일이 잡히면 영향 대상입니다.

두 가지 우회법

첫째, 공유 코드를 엔트리 밖으로 빼는 방법입니다. 이 팀이 쓴 방식으로, Rollup의 manualChunks로 Mediabunny에 별도 청크를 주면 엔트리와 ProRes 청크가 모두 그 청크를 import하게 됩니다. 이후 Safari에서 623MB와 5GB ProRes 테스트 파일이 모두 변환됐습니다. 둘째, 엔트리를 import("./main.js")만 하는 얇은 로더로 만드는 방법입니다. 아무것도 엔트리를 import하지 않으므로 이중화될 대상 자체가 없고, 다른 개발자가 쓴 이 방식도 세 브라우저 모두에서 통과합니다. 첫째는 좁게 한 라이브러리만 옮기고, 둘째는 번들러가 나중에 엔트리로 끌어올릴 모든 것을 커버합니다.

시사점

이 버그는 실제 브라우저에서 모든 도구를 E2E로 돌렸기에 드러났습니다. safaridriver와 geckodriver를 통한 WebDriver, 실제 영상 파일, 그리고 모든 출력 파일의 ffprobe 검사와 ffmpeg 디코딩까지 수행한 결과입니다. 실패가 '정중하고 그럴듯한' 에러 메시지로 나타났기에, 빠른 수동 점검이었다면 그대로 통과했을 종류의 버그였습니다.

원문: dev.to / 번역·요약: Trawling