← All reports

[6] Range boundary-point lock discipline follow-up

MediumWebCore DOMRace

621e3bf

Medium, and only because the lock it tightens already exists — this is review follow-up on the Range locking work, closing a window where a concurrent reader could observe a start/end pair drawn from two different states. Whether that window has real teeth depends on whether the GC-side reader retains bare node pointers past the synchronized region.

A live DOM Range keeps two endpoints — a start and an end — that must satisfy one relationship: start comes before or equals end in document order. When script sets one endpoint past the other, the specification requires collapsing the range onto the newly set endpoint. That repair is a second write, and the two writes together form one logical update; a reader that lands between them sees an inverted range, a configuration the code itself treats as invalid. The endpoints are guarded by m_boundaryPointLock, and the seven static boundary* helpers that mutation notifications call all assume — but until this commit did not declare — that their caller holds it.

The angle: for defensive value — this closes the intermediate-state window a marking thread could be scheduled into, and turns an unstated lock precondition into something a future refactor cannot silently drop.

Unreviewed. Addressing the review comments in the original PR.

Source/WebCore/dom/Range.cpp

ExceptionOr<void> Range::setStart(Ref<Node>&& container, unsigned offset)
{
auto childNode = checkNodeOffsetPair(container, offset);
if (childNode.hasException())
return childNode.releaseException();
 
+ bool shouldAlsoSetEnd = !is_lteq(treeOrder(BoundaryPoint(container.copyRef(), offset), makeBoundaryPoint(m_end)));
{
Locker locker { m_boundaryPointLock };
m_start.set(WTF::move(container), offset, childNode.releaseReturnValue());
+ if (shouldAlsoSetEnd)
+ m_end = m_start;
}
- if (!is_lteq(treeOrder(makeBoundaryPoint(m_start), makeBoundaryPoint(m_end)))) {
- Locker locker { m_boundaryPointLock };
- m_end = m_start;
- }
+
updateAssociatedSelection();
updateDocument();
updateAssociatedHighlight();
...
-static inline void boundaryNodeWillBeRemoved(RangeBoundaryPoint& boundary, Node& nodeToBeRemoved)
+static inline void boundaryNodeWillBeRemoved(Locker<Lock>&, RangeBoundaryPoint& boundary, Node& nodeToBeRemoved)
{
if (boundary.childBefore() == &nodeToBeRemoved)
boundary.childBeforeWillBeRemoved();
...
void Range::nodeWillBeRemoved(Node& node)
{
Locker locker { m_boundaryPointLock };
- boundaryNodeWillBeRemoved(m_start, node);
- boundaryNodeWillBeRemoved(m_end, node);
+ boundaryNodeWillBeRemoved(locker, m_start, node);
+ boundaryNodeWillBeRemoved(locker, m_end, node);
m_didChangeForHighlight = true;
}

Two distinct changes, both in Range.cpp.

First, Range::setStart and Range::setEnd no longer split a single logical update across two separate Locker { m_boundaryPointLock } scopes. Previously the code took the lock, wrote m_start (resp. m_end), released the lock, evaluated treeOrder(makeBoundaryPoint(m_start), makeBoundaryPoint(m_end)), and — if the range had become inverted — re-acquired the lock to collapse the other endpoint. Now the ordering comparison is hoisted above the critical section and computed from the incoming arguments (BoundaryPoint(container.copyRef(), offset)) against the current opposite endpoint, storing the result in a bool shouldAlsoSetEnd / bool shouldAlsoSetStart; both m_start.set(...) and the conditional m_end = m_start (resp. m_start = m_end) then happen inside one critical section. Note the hoisted predicate still reads the opposite endpoint outside the lock — the patch folds the write side into one critical section, it does not add locking to that read.

Second, seven static boundary-mutation helpers — boundaryNodeChildrenChanged, boundaryNodeChildrenWillBeRemoved, boundaryNodeWillBeRemoved, boundaryTextInserted, boundaryTextRemoved, boundaryTextNodesMerged, boundaryTextNodesSplit — gain an unnamed leading Locker<Lock>& parameter, and their callers (Range::nodeChildrenChanged, nodeChildrenWillBeRemoved, nodeWillBeRemoved, textInserted, textRemoved, textNodesMerged, textNodeSplit) pass their local locker. This is lock-discipline plumbing: it makes "caller must hold m_boundaryPointLock" part of each helper's signature rather than an unstated convention. No functional change to the helper bodies.

A multi-field update split across two separate critical sections, letting a concurrent reader observe an intermediate state that no single-threaded observer can reach.

Live Range and its endpoints. A DOM Range obtained from document.createRange() stores its endpoints as two RangeBoundaryPoint members, m_start and m_end. RangeBoundaryPoint holds Ref<Node> m_container, unsigned m_offset, and RefPtr<Node> m_childBefore; because it holds a strong Ref, the container node stays alive as long as the boundary point references it.

Mutation notifications. When the DOM tree changes, Document notifies every attached live range through Range::nodeChildrenChanged, nodeChildrenWillBeRemoved, nodeWillBeRemoved, textInserted, textRemoved, textNodesMerged, and textNodeSplit, each of which fixes up both boundary points via a static boundary* helper.

NODELETE. An annotation applied to some of these helpers in Range.cpp, marking code paths that must not trigger object destruction. The helpers that carry it are exactly those whose visible bodies only adjust offsets, while the unannotated ones reassign a boundary's container.

Non-thread-safe refcounting. WebCore Node reference counts are not atomic, so in general only the main thread may increment or decrement them.

GC-thread visiting. JSC's garbage collector traces the heap on dedicated marking threads. DOM wrappers participate through visitAdditionalChildrenInGCThread; for Range this is JSRange::visitAdditionalChildrenInGCThread, which calls Range::visitNodesInGCThread(visitor). Opaque roots are the mechanism by which a wrapper tells the collector "keep this native subgraph's wrappers alive."

Lock / Locker. WTF's mutex and its RAII scoped-acquisition helper. Range declares mutable Lock m_boundaryPointLock. A Locker releases the lock when its enclosing scope exits.

treeOrder. treeOrder(BoundaryPoint, BoundaryPoint) returns a std::partial_ordering describing which of two boundary points comes first in document order; is_lteq tests for "before or equal." The DOM specification requires setStart/setEnd to collapse the range onto the newly set endpoint when the resulting range would be inverted.

The root cause is a lock-granularity defect: m_boundaryPointLock already existed and already guarded the individual writes, but a single logical two-endpoint update was performed as two critical sections with an unlocked gap between them.

  Main thread (pre-patch setStart)      Marking thread
  ────────────────────────────────      ──────────────
  Locker { lock }
    m_start.set(new)   ← drops
      m_start's OLD container ref
  } release
                                        ── scheduled here ──
                                        reads m_start (new)
                                        reads m_end   (stale)
                                        => inverted pair: a state
                                           no single-threaded
                                           caller can observe
  treeOrder(m_start, m_end) -> inverted
  Locker { lock }
    m_end = m_start
  }

Be precise about which reference is released where. In pre-patch setStart, the first critical section's m_start.set(...) drops m_start's own previous container reference; m_end keeps its Ref<Node> intact throughout the gap and is only overwritten — dropping its old container reference — by the second block. So the intermediate state exposed in the gap is (a) the inverted start/end pair and (b) the release of m_start's previous container, not a dangling m_end. Post-patch, both the write to m_start and the conditional collapse of m_end occur under a single Locker, so no reader can be scheduled between them.

The reader in question is JSRange::visitAdditionalChildrenInGCThread calling wrapped().visitNodesInGCThread(visitor) — a JSC marking thread reading a live Range's boundary points off the main thread. Whether the GC-side reader takes m_boundaryPointLock itself is what decides how much teeth this window has, and this patch does not touch Range::visitNodesInGCThread — so whether a genuinely unsynchronized concurrent reader remains is an open question rather than something the change settles.

The patch closes the write-side tear only. The predicate that decides whether to collapse still reads the opposite endpoint outside the lock: post-patch setStart evaluates makeBoundaryPoint(m_end) before acquiring the lock, and setEnd evaluates makeBoundaryPoint(m_start) before acquiring it. The residual shape is "unlocked read of one endpoint, then locked atomic write of both," not a fully serialized read-modify-write. Whether that residual read matters depends on which other threads may write those fields; the mutation-notification paths in this file all write under the lock on the main thread.

Where the race would have teeth is worth mapping precisely. Four of the seven boundary helpers — boundaryNodeChildrenWillBeRemoved, boundaryNodeWillBeRemoved, boundaryTextNodesMerged, boundaryTextNodesSplit — are not annotated NODELETE, unlike boundaryNodeChildrenChanged, boundaryTextInserted, and boundaryTextRemoved, and those four are exactly the ones whose visible bodies reassign a boundary's container and can therefore release a node reference. That partition is the map of where a reference release, and therefore a stale-pointer read, could occur.

Discovery is most likely code review. The change has the shape of review follow-up on an earlier locking patch: a reviewer noticing that setStart/setEnd performed one logical update across two critical sections, and that the boundary* helpers carried their lock precondition only by convention. The underlying race that motivated the original locking work was plausibly found either by auditing the surface reachable from JSC's marking threads — enumerating visitAdditionalChildrenInGCThread implementors and checking which read mutable main-thread state — or from a crash-report / ThreadSanitizer signal on a marking thread; which of the two is not determined here.

This defect weakens the consistency guarantee at the boundary between the main (mutator) thread and JSC's marking threads inside the WebContent process. The assumption at stake is that DOM state read from a GC thread is either immutable for the duration of the read or serialized as a whole transaction by a lock — the assumption m_boundaryPointLock exists to uphold. Before the fix, a marking thread scheduled in the unlocked gap could read a start/end pair composed of one updated and one stale endpoint, and the node whose reference the mutator released in the first critical section could be one the reader had already sampled as a bare pointer. If a reader that holds only unpinned node pointers exists, an attacker who reliably won such a race would hold a cross-thread stale-pointer read on DOM nodes in the renderer; that would remain confined to the WebContent sandbox and would still require a separate escape.

Insight: the restructuring is only possible because the predicate can be evaluated against the incoming arguments (BoundaryPoint(container.copyRef(), offset)) rather than against the post-write value of m_start — recognising that equivalence is what let the author collapse two critical sections into one instead of widening the lock to cover the comparison. The second half — threading an unnamed Locker<Lock>& through every helper — is a cheap type-level way to encode a lock precondition in a codebase where the mutating helpers are free functions a future refactor could easily call from an unlocked path. It does not prove the right lock is held, which is what WTF_GUARDED_BY_LOCK / assertIsHeld would add, but it makes the requirement impossible to overlook at the call site.