← All reports

[5] Committed CommandBuffer destruction thread decided by release ordering

MediumWebGPU Metal backendRace

8783151

Medium. 명시적인 owner와 release 시점의 drain이 사라지고, 그 자리를 내부 strong capture 두 개가 대신합니다. 그래서 둘 중 어느 쪽이 마지막 release를 수행하느냐에 따라 ~CommandBuffer()가 실행되는 thread가 결정됩니다. 가능한 ordering 가운데 일부에서만, 그 thread가 non-atomic child refcount와 device의 encoder map을 동시에 건드리는 위치에 놓이게 됩니다.

WebKit의 WebGPU 구현은 GPU process에서 동작하며, WebContent에서 오는 IPC 메시지를 받아 실행됩니다. 기록된 command buffer는 Metal로 전달되고, GPU 작업이 끝나면 Metal이 자신이 선택한 thread에서 callback을 호출합니다. 이때 commit된 GPU 작업 단위인 CommandBuffer는 thread-safe reference counting을 사용합니다. 반면 CommandBuffer가 참조를 들고 있는 CommandEncoder, 즉 그 명령들을 실제로 기록한 객체는 RefCountedAndCanMakeWeakPtr을 상속하므로 atomic이 아닌 counter를 씁니다. 이 조합이 안전하려면, command buffer의 마지막 release가 encoder의 counter와 device의 encoder map을 건드려도 되는 thread에서 일어나야 합니다.

관전 포인트: 마지막 release가 Metal의 completion thread에서 일어나면, non-atomic refcount와 device의 encoder map이 WebGPU IPC를 처리하는 thread와 동시에 변경됩니다. count가 깨지고 객체가 조기에 파괴되는 전형적인 형태입니다.

CommandBuffer::makeInvalidDueToCommit()는 더 이상 Ref<CommandBuffer>Instance::retainCommandBuffer()에 전달하지 않습니다. 그래서 Instance가 소유하던 m_retainedCommandBufferInstances 컨테이너도 Metal의 completion callback 이후까지 commit된 buffer를 붙잡아 두지 않게 되었습니다. 함께 wgpuInstanceRelease 안에 있던 waitForCommandBufferCompletions(), 즉 아직 처리 중인 작업을 모두 기다려 비우던 drain도 제거되었습니다. 그 대신 Queue::scheduleWork continuation이 strong protectedThis 사본을 다시 갖게 되었습니다. 이전에는 ThreadSafeWeakPtr만 capture하고 있었습니다.

여러 내부 reference 중 마지막에 release되는 쪽으로 ownership이 넘어가면서, cross-thread 객체의 파괴 thread가 설계가 아니라 타이밍에 의해 결정되는 구조.

  Ordering A (benign)                 Ordering B (hazardous)
  -------------------                 ----------------------
  Metal releases the block            the work item runs and is
  while the work item is still        destroyed before Metal
  pending                             releases the block
        |                                   |
  the work item holds the last Ref    the block release performs
        |                             the final deref()
  ~CommandBuffer() runs on the              |
  work item's thread                  ~CommandBuffer() runs on
                                      Metal's completion thread
                                            |
                                      non-atomic CommandEncoder
                                      deref + Device map mutation
                                      race the IPC-servicing thread

이 코드가 있는 위치. Source/WebGPU/WebGPU/는 WebGPU IPC 표면 뒤에 있는 네이티브 구현이며, WebContent에서 보내는 RemoteDevice, RemoteCommandEncoder, RemoteCommandBuffer, RemoteQueue 메시지로 구동됩니다. 그중 객체 lifetime의 핵심을 이루는 것이 CommandBuffer, CommandEncoder, Device, Instance입니다.

Metal completion handler. Metal은 addCompletedHandler:로 등록된 block을 자체 내부 thread에서 호출합니다. block이 capture한 값들은 framework가 그 block을 release할 때까지 살아 있습니다. 이 release 시점은 handler가 반환되는 시점과는 별개의 사건입니다.

두 가지 refcount 방식. CommandBufferThreadSafeRefCountedAndCanMakeThreadSafeWeakPtr을 상속합니다. 반면 CommandEncoderRefCountedAndCanMakeWeakPtr(CommandEncoder.h)을 상속하며, 여기에 쓰이는 counter는 atomic이 아닙니다. 이 차이가 각 객체에 대해 어떤 thread에서 ref()/deref()를 호출해도 되는지를 결정합니다.

Device의 encoder map. Device::createCommandEncoder()RemoteDevice IPC 메시지를 처리하는 thread에서 m_commandEncoderMap.set(commandEncoder->uniqueId(), commandEncoder.ptr())을 기록합니다. 같은 map에서 항목을 지우는 쪽은 Device::removeCommandEncoder(m_uniqueId)입니다. 이 map에는 lock이 없습니다.

패치 이후 makeInvalidDueToCommit()는 commit된 CommandBuffer에 대해 backend 내부 strong reference를 두 개 만듭니다. 하나는 addCompletedHandler: block에 값으로 capture되는 쪽(protectedThis = protect(*this))이고, 다른 하나는 그 block이 예약된 work item에 넣는 사본입니다. 물론 다른 strong reference도 한동안 함께 존재합니다. Queue::submitVector<Ref<CommandBuffer>>를 받고, WebKit 쪽 remote wrapper 역시 Destruct 메시지가 올 때까지 자체 reference를 들고 있을 가능성이 높습니다. 다만 그 wrapper는 IPC 경계의 WebKit 쪽에 있으므로 이 backend 범위 밖입니다. 이들이 모두 release되고 나면, 마지막 release는 앞서 언급한 내부 capture 두 개 중 하나에서 나옵니다. 어느 쪽이 마지막이 되느냐가 곧 파괴 thread를 결정하게 됩니다.

다시 들어온 strong capture 자체도 lifetime anchor 역할을 하며, 제거된 pin이 제공하던 보호의 일부를 대신합니다. 즉 단순한 제거가 아니라 부분적인 대체에 해당합니다. 다만 Metal이 addCompletedHandler: block을 handler 반환 시점에 대해 언제 release하는지는 framework 내부 구현 사항입니다. 그래서 다이어그램의 두 ordering이 모두 살아 있게 됩니다. ordering A에서는 work item이 마지막 reference를 들고 있으므로, ~CommandBuffer()는 그 item을 실행하거나 파괴하는 thread에서 동작하게 됩니다. 남는 위험은 ordering B입니다. work item이 Metal의 block release보다 먼저 실행되고 파괴되는 경우, 또는 schedule-work 구현이 그 item을 completion thread에서 동기적으로 dispatch하는 경우를 가정할 수 있습니다. 이때는 Metal의 release가 마지막 deref()를 수행하게 됩니다.

teardown 시점에는 두 번째 경로가 열리는데, 이쪽은 대체 장치가 전혀 없습니다. waitForCommandBufferCompletions()가 사라지면서 wgpuInstanceRelease는 더 이상 처리 중인 buffer를 기다리지 않습니다. Ref<CommandBuffer>를 들고 있는 pending work item이 ~Instance()의 일부로 파괴되는 상황을 가정해 볼 수 있습니다. Instance::defaultScheduleWork fallback은 이런 항목들을 m_pendingWork에 추가합니다. 이 경우 마지막 release는 instance를 release한 thread가 어디든 그 thread에서 일어나게 됩니다.

실제로 invariant가 깨지는 지점은 destructor입니다. ~CommandBuffer()retainTimestampsForOneUpdateLoop()를 호출하는데, 여기서 m_commandEncoder가 지역 RefPtr로 복사됩니다. 그 뒤 RefPtr<CommandEncoder> 멤버가 파괴됩니다. atomic이 아닌 counter에 대한 이 ref()/deref()는 같은 counter를 건드리는 다른 동시 접근과 race 상태에 놓이게 됩니다. count가 다른 thread에서 0에 도달하면, ~CommandEncoder()도 그곳에서 실행되면서 m_device->removeCommandEncoder(m_uniqueId)를 호출하게 됩니다. 결과적으로 Device::m_commandEncoderMapcreateCommandEncoder()와 동시에 변경될 수 있습니다. 여기서 중요한 것은 RemoteDevice IPC 메시지를 처리하는 thread가 구체적으로 무엇이냐가 아닙니다. Metal의 completion thread와는 다른 thread라는 사실만으로 map 변경이 동시에 일어나기 때문입니다. 한편 ~CommandEncoder()finalizeBlitCommandEncoder()clearTracking()도 호출합니다. 두 함수 모두 Metal encoder 상태와 ObjC 컬렉션을 아무런 동기화 없이 건드립니다.

이 commit이 해당 위험을 새로 만들어낸 것은 아닙니다. 다만 commit된 CommandBuffer의 파괴 thread가 명시적인 owner와 release 시점의 drain으로 고정되지 않고, release 순서에 따라 결정되는 소유 형태로 되돌아간 셈입니다. 불리한 ordering에서의 결과는 non-atomic refcount 손상, 즉 조기 파괴나 use-after-free입니다. 여기에 GPU process 안에서 동기화 없는 hash table 변경까지 더해집니다.