From 40ff52aa4ab5de4678c5a4225b3046f223f4eee3 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 18 Jul 2026 12:11:30 +0000 Subject: [PATCH] DFG: stop rooting m_mustHandleValues from the concurrent compiler plan When a hot loop triggers Baseline->DFG (or DFG->FTL) OSR, the tier-up path snapshots every live argument and local of the triggering frame into Plan::m_mustHandleValues so the compiler can seed OSR-entry type predictions. Plan::checkLivenessAndVisitChildren marked those JSValues as roots for the life of the concurrent compile, which meant whatever user objects happened to be in scope at the tier-up point stayed alive until the plan was finalized. In Bun this showed up as Node's test-gc-http-client* suite reporting N-1/N collected: an in-flight plan was rooting one IncomingMessage (and the ClientRequest it points to) via RootMarkReason::JITWorkList. The compiler does not need those values kept alive on its own. Every reader of m_mustHandleValues (PredictionInjection, CFA injectOSR, TypeCheckHoisting) already skips nullopt. So: - stop visiting m_mustHandleValues in checkLivenessAndVisitChildren - in finalizeInGC (post-mark, compiler threads suspended), drop any entry whose cell was not otherwise marked Values that survive marking (still reachable from elsewhere, or already frozen into the Graph at an earlier safepoint) are unaffected. Values that are only reachable via the plan become nullopt; the OSR entry for that local simply widens to unknown. --- Source/JavaScriptCore/dfg/DFGPlan.cpp | 28 ++++++++++++++++++++++----- 1 file changed, 23 insertions(+), 5 deletions(-) diff --git a/Source/JavaScriptCore/dfg/DFGPlan.cpp b/Source/JavaScriptCore/dfg/DFGPlan.cpp index ec4d9b92dcd3..6c59c84dd25d 100644 --- a/Source/JavaScriptCore/dfg/DFGPlan.cpp +++ b/Source/JavaScriptCore/dfg/DFGPlan.cpp @@ -160,6 +160,23 @@ void Plan::finalizeInGC() ASSERT(m_vm); if (m_recordedStatuses) m_recordedStatuses->finalizeWithoutDeleting(*m_vm); + + // m_mustHandleValues was snapshot from the triggering frame's live locals + // so the compiler can seed OSR-entry predictions. It is not a reason to + // keep those objects alive on its own: the DFG phases that read it all + // treat nullopt as "unknown", so any entry that nothing else marked can be + // dropped here (compiler threads are suspended for the duration of GC). + cleanMustHandleValuesIfNecessary(); + { + Locker locker { m_mustHandleValueCleaningLock }; + for (unsigned i = m_mustHandleValues.size(); i--;) { + std::optional& value = m_mustHandleValues[i]; + if (!value || !*value || !value->isCell()) + continue; + if (!m_vm->heap.isMarked(value->asCell())) + value = std::nullopt; + } + } } void Plan::notifyReady() @@ -658,12 +675,13 @@ bool Plan::checkLivenessAndVisitChildren(AbstractSlotVisitor& visitor) if (!Base::checkLivenessAndVisitChildren(visitor)) return false; + // m_mustHandleValues is not visited here. It holds whatever JSValues were + // live in the frame at the tier-up point, purely to seed OSR-entry + // predictions; rooting them would keep arbitrary user objects alive for the + // life of a concurrent compile. finalizeInGC() nulls entries that nothing + // else marked, and every reader (PredictionInjection, CFA injectOSR, + // TypeCheckHoisting) already skips nullopt. cleanMustHandleValuesIfNecessary(); - for (unsigned i = m_mustHandleValues.size(); i--;) { - std::optional value = m_mustHandleValues[i]; - if (value) - visitor.appendUnbarriered(value.value()); - } if (m_recordedStatuses) { m_recordedStatuses->visitAggregate(visitor);