RunLoop::Timer thread-affinity assertions and eight teardown race fixes
Component: WTF RunLoop | f53d1f2
RunLoop::Timer는 platform의 run loop timer를 감싸는 wrapper로, Cocoa에서는 CFRunLoopTimer를, GLib에서는 GSource를 사용합니다. 여기서 핵심은 이 wrapper의 모든 연산이 동일한 thread-safety 특성을 갖지 않는다는 점입니다. Timer를 시작하거나 다시 arm하는 동작은 어느 thread에서 호출하든 안전한데, 이는 작업을 (재)예약할 뿐 TimerBase를 해제하는 일이 결코 없기 때문입니다. dispatch() / dispatchAfter()와 JSRunLoopTimer가 이미 이 특성에 의존하고 있습니다. 반면 active 상태의 timer를 멈추거나 파괴하는 동작은 다릅니다. 이 경우 platform state를 실제로 해제하는데, owning thread에서 진행 중인 callback이 그 state를 여전히 참조하고 있을 수 있습니다.
이번 commit은 이 비대칭성을 ASSERT_WITH_SECURITY_IMPLICATION 체크로 명시합니다. Timer가 active 상태일 때는 stop()과 destructor가 반드시 해당 timer 자신의 run loop thread에서 실행되도록 요구합니다. 그리고 이 assertion들이 실제로 잡아낸 여덟 건의 cross-thread teardown race를 수정했는데, 그중에는 다른 agent의 thread에서 stop되면서 self-reference를 누수시키던 JSC Atomics.waitAsync timer도 포함되어 있습니다.
Any thread Owning run loop thread
---------- ----------------------
startOneShot() --+
startRepeating() +--> (re)schedule only safe: never frees TimerBase
dispatchAfter() --+
stop() --+
~Timer() +--> invalidate platform must run here: an in-flight
timer state callback may still be running
Significance
이번에 수정된 것들은 진행 중인 timer callback과 cross-thread teardown 사이에서 발생하던 실질적인 use-after-free 형태의 race였으며, JSC와 WebCore의 graphics·scrolling 코드, 그리고 WebKit의 compositor·event-dispatch 코드 전반에 걸쳐 있었습니다. 이 변경에서 오래 남는 부분은 assertion 쪽입니다. CFRunLoopTimerInvalidate()와 이미 발동된 callback이 나쁜 타이밍으로 겹칠 때만 재현되던 조용한 race를, 문제의 call site에서 즉시 발생하고 원인을 특정할 수 있는 crash로 바꾸어 놓았기 때문입니다.
Audit directions
앞으로 살펴봐야 할 패턴은 run loop를 소유하지 않은 thread에서 run-loop-owned state를 teardown하는 상황입니다. 좁게 보면, 이번 assertion은 RunLoop::Timer만 다루고 있어서, platform run-loop 리소스를 감싸는 동등한 WTF·WebCore wrapper들, 즉 observer, source, dispatch-suspended object 등에는 아직 같은 보호 장치가 없습니다. WorkQueue lambda에서 도달 가능한 stop()/invalidate()/destructor 경로를 점검할 필요가 있습니다. 알아볼 수 있는 단서는 owner가 ThreadSafeRefCounted인 member timer나 source입니다. Owner의 refcounting이 thread-safe하다는 사실이, 그 owner가 소유한 대상의 teardown까지 thread-safe하다는 착각으로 이어지는 경우가 흔하기 때문입니다. 조금 더 넓게 보면, 이번 JSC 사례, 즉 다른 agent의 thread에서 timer가 stop되면서 self-reference가 누수된 패턴은 stop 시점에서만 해제 경로를 갖는 모든 self-referencing callback object로 일반화됩니다. JSRunLoopTimer의 subclass들과 WebCore의 animation·scrolling timer들을 대상으로 동일한 self-Ref-released-on-stop 형태가 있는지 나열해 볼 필요가 있습니다. 가장 넓게 보면, 이 비대칭성 자체(어디서든 arm할 수 있지만 disarm은 owner에서만 가능한 구조)는 흔한 platform-wrapper 계약이면서도 call site에서 거의 문서화되지 않습니다. 그래서 arming API와 disarming API를 함께 제공하면서도 서로 다른 thread affinity를 명시하지 않은 wrapper는 모두 한 번씩 점검해 볼 가치가 있습니다.
조문된 커밋 항목들을 번역 규칙에 맞춰 한국어로 재작성하겠습니다.