Summary
We encountered an intermittent, non-deterministic failure in our CI/CD pipeline (Jenkins) where Karma tests would fail with a RangeError: Maximum call stack size exceeded at Array.sort. This error was highly elusive, appearing only once in hundreds of test runs. While the application logic appeared sound during manual QA and local development, the flaky test behavior in the headless Chrome environment suggested a race condition or leaked state between test suites rather than a logic error in the production code.
Root Cause
The investigation revealed that the error was not caused by actual deep recursion in the application logic, but by asynchronous state leakage and uncontrolled event listener accumulation within the testing environment.
- Global Event Pollution: The
$(window).on("resize")listener was being attached to the globalwindowobject. WhileafterEachattempted to clean up, the asynchronous nature ofsetTimeoutandjasmine.Clock.tickmeant that some timers were still scheduled to fire after the test context had already been torn down. - Mock Clock Desynchronization: Using
jasmine.Clock.useMock()manipulates the passage of time. If a test triggers asetTimeoutand the clock is advanced, but the cleanup doesn’t account for pending tasks, the next test suite inherits a “dirty” environment. - The
Array.sortRed Herring: The error occurring atArray.sortis a classic symptom of a corrupted execution context. When a callback from a previous, “dead” test fires during the execution of a new test, it can interfere with the internal state of the JavaScript engine or the DOM, leading to stack exhaustion when the engine attempts to process interrupted or circular references in the event loop.
Why This Happens in Real Systems
In modern web development, non-deterministic failures (Flakiness) usually stem from the gap between a clean execution environment and the reality of shared global resources.
- Shared Singletons: In testing, the
window,document, andprocessobjects are singletons. If one test modifies them and fails to revert them perfectly, it poisons all subsequent tests. - Event Loop Latency: In CI environments (like Jenkins), CPU contention is higher than on a developer’s MacBook. This changes the timing of microtasks and macrotasks, making race conditions much more likely to manifest.
- Memory Leaks: Small leaks in event listeners or timers don’t crash a browser during a single session, but in a test runner that executes 1,000 tests in one process, they accumulate until the stack or heap is exhausted.
Real-World Impact
- Developer Friction: Engineers lose trust in the CI pipeline. When tests fail “randomly,” developers start ignoring failures, which is the precursor to missing real bugs.
- Deployment Delays: Flaky tests block the merge queue, requiring manual restarts of Jenkins jobs, wasting expensive compute time and human engineering hours.
- False Negatives: A “Maximum call stack size exceeded” error might be ignored as a “glitch,” masking a genuine recursion bug that could crash the production application under heavy load.
Example or Code
The following snippet demonstrates the dangerous pattern found in the controller, specifically how the setTimeout creates a closure that survives the test lifecycle.
_resizeFix: function() {
var self = this;
if (this._fixApplied) return;
// DANGER: This attaches to a global singleton (window)
$(window).off("resize.myElement").on("resize.myElement", function() {
clearTimeout(waitTimer);
// This timer lives in the macrotask queue
waitTimer = setTimeout(function() {
self.renderFunction();
// This second timer creates a massive window for leakage
setTimeout(function() {
isInProgress = false;
}, 1000);
}, 200);
});
this._fixApplied = true;
}
How Senior Engineers Fix It
A senior engineer doesn’t just “reduce the clock tick”; they address the architectural weakness in the test isolation.
- Strict Cleanup (The “Janitor” Pattern): Ensure that every single
on()has a correspondingoff()inafterEach, and specifically usejasmine.clock().uninstall()to prevent clock pollution. - Dependency Injection: Instead of relying on the global
window, pass aneventTargetto the controller. In tests, you can pass a mock event target that is destroyed after each test, ensuring no listeners can ever leak to the realwindow. - Idempotent Tests: Ensure that the
beforeEachsetup explicitly resets the state of all singletons used by the component, rather than assuming a clean slate. - Deterministic Timers: Instead of
jasmine.Clock.tick(1010), calculate the exact time required based on the business logic to avoid “overshooting” into the next test’s execution window.
Why Juniors Miss It
- Focus on the Symptom: Juniors often focus on the error message (
RangeError) and try to optimize the code causing it (the recursion), rather than looking at the test environment. - The “Works on My Machine” Fallacy: Because the error is rare locally, a junior might conclude it is a “flaky runner issue” or a “browser bug” rather than a logic flaw in their cleanup code.
- Lack of Understanding of the Event Loop: Juniors often treat
setTimeoutas a synchronous delay. They don’t realize that asetTimeoutcallback is a scheduled task that can execute at any point in the future, even after the function that created it has finished.