← All reports

WASM multi-memory grow refreshes only the memory that changed

Growing one shared memory refreshed every other memory in the instance.

Component: JSC WebAssembly memory | 4002a93

WASM multi-memory는 하나의 모듈이 여러 개의 linear memory를 동시에 import할 수 있게 해주는 기능입니다. 이때 shared: true로 선언된 메모리와 그렇지 않은 메모리가 한 모듈 안에 섞일 수 있습니다. 전자는 SharedArrayBuffer 기반이라 여러 thread와 worker에서 접근 가능하고, 후자는 thread-local입니다. 각 JSWebAssemblyInstance는 메모리별로 {base, boundsCheckingSize} 쌍을 캐시해 둡니다. 덕분에 JIT이 생성한 bounds check가 살아 있는 객체를 dereference하지 않고 단순한 정수 비교로 처리됩니다. 한편 BufferMemoryHandle은 특정 메모리를 import한 모든 instance, 즉 모든 "anchor"를 thread 경계를 넘어 추적합니다. shared memory에 대한 grow()가 발생하면 각 consumer에게 갱신을 알릴 수 있는 구조입니다.

문제는 shared WebAssembly.Memory를 grow할 때의 동작이었습니다. 모든 anchor를 순회하면서 updateCachedMemories()를 호출했는데, 이 함수는 해당 instance의 메모리 캐시 전체를 갱신합니다. 다른 thread가 소유한 non-shared sibling memory까지 함께 갱신 대상에 포함된 셈입니다. 그런데 그 thread들도 각자 Memory::grow()를 수행하며, buffer를 교체하는 과정에서 기존 handle을 해제합니다. 결과적으로 갱신 로직이 이미 해제된 BufferMemoryHandle을 읽는 race가 성립합니다. 패치는 무차별 갱신을 updateMatchingCachedMemoriesConcurrently()로 대체했습니다. 이 함수는 각 메모리 슬롯의 Wasm::Memory::shared() 포인터가 실제로 grow된 SharedArrayBufferContents와 동일한지 확인하고, 일치하지 않으면 건너뜁니다.

race로 읽힌 값은 JIT으로 컴파일된 WASM 코드가 아무런 검증 없이 신뢰하는 {base, boundsCheckingSize} 쌍에 그대로 반영됩니다. race에서 밀리는 경우, 모든 load와 store를 통제하는 데이터에 stale하거나 이미 해제된 값이 주입될 가능성이 있습니다. 이 커밋이 단순한 threading 정리가 아니라 memory-safety fix로 분류되는 이유이기도 합니다. 오염되는 대상이 부수적인 상태가 아니라 bounds check의 입력값 자체이기 때문입니다.

앞으로 살펴볼 만한 패턴은, 알림을 fan-out하면서 정작 이벤트가 무효화한 범위보다 더 넓은 상태를 갱신하는 구조입니다. cross-thread callback이 anchor나 subscriber, observer 레지스트리를 순회한 뒤 대상의 캐시 상태를 전부 다시 읽는 코드가 있다면 주의가 필요합니다. 여분의 슬롯들은 알림을 보내는 쪽과 lock을 공유하지 않는 thread가 소유하고 있을 수 있습니다. 나머지 anchors() 사용처와, shared object의 변경에서 도달 가능한 per-instance 캐시 갱신 경로를 같은 관점으로 점검해 볼 필요가 있습니다. 판별 기준은 단순합니다. per-target callback의 본문이 이벤트가 지목한 단일 슬롯만 건드리지 않고 컨테이너 전체를 순회하고 있는지 보면 됩니다.