Skip to content

Commit f10e8df

Browse files
Enable the memory observer with full support for releasing memory (#8998)
1 parent 40b2a26 commit f10e8df

9 files changed

Lines changed: 540 additions & 93 deletions

File tree

packages/devtools_app/lib/src/framework/observer/memory_observer.dart

Lines changed: 310 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,15 @@ import '../../shared/primitives/byte_utils.dart';
1717
import '../../shared/utils/utils.dart';
1818
import '_memory_desktop.dart' if (dart.library.js_interop) '_memory_web.dart';
1919

20+
/// The result of a request to [MemoryObserver.reduceMemory].
21+
///
22+
/// `fromBytes` - total memory usage before the reduction request.
23+
/// `toBytes` - total memory usage after the reduction request is completed.
24+
/// `success` - whether the reduction in memory brought DevTools memory usage
25+
/// below the threshold [MemoryObserver._memoryPressureLimitGb].
26+
typedef ReduceMemoryResult =
27+
({bool success, int? fromBytes, int? toBytes, String? error});
28+
2029
/// Observes the memory usage of the DevTools app (web only) and shows a memory
2130
/// pressure warning to users when DevTools is nearing the memory limit.
2231
///
@@ -40,6 +49,12 @@ class MemoryObserver extends DisposableController {
4049

4150
DebounceTimer? _timer;
4251

52+
/// Tracks the most recent memory usage measurement.
53+
///
54+
/// This value is updated each time [MemoryObserver._memoryExceedsThreshold]
55+
/// is called.
56+
static int? _lastMemoryUsageInBytes;
57+
4358
@override
4459
void init() {
4560
super.init();
@@ -56,14 +71,9 @@ class MemoryObserver extends DisposableController {
5671
Future<void> _pollForMemoryUsage({
5772
DebounceCancelledCallback? cancelledCallback,
5873
}) async {
59-
final memoryUsageInBytes =
60-
_debugMeasureUsageInBytes != null
61-
? await _debugMeasureUsageInBytes()
62-
: await measureMemoryUsageInBytes();
63-
if (memoryUsageInBytes == null) return;
64-
65-
final memoryInGb = convertBytes(memoryUsageInBytes, to: ByteUnit.gb);
66-
if (memoryInGb > _memoryPressureLimitGb) {
74+
if (await _memoryExceedsThreshold(
75+
debugMeasureUsageInBytes: _debugMeasureUsageInBytes,
76+
)) {
6777
final gaScreen = DevToolsRouterDelegate.currentPage ?? gac.devToolsMain;
6878
ga.impression(gaScreen, gac.memoryPressure);
6979
bannerMessages.addMessage(
@@ -72,14 +82,76 @@ class MemoryObserver extends DisposableController {
7282
);
7383
}
7484
}
85+
86+
static Future<bool> _memoryExceedsThreshold({
87+
@visibleForTesting Future<int?> Function()? debugMeasureUsageInBytes,
88+
}) async {
89+
final memoryUsageInBytes =
90+
debugMeasureUsageInBytes != null
91+
? await debugMeasureUsageInBytes()
92+
: await measureMemoryUsageInBytes();
93+
_lastMemoryUsageInBytes = memoryUsageInBytes;
94+
if (memoryUsageInBytes == null) return false;
95+
96+
final memoryInGb = convertBytes(memoryUsageInBytes, to: ByteUnit.gb);
97+
return memoryInGb > _memoryPressureLimitGb;
98+
}
99+
100+
/// Attempts to reduce the memory footprint of DevTools by releasing memory
101+
/// from unused DevTools screens and, if necessary, releasing partial memory
102+
/// from the current screen in use.
103+
///
104+
/// Returns a [ReduceMemoryResult] containing metadata and a success result.
105+
static Future<ReduceMemoryResult> reduceMemory({
106+
@visibleForTesting Future<int?> Function()? debugMeasureUsageInBytes,
107+
}) async {
108+
final fromBytes = _lastMemoryUsageInBytes;
109+
await screenControllers.forEachInitializedAsync((screenController) async {
110+
if (DevToolsRouterDelegate.currentPage != screenController.screenId) {
111+
// If we need to release more memory, we can consider disposing the
112+
// screen controllers too. This would revert the controller back to it's
113+
// lazy initialized state, waiting to be re-initialized upon first use.
114+
await screenController.releaseMemory();
115+
}
116+
});
117+
118+
// TODO(kenz): clear other potential sources of memory bloat such as the
119+
// console history or caches like the resolved URI manager.
120+
121+
if (await _memoryExceedsThreshold(
122+
debugMeasureUsageInBytes: debugMeasureUsageInBytes,
123+
)) {
124+
await screenControllers.forEachInitializedAsync((screenController) async {
125+
if (DevToolsRouterDelegate.currentPage == screenController.screenId) {
126+
// If memory usage still exceeds the threshold, perform a partial
127+
// release of memory on the current screen. This is more disruptive
128+
// to the user, so only do this if releasing memory from every other
129+
// screen first did not work.
130+
await screenController.releaseMemory(partial: true);
131+
}
132+
});
133+
}
134+
135+
final success =
136+
!(await _memoryExceedsThreshold(
137+
debugMeasureUsageInBytes: debugMeasureUsageInBytes,
138+
));
139+
final toBytes = _lastMemoryUsageInBytes;
140+
return (
141+
success: success,
142+
fromBytes: fromBytes!,
143+
toBytes: toBytes!,
144+
error: null,
145+
);
146+
}
75147
}
76148

77149
class _MemoryPressureBannerMessage extends banner_messages.BannerWarning {
78150
_MemoryPressureBannerMessage()
79151
: super(
80152
screenId: banner_messages.universalScreenId,
81153
key: _messageKey,
82-
buildTextSpans: (_) {
154+
buildTextSpans: (context) {
83155
final limitAsBytes = convertBytes(
84156
MemoryObserver._memoryPressureLimitGb,
85157
from: ByteUnit.gb,
@@ -92,24 +164,242 @@ class _MemoryPressureBannerMessage extends banner_messages.BannerWarning {
92164
'${printBytes(limitAsBytes, unit: ByteUnit.gb, includeUnit: true)}. '
93165
'Consider releasing memory by clearing data you are no '
94166
'longer analyzing, or by clicking "Reduce memory" below, '
95-
'which will make a best-effort attempt to clear stale data. '
96-
'If you do not take action, DevTools may eventually crash '
97-
'due to an out of memory error (OOM).',
167+
'which will make a ',
168+
children: [
169+
TextSpan(
170+
text: 'best-effort attempt',
171+
style: Theme.of(context).boldTextStyle,
172+
),
173+
const TextSpan(
174+
text:
175+
' to clear stale data. If you do not take action, '
176+
'DevTools may eventually crash due to an out of memory '
177+
'error (OOM).\n\n'
178+
'WARNING: clicking "Reduce memory" will clear data from '
179+
'other DevTools screens and may partially clear data '
180+
'from the screen you are currently using. Consider '
181+
'saving data from other DevTools screens, where '
182+
'supported, if you do not want to lose data.',
183+
),
184+
],
98185
),
99186
];
100187
},
101188
buildActions:
102189
(_) => [
103-
DevToolsButton(
104-
label: 'Reduce memory',
105-
onPressed: () {
106-
ga.select(gac.devToolsMain, gac.memoryPressureReduce);
107-
// TODO(https://github.com/flutter/devtools/issues/7002): add
108-
// support to screen controllers to reduce memory.
109-
},
110-
),
190+
// Wrapping with an `Expanded` is okay because this list is set as
191+
// the `children` parameter of a `Row` widget in `BannerMessage`.
192+
const Expanded(child: _ReduceMemoryButton()),
111193
],
112194
);
113195

114196
static const _messageKey = Key('MemoryPressureBannerMessage');
115197
}
198+
199+
class _ReduceMemoryButton extends StatefulWidget {
200+
const _ReduceMemoryButton();
201+
202+
@override
203+
State<_ReduceMemoryButton> createState() => _ReduceMemoryButtonState();
204+
}
205+
206+
class _ReduceMemoryButtonState extends State<_ReduceMemoryButton> {
207+
bool inProgress = false;
208+
209+
final result = ValueNotifier<ReduceMemoryResult?>(null);
210+
211+
@override
212+
void dispose() {
213+
result.dispose();
214+
super.dispose();
215+
}
216+
217+
@override
218+
Widget build(BuildContext context) {
219+
final colorScheme = Theme.of(context).colorScheme;
220+
return Row(
221+
mainAxisSize: MainAxisSize.min,
222+
children: [
223+
DevToolsButton(
224+
label: 'Reduce memory',
225+
onPressed: _onPressed,
226+
color: colorScheme.onTertiaryContainer,
227+
),
228+
Flexible(
229+
child: Padding(
230+
padding: const EdgeInsets.symmetric(horizontal: denseSpacing),
231+
child:
232+
inProgress
233+
? SizedBox(
234+
height: actionsIconSize,
235+
width: actionsIconSize,
236+
child: const CircularProgressIndicator(),
237+
)
238+
: ValueListenableBuilder(
239+
valueListenable: result,
240+
builder: (context, result, _) {
241+
return _SuccessOrFailureMessage(result: result);
242+
},
243+
),
244+
),
245+
),
246+
],
247+
);
248+
}
249+
250+
Future<void> _onPressed() async {
251+
ga.select(gac.devToolsMain, gac.memoryPressureReduce);
252+
setState(() {
253+
inProgress = true;
254+
result.value = null;
255+
});
256+
ReduceMemoryResult? reduceMemoryResult;
257+
try {
258+
reduceMemoryResult = await MemoryObserver.reduceMemory();
259+
} catch (e) {
260+
reduceMemoryResult = (
261+
success: false,
262+
fromBytes: null,
263+
toBytes: null,
264+
error: e.toString(),
265+
);
266+
} finally {
267+
setState(() {
268+
inProgress = false;
269+
result.value = reduceMemoryResult;
270+
});
271+
}
272+
}
273+
}
274+
275+
class _SuccessOrFailureMessage extends StatefulWidget {
276+
const _SuccessOrFailureMessage({required this.result});
277+
278+
final ReduceMemoryResult? result;
279+
280+
@override
281+
State<_SuccessOrFailureMessage> createState() =>
282+
_SuccessOrFailureMessageState();
283+
}
284+
285+
class _SuccessOrFailureMessageState extends State<_SuccessOrFailureMessage> {
286+
static const _startingDismissCountDown = 5;
287+
288+
int dismissCountDown = _startingDismissCountDown;
289+
290+
Timer? _timer;
291+
292+
@override
293+
void initState() {
294+
super.initState();
295+
_init();
296+
}
297+
298+
@override
299+
void didUpdateWidget(covariant _SuccessOrFailureMessage oldWidget) {
300+
super.didUpdateWidget(oldWidget);
301+
if (oldWidget.result != widget.result) {
302+
_init();
303+
}
304+
}
305+
306+
void _init() {
307+
final result = widget.result;
308+
if (result != null && result.success) {
309+
dismissCountDown = _startingDismissCountDown;
310+
_timer?.cancel();
311+
_timer = Timer.periodic(const Duration(seconds: 1), (_) {
312+
if (dismissCountDown <= 1) {
313+
_timer?.cancel();
314+
_timer = null;
315+
bannerMessages.removeMessageByKey(
316+
_MemoryPressureBannerMessage._messageKey,
317+
banner_messages.universalScreenId,
318+
);
319+
}
320+
setState(() {
321+
dismissCountDown--;
322+
});
323+
});
324+
}
325+
}
326+
327+
@override
328+
void dispose() {
329+
_timer?.cancel();
330+
_timer = null;
331+
super.dispose();
332+
}
333+
334+
@override
335+
Widget build(BuildContext context) {
336+
final theme = Theme.of(context);
337+
final color = theme.colorScheme.onTertiaryContainer;
338+
339+
final result = widget.result;
340+
if (result == null) return const SizedBox.shrink();
341+
342+
String message;
343+
if (result.error != null) {
344+
message =
345+
'Attempt to reduce memory was unsuccessful. Error: ${result.error}';
346+
} else {
347+
assert(result.fromBytes != null && result.toBytes != null);
348+
final fromBytesAsString = printBytes(
349+
result.fromBytes!,
350+
fractionDigits: 2,
351+
unit: ByteUnit.gb,
352+
includeUnit: true,
353+
);
354+
final toBytesAsString = printBytes(
355+
result.toBytes!,
356+
fractionDigits: 2,
357+
unit: ByteUnit.gb,
358+
includeUnit: true,
359+
);
360+
361+
if (result.success) {
362+
message =
363+
'Successfully reduced memory from $fromBytesAsString to '
364+
'$toBytesAsString. This warning will automatically dismiss in '
365+
'$dismissCountDown seconds.';
366+
} else {
367+
final limitAsBytes = convertBytes(
368+
MemoryObserver._memoryPressureLimitGb,
369+
from: ByteUnit.gb,
370+
to: ByteUnit.byte,
371+
);
372+
final limitBytesAsString = printBytes(
373+
limitAsBytes,
374+
unit: ByteUnit.gb,
375+
includeUnit: true,
376+
);
377+
message =
378+
'Attempt to reduce memory was unsuccessful. Memory was reduced from '
379+
'$fromBytesAsString to $toBytesAsString, but the total memory still '
380+
'exceeds the $limitBytesAsString threshold.';
381+
}
382+
}
383+
384+
return RichText(
385+
text: TextSpan(
386+
children: [
387+
WidgetSpan(
388+
child: Padding(
389+
padding: const EdgeInsets.only(right: denseSpacing),
390+
child: Icon(
391+
result.success ? Icons.check : Icons.close,
392+
size: actionsIconSize,
393+
color: color,
394+
),
395+
),
396+
),
397+
TextSpan(
398+
text: message,
399+
style: theme.regularTextStyleWithColor(color),
400+
),
401+
],
402+
),
403+
);
404+
}
405+
}

packages/devtools_app/lib/src/screens/memory/panes/diff/controller/diff_pane_controller.dart

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -155,12 +155,14 @@ class DiffPaneController extends DisposableController with Serializable {
155155
const offsetForInstructionSnapshot = 1;
156156
final snapshots = core._snapshots;
157157
final snapshotsToRemove = max(
158-
offsetForInstructionSnapshot,
158+
0,
159159
(snapshots.value.length - offsetForInstructionSnapshot) ~/
160160
(partial ? 2 : 1),
161161
);
162-
final endIndexToRemoveExclusive =
163-
offsetForInstructionSnapshot + snapshotsToRemove;
162+
final endIndexToRemoveExclusive = min(
163+
snapshots.value.length,
164+
offsetForInstructionSnapshot + snapshotsToRemove,
165+
);
164166
for (
165167
var i = offsetForInstructionSnapshot;
166168
i < endIndexToRemoveExclusive;

0 commit comments

Comments
 (0)