[3] Cross-thread destruction of WebGPU CommandBuffer on Metal's completion queue
A queue that quietly declines work picks the thread your destructor runs on.
High. 두 thread가 동일한 refcount word에 대해 non-atomic read-modify-write를 수행합니다. 갱신이 하나 유실되면 살아 있는 holder가 남은 상태에서 count가 0에 도달하거나, deref()가 0에 두 번 도달하게 됩니다. race window는 RemoteGPU teardown 시점에 열리며, 이 teardown은 renderer가 직접 유발합니다.
WebKit의 GPU process 그래픽 스택에서 thread를 넘나드는 reference counting은 single-owner 규칙으로 안전성을 확보합니다. refcount가 non-atomic인 객체는 그 객체를 소유한 thread만 건드린다는 전제입니다. WebGPU의 Metal backend는 Instance마다 하나씩 존재하는 thread 위에서 작업을 수행하는데, 여기서 Instance란 WebContent process에서 들어오는 WebGPU IPC를 처리하는 RemoteGPU 단위의 root 객체를 말합니다. 반면 Metal 자신은 completion handler를 자체 dispatch queue에서 호출하며, 이 thread는 WebGPU의 소유가 아닙니다. 둘 사이를 잇는 것이 work queue를 한 번 거치는 hop입니다. Metal thread에서 실행되는 block의 역할은 실제 정리 작업을 소유 thread로 되돌려 보내는 것뿐이고, 설계 전체가 이 hop이 항상 완료된다는 전제 위에 서 있습니다.
관전 포인트: WebGPU를 사용하는 페이지가 command buffer 완료 시점에 맞춰 GPU connection을 끊으면, 마지막 reference가 Metal thread에서 해제되도록 유도할 수 있습니다. 이때 WebGPU work queue thread와 공유되는 non-atomic refcount가 torn 상태에 빠집니다.
Metal thread 쪽 진입 지점과 여기서 이루어지는 strong capture:
Source/WebGPU/WebGPU/CommandBuffer.mm
// CommandBuffer::makeInvalidDueToCommit()은 protect(*this) — 즉 강한
// Ref<CommandBuffer> — 를 capture하는 Metal completion handler를 등록합니다.
// 그 body는 동일한 강한 Ref를 실은 inner lambda를 Queue::scheduleWork에
// post합니다.
count가 torn 상태가 되는 non-atomic 멤버:
Source/WebGPU/WebGPU/CommandEncoder.h
class CommandEncoder : public CommandsMixin, public RefCountedAndCanMakeWeakPtr<CommandEncoder>
Patch Details
이번 수정은 세 가지 방식으로 single-owner invariant를 복원했습니다. 먼저 Instance::retainCommandBuffer가 Ref<CommandBuffer>를 새로 추가된 m_retainedCommandBufferInstances 컨테이너에 보관합니다. 이 컨테이너는 WeakObjCPtr<id<MTLCommandBuffer>>를 키로 사용하고, 해당 포인터가 nil이 된 뒤에 지연 방식으로 정리됩니다. 그 결과 Metal thread block의 deref가 마지막 deref가 되는 상황은 발생하지 않습니다. 다음으로 wgpuInstanceRelease에는 deref() 직전에 waitForCommandBufferCompletions() 호출이 추가되었습니다. commit된 모든 command buffer에 대해 [commandBuffer waitUntilCompleted]를 수행하는 함수이며, handler 실행이 끝날 때까지 C API reference를 살려둡니다. 마지막으로 post되는 inner lambda의 capture가 강한 Ref<CommandBuffer>에서 ThreadSafeWeakPtr<CommandBuffer>로 낮춰졌습니다.
queue가 조용히 버릴 수 있는 work item에 객체 수명이 묶여 있어, 소유하지 않은 thread가 마지막 reference의 해제 지점이 되는 패턴.
Background
이 코드가 있는 위치. Source/WebGPU/WebGPU/는 GPU process 안에서 Metal을 backend로 사용하는 WebGPU 구현입니다. WebContent에서 들어오는 RemoteGPU/RemoteDevice/RemoteQueue IPC를 처리합니다. CommandBuffer와 CommandEncoder는 각각 id<MTLCommandBuffer>와 encode된 command 상태를 감싸는 래퍼입니다.
Thread 모델. 작업은 평소 Instance마다 하나씩 존재하는 StreamConnectionWorkQueue thread에서 실행됩니다. 다만 Metal은 addCompletedHandler:/addScheduledHandler: block을 자체 dispatch queue에서 호출하며, 이 thread는 WebGPU의 소유가 아닙니다. Queue::scheduleWork가 Metal thread의 작업을 소유 thread로 되돌리는 hop 역할을 담당합니다.
Atomic refcount와 non-atomic refcount. WebKit에는 두 계열이 있습니다. ThreadSafeRefCounted는 증가와 감소를 atomic 연산으로 처리하므로 동시 접근에서도 안전합니다. 반면 일반 RefCounted는 그렇지 않으며, 카운터에 대한 동시 read-modify-write는 엄밀한 의미의 data race에 해당합니다. 두 thread가 같은 이전 값을 읽고 둘 다 동일하게 감소된 값을 기록하면, 갱신 하나가 통째로 사라지는 셈입니다. CommandBuffer 자체는 ThreadSafeRefCountedAndCanMakeThreadSafeWeakPtr<CommandBuffer>이지만, 그 멤버들까지 모두 같은 성질을 갖지는 않습니다.
Work item의 소멸 지점. queue에 제출된 lambda가 실행되지 못하고 버려지면, 그 자리에서, 즉 dispatch를 호출한 thread에서 소멸합니다. capture한 값들도 그 thread에서 해제됩니다. C++의 평범한 동작이지만, 이번 버그를 떠받치는 핵심 사실이기도 합니다. 버려진 work item은 의도했던 대상 thread가 아니라 제출한 thread에서 capture를 해제합니다.
Analysis
이 버그는 thread를 넘나드는 소멸 race이며, 그 결과로 non-atomic reference count가 torn 상태가 됩니다. 최종적으로는 use-after-free이고, 동기화되지 않은 컨테이너 변경 race가 부수적으로 함께 발생합니다.
Metal completion thread WebGPU work-queue thread
────────────────────── ────────────────────────
completion handler fires RemoteGPU teardown begins
Queue::scheduleWork(lambda) m_shouldQuit = true
└─ dispatch() drops item ObjectHeap released
lambda destroyed in place (RemoteCommandBuffer_Destruct)
last Ref<CommandBuffer> │
~CommandBuffer() here │
└─ ~RefPtr<CommandEncoder> │
non-atomic deref ──────────┼──► non-atomic deref
▼ on the same word
commit message에 따르면, m_shouldQuit가 설정된 뒤에는 StreamConnectionWorkQueue::dispatch가 제출된 work item을 아무 통지 없이 버립니다. 버려진 lambda는 Metal thread에서 소멸하고, 함께 capture하고 있던 강한 Ref<CommandBuffer>도 그 자리에서 해제됩니다. 이때 GPU process의 ObjectHeap 항목이 RemoteCommandBuffer_Destruct/RemoteCommandEncoder_Destruct로 이미 해제된 상태라면, 그 해제가 마지막 reference에 해당합니다. 그러면 ~CommandBuffer가 Metal thread에서 실행됩니다.
CommandBuffer 자신의 count는 atomic이므로 이 부분은 문제 없이 넘어갑니다. 손상은 멤버 쪽에서 발생합니다. CommandBuffer.h는 RefPtr<CommandEncoder> m_commandEncoder를 선언하고 있고, CommandEncoder는 RefCountedAndCanMakeWeakPtr, 즉 non-atomic입니다. ~CommandBuffer()는 일반적인 멤버 정리 과정으로 이 멤버를 소멸시키면서 Metal thread에서 non-atomic 감소를 수행합니다. 같은 시각 work queue thread는 ObjectHeap을 정리하며 동일한 카운터를 감소시킵니다. 감소가 하나 유실되면 객체가 leak됩니다. 반대로 증가가 유실되거나, 두 thread가 같은 이전 값을 읽고 둘 다 감소된 값을 기록하는 interleaving이 발생하면 결과가 달라집니다. 다른 holder가 여전히 포인터를 쥐고 있는 상태에서 count가 0이 되거나, deref()가 0에 두 번 도달하게 됩니다.
commit message는 두 번째 경로도 언급합니다. ~CommandEncoder는 lock 없이 관리되는 Device::m_commandEncoderMap을 변경하는데, 이 동작이 work queue thread의 removeCommandEncoder와 경쟁합니다. lock 없이 HashMap을 동시에 변경하면, refcount race와 무관하게 테이블 자체가 손상될 가능성이 있습니다.
같은 경계에는 짝을 이루는 경로가 하나 더 있습니다. Queue::scheduleWork에 도달하는 WebGPU의 addCompletedHandler/addScheduledHandler는 모두 Metal thread에서 ThreadSafeWeakPtr로부터 임시 RefPtr<Instance>를 생성합니다. Device.h의 Device::instance()가 m_instance.get()을 기반으로 RefPtr<Instance>를 반환하기 때문입니다. 이 임시 객체가 work queue thread의 wgpuInstanceRelease deref보다 오래 살아남는 경우를 가정할 수 있습니다. 그 경우 ~Instance가 Metal thread에서 실행되고, retainedDeviceInstances의 Ref<Device> 키들도 그 thread에서 소멸합니다.
수정의 세 번째 항목은 보조 장치가 아니라 반드시 필요한 부분이며, commit message도 이를 명시합니다. Instance::scheduleWork는 work item을 makeBlockPtr(WTF::move(workItem)).get()으로 감싸는데, 이 ObjC wrapper는 Metal thread에서 autorelease되고 waitUntilCompleted가 반환된 뒤에 drain됩니다. inner capture가 강한 참조로 남아 있으면 waitForCommandBufferCompletions()가 제공하는 anchor보다 오래 살아남게 됩니다. 따라서 anchor가 실제로 lifetime을 한정하려면 ThreadSafeWeakPtr로 낮추는 변경이 함께 필요합니다.
renderer는 평범한 WebGPU 사용과 connection teardown만으로 이 경로에 도달할 수 있고, teardown 시점 역시 renderer가 직접 결정합니다. 그만큼 GPU process의 memory safety를 약화시키는 vulnerability에 해당합니다.
Audit directions
- Teardown 중 work item을 버리는 queue.
dispatch가 item을 실행하지 않은 채 반환할 수 있는 queue라면, 제출한 thread가 그 item의 capture가 소멸하는 지점이 됩니다. 여기서는m_shouldQuit상태의StreamConnectionWorkQueue::dispatch가 그 사례입니다. 점검 대상은 quit flag를 가진 WebKit의 다른 work queue 전부이며, 대상 thread에서 해제될 것을 전제로 강한 참조를 capture하는 호출 지점과 함께 살펴봐야 합니다. 리뷰에서의 단서는 "나중에 실행될 것"이라는 전제가 주석이나 구조에 깔려 있는 함수에서dispatch()의 반환값이 무시되는 경우입니다. - 바깥 객체는 atomic, 멤버는 non-atomic인 조합. 어떤 타입이
ThreadSafeRefCounted라는 사실은 그 타입이 소유한 객체의 refcount 규율에 대해 아무것도 말해주지 않습니다.CommandBuffer는 thread-safe였지만, 정작 보유하고 있던RefPtr<CommandEncoder>는 그렇지 않았습니다. 다른ThreadSafeRefCounted그래픽 타입들을 훑으면서, destructor에서 도달 가능한 일반RefCounted멤버가 있는지 확인할 필요가 있습니다. 리뷰 단서는ThreadSafeRefCounted클래스 선언의 멤버 목록에RefPtr<T>가 있고, 그T의 base가 thread-safe하지 않은 경우입니다. - Destructor에서 lock 없이 변경되는 컨테이너.
~CommandEncoder가 lock 없이Device::m_commandEncoderMap을 건드리는 동작은 소멸이 특정 thread에 묶여 있을 때에만 안전합니다. destructor가 공유 map에서 자신을 해제 등록하는 지점이 있다면, thread 한정이 실제로 강제되고 있는지 아니면 전제로만 남아 있는지 확인해야 합니다.