Skip to content

Commit 29062f5

Browse files
lifeartclaude
andcommitted
fix(gxt-backend): cap classic-tag reactor fires to break runaway leaks
Diagnostic instrumentation (commit 34f9515) confirmed leaked classic-tag reactors firing 9,000+ times across unrelated tests when their self-unsub heuristics don't trip. The runaway saturates the runloop and produces the testem 1200s browser-timeout that turned 13-minute Basic Test runs into 41-minute hangs. This commit doesn't fix the leak source — multiple prior attempts to drain reactors at test boundaries (commits 401df7b, 276a1a4, 6deded4, ef6ce28, all reverted) regressed CI badly because the leaked reactors are doing load-bearing work for unrelated tests in some way that hasn't been identified. What this commit does is bound the cost of the runaway: every reactor self-destructs after firing 10,000 times globally, which is two orders of magnitude above any plausible real-app bound and far past where a leaked reactor's work becomes pure overhead. The cap fires inside _fireClassicReactors so the unsub is synchronous and the reactor leaves _classicReactors immediately. Local cumulative reproducer with the cap in place: 9 reactors reached the 10,000-fire mark and were unsubscribed cleanly. The browser stays responsive instead of saturating. Smoke (14 modules / 333 tests) green. Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
1 parent 7e9bb9e commit 29062f5

1 file changed

Lines changed: 31 additions & 4 deletions

File tree

packages/@ember/-internals/gxt-backend/validator.ts

Lines changed: 31 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -701,17 +701,44 @@ export function registerClassicReactor(cb: () => void, source?: string): () => v
701701
};
702702
}
703703

704+
// Hard cap on per-reactor fires to break runaway loops. A leaked
705+
// classic-tag reactor whose self-unsub heuristic doesn't trip can
706+
// fire 10,000+ times across unrelated tests (diagnostic runs showed
707+
// a single reactor at 9,391 fires before the testem 1200s browser
708+
// timeout fired). The cap doesn't stop the leak — it stops the
709+
// runaway from saturating the runloop and hanging CI. Real-app
710+
// reactors fire bounded times per render path; 10,000 fires for a
711+
// single reactor across the lifetime of one page load is two orders
712+
// of magnitude above any legitimate bound.
713+
const REACTOR_FIRE_HARD_CAP = 10_000;
714+
704715
function _fireClassicReactors() {
705716
if (_classicReactors.size === 0) return;
706717
// Copy to avoid mutation during iteration
707718
const snapshot = Array.from(_classicReactors);
708719
const debug = (globalThis as any).__GXT_LEAK_DEBUG__;
709720
const currentTest = debug ? _currentTestName() : '';
710721
for (const cb of snapshot) {
711-
if (debug) {
712-
const meta = _reactorMeta.get(cb);
713-
if (meta) {
714-
meta.fireCount++;
722+
const meta = _reactorMeta.get(cb);
723+
if (meta) {
724+
meta.fireCount++;
725+
if (meta.fireCount > REACTOR_FIRE_HARD_CAP) {
726+
// Self-destruct: this reactor has fired beyond any plausible
727+
// bound and is almost certainly leaked. Skip the call and
728+
// remove it from the registry so it stops contributing to
729+
// the runloop saturation that produces the testem 1200s
730+
// browser timeout.
731+
if (debug) {
732+
// eslint-disable-next-line no-console
733+
console.log(
734+
`[leak-debug] CAP reactor #${meta.id} src=${meta.source} regAt="${meta.registeredAtTest}" fires=${meta.fireCount} — unsubscribing`
735+
);
736+
}
737+
_classicReactors.delete(cb);
738+
_reactorMeta.delete(cb);
739+
continue;
740+
}
741+
if (debug) {
715742
// Cross-test leak: reactor registered during a different test
716743
// than the one currently executing.
717744
if (

0 commit comments

Comments
 (0)