[Wasm] Use a lazy restore frame when returning from tail calls
JSTests/wasm/stress/tail-call-cross-instance-gc.js
각 WebAssembly instance는 memory base, memory bounds, instance pointer 등 instance별 데이터를 전용 레지스터에 고정합니다. tail call이 instance 경계를 넘을 때(return_call_indirect로 다른 module을 호출하는 경우 등), callee의 instance 정보가 해당 레지스터를 덮어씁니다. 이후 호출 chain이 원래의 non-tail caller로 복귀하는 경우, 실행을 재개하기 전에 그 레지스터를 반드시 복원해야 합니다.
이 commit은 기존의 compile-time transitive tail call clobbering 분석을 runtime "restore frame" 메커니즘으로 대체했습니다. Wasm tail call이 처음으로 instance 경계를 넘는 시점에, caller의 argument area 바로 위에 32바이트 frame이 지연 삽입됩니다. 이 frame에는 caller의 instance pointer와 원래 return address가 담기고, 나머지 frame 영역은 32바이트 아래로 이동됩니다. 복귀 시에는 전용 thunk(_wasm_restore_frame_return)가 instance 및 memory 레지스터를 재로드합니다. 아울러 callCanClobberInstance와 computeTransitiveTailCalls, 이에 따른 inlining 제한도 함께 제거되었습니다. 대신 ARM64E PAC re-signing 처리와 JIT cage gate thunk가 추가되었습니다. 한편 현재 return PC와 thunk 주소를 비교하는 재사용 확인 로직도 도입되어, 반복적인 cross-instance hop 시에도 restore frame이 중복 삽입되지 않도록 합니다. spec이 명시적으로 요구하는 동작입니다.
After (runtime restore frame, lazy insertion):
Caller frame (inst A)
arg area
[32-byte restore frame inserted above args, first cross-inst call only]
slot 0: original CallerFrameAndPC
slot 1: saved instance A
slot 2: RestoreFrameCallee
callee-saves <- entire frame shifted down 32 bytes
return -> new cfr -> _wasm_restore_frame_return -> reload inst A regs -> jump to original retPC
Reuse check: if retPC == thunk addr, skip insertion (no accumulation)
Significance
Cross-instance tail call chain이 이제 자유롭게 인라인될 수 있게 되었습니다. 다만 ABI 정확성은 새로운 runtime frame type에 의존하게 되었습니다. GC, stack unwinder, sampling profiler, 세 가지 Wasm backend(BBQ, OMG, IPInt) 모두가 이 frame type을 일관되게 처리해야 합니다. ARM64E PAC re-signing 처리도 이 범위에 포함됩니다. 전용 GC stress test를 함께 추가했다는 사실 자체가, 개발자들도 이 부분의 정확성 확보가 쉽지 않다는 점을 인식하고 있었음을 나타냅니다.
Audit directions
- Stack frame shifting copy loop. restore frame 삽입 시, caller의 전체 frame(callee-saves 포함)을 물리적으로 32바이트 아래로 이동하는 copy loop가 수행됩니다.
emitRestoreInstanceFrameIfNeeded의 copy loop는 stack에 위치한 source가 있을 경우 do-while 방식으로 동작하며, 불필요한 경우frameSize파라미터를 통해 건너뜁니다. copy 횟수나 방향에서 off-by-one 오류가 발생하면, 즉각 crash 없이 callee-save 레지스터가 조용히 손상되거나 인접 데이터를 덮어쓸 가능성이 있습니다. - ARM64E PAC re-signing. 원래 return PC는 caller frame의 PAC signing context에서 restore frame의 PAC context로 재서명되어야 합니다. 이 commit은
!Options::allowNonSPTagging()을 명시적으로 특수 케이스로 처리합니다. 사용된 context나 key가 잘못되거나, non-SP-tagging 경로에서 엣지 케이스가 누락된 경우, 위조 가능한 return address가 생성되거나 PAC bypass로 이어질 가능성이 있습니다. - Reuse-check correctness across backends. "중복 삽입 방지" 불변 조건은 전적으로
ReturnPC == thunk-address비교에 의존합니다. 이 비교는 BBQ, OMG, IPInt, JIT cage gate thunk 전체에서 일치해야 합니다. 정상적인 return address가 thunk 주소와 충돌하거나, gate thunk 주소가 비교 대상과 다를 경우, restore frame이 건너뛰어지거나(잘못된 instance 복원) 중복 삽입되는(spec 위반 stack 증가) 문제가 발생할 가능성이 있습니다. - GC and unwinder correctness. restore frame은 새로운 frame type입니다. GC는 해당 frame의
Callee(RestoreInstanceCalleesingleton) 슬롯과CodeBlock(wasmInstancepointer) 슬롯을 scanning해야 합니다. stack unwinder와 sampling profiler 역시 이 frame type을 인식하고 순회해야 합니다. GC 부하 하에서 간헐적으로 발생하는 경우라도, 이 처리에 실패하면 use-after-free나 profiler symbolication 오류로 이어질 가능성이 있습니다. - JIT cage gate thunk consistency.
wasmRestoreFramegate thunk는 JIT key로 서명된 return PC를 정확히 untag해야 합니다._wasm_restore_frame_return의 로직과 어긋나는 부분이 있을 경우, security boundary 불일치로 이어지며 untagging 처리를 혼란시키는 데 활용될 가능성이 있습니다.