← All issues

[13] DeferredWorkTimer leaves stale ticket pointer in m_tasks after cancellation

Severity: Medium | Component: JavaScriptCore DeferredWorkTimer | e7c6375

Medium으로 평가된 이유는 다음과 같습니다. 이 diff는 m_tasks에서의 stale pointer dequeue를 수정합니다. 첫 번째 pop이 TicketData를 해제하면 중복 항목이 해제된(또는 TZone에서 재사용된) pointer를 역참조합니다. 이 window에 도달하려면 충분한 수의 in-flight ticket이 있는 상태에서 cross-realm teardown이 필요합니다. primitive의 강도는 TZone 재사용이 해제된 slot에서 발생하는지 여부에 달려 있습니다.

cancelPendingWorkSafe()는 소멸 중인 global의 모든 weak ticket에 대해 무조건 (ticket, noop) 항목을 m_tasks에 추가하고 있었습니다. 그러나 이는 불필요한 동작이었습니다. doWork()에는 이미 removeIf(isCancelled) 처리 단계가 있어 취소된 ticket을 m_pendingTickets에서 제거하고, setTimeUntilFire(0_s) 호출이 doWork() 실행을 이미 보장하기 때문입니다.

Source/JavaScriptCore/runtime/DeferredWorkTimer.cpp

void DeferredWorkTimer::cancelPendingWorkSafe(JSGlobalObject* globalObject)
{
for (Ref<TicketData> ticket : *globalObject->m_weakTickets) {
if (!ticket->isCancelled())
cancelPendingWork(ticket.ptr());
- m_tasks.append(std::make_tuple(ticket.ptr(), [](DeferredWorkTimer::Ticket) { }));
}
if (!isScheduled() && !m_currentlyRunningTask)
setTimeUntilFire(0_s);

제거된 것은 단 한 줄입니다. 소멸 중인 JSGlobalObject에 속한 모든 weak ticket에 대해 (ticket, noop) tuple을 m_tasks에 무조건 추가하던 코드입니다. 나머지 로직은 그대로 유지됩니다. global의 m_weakTickets를 순회하면서 취소되지 않은 각 ticket에 대해 cancelPendingWork를 호출하고, setTimeUntilFire(0_s)를 통해 doWork() 실행을 예약합니다. 취소된 ticket의 정리는 doWork() 마지막 단계에 이미 존재하는 m_pendingTickets.removeIf(isCancelled) 처리에 맡겨집니다. regression test는 child global 안에서 FinalizationRegistry 인스턴스를 다수 생성한 뒤, global을 해제하고 GC를 수행한 다음, timer 기반 deferred work를 대량으로 예약하는 방식으로 구성되었습니다.

Work queue 내 stale raw pointer 재사용: 소유자 측 cleanup이 중복 raw pointer 항목을 추가했고, 이 항목들이 consumer 측의 소유 Ref 해제 이후에도 남아있었습니다.

DeferredWorkTimer는 두 개의 자료구조를 관리합니다. 첫 번째인 m_pendingTickets는 각 ticket의 keep-alive reference를 보유하는 HashSet<Ref<TicketData>>이고, 두 번째인 m_tasksTicket = TicketData*가 raw pointer인 Deque<tuple<Ticket, Task>>입니다. JS가 deferred work를 요청할 때(Atomics.waitAsync, FinalizationRegistry cleanup, WebAssembly promise 연동) ticket이 추가되고, doWork()는 VM API lock 하에서 m_tasks를 FIFO 방식으로 pop합니다. TicketData::cancel()m_isCancelled 플래그를 설정하지만, 어느 컨테이너에서도 ticket을 제거하지 않습니다. 정리는 doWork() 내부에서 지연 처리됩니다. cancelPendingWorkSafe(JSGlobalObject*)JSGlobalObject::~JSGlobalObject에서 호출되어 realm이 소멸 중인 ticket들을 정리합니다. TicketDataWTF_MAKE_TZONE_ALLOCATED_IMPL로 할당됩니다. 해제된 인스턴스는 type별로 분리된 freelist로 반환되며, 다음 TicketData::create 호출이 동일한 주소를 재사용할 수 있습니다. HashSet<Ref<T>>::find(rawPtr)는 pointer 값 기반의 해싱/동등 비교를 사용하며, 멤버십 결정 시 입력 pointer를 역참조하지 않습니다.

cancelPendingWorkSafe는 소멸 중인 global의 모든 weak ticket에 대해, 이미 대기 중인 항목과 별개로 (ticket, noop) 항목을 m_tasks에 추가했습니다. m_tasks에서 Ticket은 raw TicketData*로 저장되고, keep-alive Ref는 m_pendingTickets에 있습니다. doWork()의 메인 루프는 첫 번째 m_tasks 항목에서 취소된 ticket을 발견하면 m_pendingTickets.remove(pendingTicket)을 호출합니다. 이 호출이 마지막 Ref를 해제하고 TicketData를 소멸시킵니다. cancelPendingWorkSafe가 동일한 ticket pointer를 두 번째로 큐에 추가했기 때문에, 다음 루프 반복에서 pop되는 (ticket, noop)ticket은 이미 dangling TicketData* 상태가 됩니다.

🔒

The lifetime and reuse implications of a single removed `m_tasks.append` are explored in depth, including what the queue actually holds and what happens when the same pointer is enqueued twice.

더 확인하려면 구독해 주세요

🔒

Multiple reusable audit patterns identified across JSC runtime queues, with concrete starting points for variant discovery in deferred-work, microtask, and waiter-list bookkeeping.

더 확인하려면 구독해 주세요