Skip to content

Commit 45898d0

Browse files
authored
[Scheduler] Prevent event log from growing unbounded (#16781)
If a Scheduler profile runs without stopping, the event log will grow unbounded. Eventually it will run out of memory and the VM will throw an error. To prevent this from happening, let's automatically stop the profiler once the log exceeds a certain limit. We'll also print a warning with advice to call `stopLoggingProfilingEvents` explicitly.
1 parent 87eaa90 commit 45898d0

File tree

4 files changed

+85
-26
lines changed

4 files changed

+85
-26
lines changed

packages/scheduler/src/SchedulerProfiling.js

Lines changed: 18 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,9 @@ if (enableProfiling) {
4444
profilingState[CURRENT_TASK_ID] = 0;
4545
}
4646

47-
const INITIAL_EVENT_LOG_SIZE = 1000;
47+
// Bytes per element is 4
48+
const INITIAL_EVENT_LOG_SIZE = 131072;
49+
const MAX_EVENT_LOG_SIZE = 524288; // Equivalent to 2 megabytes
4850

4951
let eventLogSize = 0;
5052
let eventLogBuffer = null;
@@ -65,10 +67,16 @@ function logEvent(entries) {
6567
const offset = eventLogIndex;
6668
eventLogIndex += entries.length;
6769
if (eventLogIndex + 1 > eventLogSize) {
68-
eventLogSize = eventLogIndex + 1;
69-
const newEventLog = new Int32Array(
70-
eventLogSize * Int32Array.BYTES_PER_ELEMENT,
71-
);
70+
eventLogSize *= 2;
71+
if (eventLogSize > MAX_EVENT_LOG_SIZE) {
72+
console.error(
73+
"Scheduler Profiling: Event log exceeded maxinum size. Don't " +
74+
'forget to call `stopLoggingProfilingEvents()`.',
75+
);
76+
stopLoggingProfilingEvents();
77+
return;
78+
}
79+
const newEventLog = new Int32Array(eventLogSize * 4);
7280
newEventLog.set(eventLog);
7381
eventLogBuffer = newEventLog.buffer;
7482
eventLog = newEventLog;
@@ -79,14 +87,17 @@ function logEvent(entries) {
7987

8088
export function startLoggingProfilingEvents(): void {
8189
eventLogSize = INITIAL_EVENT_LOG_SIZE;
82-
eventLogBuffer = new ArrayBuffer(eventLogSize * Int32Array.BYTES_PER_ELEMENT);
90+
eventLogBuffer = new ArrayBuffer(eventLogSize * 4);
8391
eventLog = new Int32Array(eventLogBuffer);
8492
eventLogIndex = 0;
8593
}
8694

8795
export function stopLoggingProfilingEvents(): ArrayBuffer | null {
8896
const buffer = eventLogBuffer;
89-
eventLogBuffer = eventLog = null;
97+
eventLogSize = 0;
98+
eventLogBuffer = null;
99+
eventLog = null;
100+
eventLogIndex = 0;
90101
return buffer;
91102
}
92103

packages/scheduler/src/__tests__/SchedulerProfiling-test.js

Lines changed: 46 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -99,9 +99,12 @@ describe('Scheduler', () => {
9999
const SchedulerResumeEvent = 8;
100100

101101
function stopProfilingAndPrintFlamegraph() {
102-
const eventLog = new Int32Array(
103-
Scheduler.unstable_Profiling.stopLoggingProfilingEvents(),
104-
);
102+
const eventBuffer = Scheduler.unstable_Profiling.stopLoggingProfilingEvents();
103+
if (eventBuffer === null) {
104+
return '(empty profile)';
105+
}
106+
107+
const eventLog = new Int32Array(eventBuffer);
105108

106109
const tasks = new Map();
107110
const mainThreadRuns = [];
@@ -496,13 +499,46 @@ Task 2 [Normal] │ ░░░░░░░░🡐 canceled
496499
);
497500
});
498501

499-
it('resizes event log buffer if there are many events', () => {
500-
const tasks = [];
501-
for (let i = 0; i < 5000; i++) {
502-
tasks.push(scheduleCallback(NormalPriority, () => {}));
502+
it('automatically stops profiling and warns if event log gets too big', async () => {
503+
Scheduler.unstable_Profiling.startLoggingProfilingEvents();
504+
505+
spyOnDevAndProd(console, 'error');
506+
507+
// Increase infinite loop guard limit
508+
const originalMaxIterations = global.__MAX_ITERATIONS__;
509+
global.__MAX_ITERATIONS__ = 120000;
510+
511+
let taskId = 1;
512+
while (console.error.calls.count() === 0) {
513+
taskId++;
514+
const task = scheduleCallback(NormalPriority, () => {});
515+
cancelCallback(task);
516+
expect(Scheduler).toFlushAndYield([]);
503517
}
504-
expect(getProfilingInfo()).toEqual('Suspended, Queue Size: 5000');
505-
tasks.forEach(task => cancelCallback(task));
506-
expect(getProfilingInfo()).toEqual('Empty Queue');
518+
519+
expect(console.error).toHaveBeenCalledTimes(1);
520+
expect(console.error.calls.argsFor(0)[0]).toBe(
521+
"Scheduler Profiling: Event log exceeded maxinum size. Don't forget " +
522+
'to call `stopLoggingProfilingEvents()`.',
523+
);
524+
525+
// Should automatically clear profile
526+
expect(stopProfilingAndPrintFlamegraph()).toEqual('(empty profile)');
527+
528+
// Test that we can start a new profile later
529+
Scheduler.unstable_Profiling.startLoggingProfilingEvents();
530+
scheduleCallback(NormalPriority, () => {
531+
Scheduler.unstable_advanceTime(1000);
532+
});
533+
expect(Scheduler).toFlushAndYield([]);
534+
535+
// Note: The exact task id is not super important. That just how many tasks
536+
// it happens to take before the array is resized.
537+
expect(stopProfilingAndPrintFlamegraph()).toEqual(`
538+
!!! Main thread │░░░░░░░░░░░░░░░░░░░░
539+
Task ${taskId} [Normal] │████████████████████
540+
`);
541+
542+
global.__MAX_ITERATIONS__ = originalMaxIterations;
507543
});
508544
});

scripts/babel/transform-prevent-infinite-loops.js

Lines changed: 16 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -22,10 +22,10 @@ module.exports = ({types: t, template}) => {
2222
// We set a global so that we can later fail the test
2323
// even if the error ends up being caught by the code.
2424
const buildGuard = template(`
25-
if (ITERATOR++ > MAX_ITERATIONS) {
25+
if (%%iterator%%++ > %%maxIterations%%) {
2626
global.infiniteLoopError = new RangeError(
2727
'Potential infinite loop: exceeded ' +
28-
MAX_ITERATIONS +
28+
%%maxIterations%% +
2929
' iterations.'
3030
);
3131
throw global.infiniteLoopError;
@@ -36,10 +36,18 @@ module.exports = ({types: t, template}) => {
3636
visitor: {
3737
'WhileStatement|ForStatement|DoWhileStatement': (path, file) => {
3838
const filename = file.file.opts.filename;
39-
const MAX_ITERATIONS =
40-
filename.indexOf('__tests__') === -1
41-
? MAX_SOURCE_ITERATIONS
42-
: MAX_TEST_ITERATIONS;
39+
const maxIterations = t.logicalExpression(
40+
'||',
41+
t.memberExpression(
42+
t.identifier('global'),
43+
t.identifier('__MAX_ITERATIONS__')
44+
),
45+
t.numericLiteral(
46+
filename.indexOf('__tests__') === -1
47+
? MAX_SOURCE_ITERATIONS
48+
: MAX_TEST_ITERATIONS
49+
)
50+
);
4351

4452
// An iterator that is incremented with each iteration
4553
const iterator = path.scope.parent.generateUidIdentifier('loopIt');
@@ -50,8 +58,8 @@ module.exports = ({types: t, template}) => {
5058
});
5159
// If statement and throw error if it matches our criteria
5260
const guard = buildGuard({
53-
ITERATOR: iterator,
54-
MAX_ITERATIONS: t.numericLiteral(MAX_ITERATIONS),
61+
iterator,
62+
maxIterations,
5563
});
5664
// No block statement e.g. `while (1) 1;`
5765
if (!path.get('body').isBlockStatement()) {

scripts/jest/preprocessor.js

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,9 @@ const pathToBabelPluginWrapWarning = require.resolve(
2222
const pathToBabelPluginAsyncToGenerator = require.resolve(
2323
'@babel/plugin-transform-async-to-generator'
2424
);
25+
const pathToTransformInfiniteLoops = require.resolve(
26+
'../babel/transform-prevent-infinite-loops'
27+
);
2528
const pathToBabelrc = path.join(__dirname, '..', '..', 'babel.config.js');
2629
const pathToErrorCodes = require.resolve('../error-codes/codes.json');
2730

@@ -39,7 +42,7 @@ const babelOptions = {
3942
// TODO: I have not verified that this actually works.
4043
require.resolve('@babel/plugin-transform-react-jsx-source'),
4144

42-
require.resolve('../babel/transform-prevent-infinite-loops'),
45+
pathToTransformInfiniteLoops,
4346

4447
// This optimization is important for extremely performance-sensitive (e.g. React source).
4548
// It's okay to disable it for tests.
@@ -87,6 +90,7 @@ module.exports = {
8790
pathToBabelrc,
8891
pathToBabelPluginDevWithCode,
8992
pathToBabelPluginWrapWarning,
93+
pathToTransformInfiniteLoops,
9094
pathToErrorCodes,
9195
]),
9296
};

0 commit comments

Comments
 (0)