← All issues

[JSC] Use FrameTracer in wasm ref_func, table_get, and array_init_elem operations

Component: JSC WebAssembly | f771c50

JSC의 Wasm 구현에서는 funcref 값이 JS에서 처음 관찰되거나 table과 array 사이에서 복사될 때 (ensureFunctionWrapper 등을 통해) lazy하게 완전한 JS function wrapper 객체로 변환됩니다. 이 wrapper allocation 과정에서 GC가 유발될 수 있습니다. 문제는 Wasm tier들이 VM의 topCallFrame pointer를 just-in-time 방식으로만 갱신한다는 점입니다. 그래서 JS import call이 반환된 이후에는 topCallFrame이 이미 죽은 native state를 가리키고 있을 수 있습니다. FrameTracer는 바로 이런 GC 구간에서 collector와 ShadowChicken(profiler·debugger를 위해 native call stack을 재구성하는 메커니즘)을 위해 frame state를 일관되게 유지하는 역할을 합니다.

Source/JavaScriptCore/wasm/WasmOperations.cpp

JSC_DEFINE_NOEXCEPT_JIT_OPERATION(operationGetWasmTableElement, EncodedJSValue, (JSWebAssemblyInstance* instance, unsigned tableIndex, uint64_t index))
{
+ // FuncRefTable::get materializes the funcref's JS wrapper on demand.
+ CallFrame* callFrame = DECLARE_WASM_CALL_FRAME(instance);
+ assertCalleeIsReferenced(callFrame, instance);
+ VM& vm = instance->vm();
+ WasmOperationPrologueCallFrameTracer tracer(vm, callFrame, OUR_RETURN_ADDRESS);
return tableGet(instance, tableIndex, index);
}

Source/JavaScriptCore/wasm/WasmIPIntSlowPaths.cpp

WASM_IPINT_EXTERN_CPP_DECL(ref_func, unsigned index)
{
+ WasmSlowPathWithoutCallFrameTracer tracer(instance->vm());
IPINT_RETURN(Wasm::refFunc(instance, index));
}

이번 commit은 ref.func, table.get, array.init_elem 세 operation에 frame tracer를 추가합니다. JIT operation 경로와 IPInt slow path 양쪽 모두에 적용되었는데, 이 세 operation 모두 funcref에 대한 JS function wrapper를 생성할 수 있고, 그 결과 GC를 유발할 수 있기 때문입니다.

FrameTracer가 없으면, funcref wrapper를 생성하는 도중 발생하는 GC가 이전 JS import call이 남긴 stale topCallFrame을 건드리게 되고, 이로 인해 ShadowChicken이 이미 죽은 native state를 살아있는 JS CallFrame처럼 읽어버릴 수 있습니다. 이것은 frame-tracing의 일관성이 깨지는 버그로, table이나 typed function reference를 사용하는 평범한 Wasm 코드에서도 도달 가능합니다. 특이한 모듈 구조가 필요하지 않습니다.

앞으로 눈여겨봐야 할 패턴은, topCallFrame을 just-in-time으로만 갱신하는 tier에서 frame state를 세우지 않은 채 allocation을 수행할 수 있는 — 즉 GC를 유발할 수 있는 — slow path입니다. array-init-elem 테스트에 남아 있는 코멘트를 보면, 이전에 Wasm GC slow path를 점검했을 때도 바로 이 opcode를 놓쳤다고 되어 있습니다. 이유는 이 opcode 자체가 array를 직접 allocate하지 않기 때문인데, 이 지점이 바로 탐색의 기준점이 됩니다. 즉 opcode 자체의 최상위 레벨이 아니라 callee 내부에서 발생하는 allocation을 찾아야 한다는 뜻입니다. 좁게 보면, WasmIPIntSlowPaths.cppWasmOperations.cpp를 훑어 ensureFunctionWrapper, copyElementSegment처럼 wrapper를 생성하는 루틴을 호출하면서도 여전히 tracer가 없는 다른 operation을 찾아야 합니다. 조금 더 넓혀 보면, 함께 추가된 stress test의 --slowPathAllocsBetweenGCs=1 --forceGCSlowPaths=true 옵션 조합을 템플릿으로 활용할 수 있습니다. 이 옵션 조합은 tracer 누락 조건을 타이밍에 의존하는 우연한 현상에서 항상 동일하게 재현되는 트리거로 바꿔주기 때문에, 이 방식이 있어야 전수 점검이 현실적으로 가능해집니다. 가장 넓게 보면, lazy하게 유지되는 stack-walk anchor를 가진 runtime이라면 helper가 transitively allocation을 수행하는 모든 지점에서 동일한 구조적 위험이 존재합니다. 점검할 때 눈여겨봐야 할 신호는, 함수 본문 전체가 helper 호출 하나뿐이면서 그 위에 tracer 선언이 없는 slow-path 함수입니다.