fix(logs): render log entries lazily to stop OOM kills on the logs screen
Opening View Logs with a full 5 MiB buffer laid out the entire log as one paragraph, freezing the frame for tens of seconds and using ~1 GB of glyph data — the OS killed the app on low-memory phones and TVs (watchdog/OOM). Render one Text.rich per entry in a SliverList.builder so only the visible slice is laid out, keeping selection through a SelectionArea. Also cap the copy-to-clipboard payload at 256 KiB (newest lines kept) to stay under Android's ~1 MiB binder transaction limit.
This commit is contained in:
@@ -45,6 +45,9 @@ class _LogsScreenState extends State<LogsScreen> with MountedSetStateMixin {
|
||||
List<LogEntry> _logs = [];
|
||||
String _deviceInfo = '';
|
||||
final ScrollController _scrollController = ScrollController();
|
||||
// skipTraversal: selection is pointer-driven; the region must not become a
|
||||
// D-pad/Tab stop between the action bar and the scrollable body.
|
||||
final FocusNode _selectionFocusNode = FocusNode(skipTraversal: true, debugLabel: 'logs-selection');
|
||||
|
||||
MediaServerHttpClient get _httpClient => widget.httpClient ?? httpClient;
|
||||
|
||||
@@ -109,6 +112,7 @@ class _LogsScreenState extends State<LogsScreen> with MountedSetStateMixin {
|
||||
@override
|
||||
void dispose() {
|
||||
_scrollController.dispose();
|
||||
_selectionFocusNode.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@@ -178,8 +182,15 @@ class _LogsScreenState extends State<LogsScreen> with MountedSetStateMixin {
|
||||
: constrainLogUploadPayload(header: header, logs: logText, maxBytes: maxBytes);
|
||||
}
|
||||
|
||||
/// Android binder transactions are capped around 1 MiB and
|
||||
/// `Clipboard.setData` crosses one; an oversized payload aborts with
|
||||
/// TransactionTooLargeException. UTF-16 parceling can double the UTF-8
|
||||
/// size, so stay well below the limit. Trimming keeps the newest lines,
|
||||
/// matching the upload path.
|
||||
static const int _maxClipboardBytes = 256 * 1024;
|
||||
|
||||
void _copyAllLogs() {
|
||||
Clipboard.setData(ClipboardData(text: _formatAllLogs()));
|
||||
Clipboard.setData(ClipboardData(text: _formatAllLogs(maxBytes: _maxClipboardBytes)));
|
||||
showSuccessSnackBar(context, t.messages.logsCopied);
|
||||
}
|
||||
|
||||
@@ -264,57 +275,45 @@ class _LogsScreenState extends State<LogsScreen> with MountedSetStateMixin {
|
||||
);
|
||||
}
|
||||
|
||||
List<TextSpan> _buildLogSpans() {
|
||||
final spans = <TextSpan>[];
|
||||
if (_deviceInfo.isNotEmpty) {
|
||||
spans.add(
|
||||
TextStyle? _logTextStyle(ThemeData theme) =>
|
||||
theme.textTheme.bodySmall?.copyWith(fontFamily: 'monospace', fontSize: 12, height: 1.5);
|
||||
|
||||
List<TextSpan> _buildDeviceInfoSpans() {
|
||||
return [
|
||||
TextSpan(
|
||||
text: '$_deviceInfo\n',
|
||||
style: TextStyle(color: Colors.grey.withValues(alpha: 0.6)),
|
||||
),
|
||||
TextSpan(
|
||||
text: '---',
|
||||
style: TextStyle(color: Colors.grey.withValues(alpha: 0.3)),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
List<TextSpan> _buildEntrySpans(LogEntry log) {
|
||||
final color = _getLevelColor(log.level);
|
||||
return [
|
||||
TextSpan(
|
||||
text: '[${_formatTime(log.timestamp)}] ',
|
||||
style: TextStyle(color: color.withValues(alpha: 0.6)),
|
||||
),
|
||||
TextSpan(
|
||||
text: '[${log.level.name.toUpperCase()}] ',
|
||||
style: TextStyle(color: color, fontWeight: .bold),
|
||||
),
|
||||
TextSpan(text: log.message),
|
||||
if (log.error != null)
|
||||
TextSpan(
|
||||
text: '$_deviceInfo\n',
|
||||
style: TextStyle(color: Colors.grey.withValues(alpha: 0.6)),
|
||||
text: '\n Error: ${log.error}',
|
||||
style: TextStyle(color: color),
|
||||
),
|
||||
);
|
||||
spans.add(
|
||||
if (log.stackTrace != null)
|
||||
TextSpan(
|
||||
text: '---\n',
|
||||
style: TextStyle(color: Colors.grey.withValues(alpha: 0.3)),
|
||||
text: '\n ${log.stackTrace.toString().replaceAll('\n', '\n ')}',
|
||||
style: TextStyle(color: Colors.grey.withValues(alpha: 0.7)),
|
||||
),
|
||||
);
|
||||
}
|
||||
for (var i = 0; i < _logs.length; i++) {
|
||||
if (i > 0) spans.add(const TextSpan(text: '\n'));
|
||||
final log = _logs[i];
|
||||
final color = _getLevelColor(log.level);
|
||||
spans.add(
|
||||
TextSpan(
|
||||
text: '[${_formatTime(log.timestamp)}] ',
|
||||
style: TextStyle(color: color.withValues(alpha: 0.6)),
|
||||
),
|
||||
);
|
||||
spans.add(
|
||||
TextSpan(
|
||||
text: '[${log.level.name.toUpperCase()}] ',
|
||||
style: TextStyle(color: color, fontWeight: .bold),
|
||||
),
|
||||
);
|
||||
spans.add(TextSpan(text: log.message));
|
||||
if (log.error != null) {
|
||||
spans.add(
|
||||
TextSpan(
|
||||
text: '\n Error: ${log.error}',
|
||||
style: TextStyle(color: color),
|
||||
),
|
||||
);
|
||||
}
|
||||
if (log.stackTrace != null) {
|
||||
spans.add(
|
||||
TextSpan(
|
||||
text: '\n ${log.stackTrace.toString().replaceAll('\n', '\n ')}',
|
||||
style: TextStyle(color: Colors.grey.withValues(alpha: 0.7)),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
return spans;
|
||||
];
|
||||
}
|
||||
|
||||
/// Banner for a startup failure recorded by an earlier launch.
|
||||
@@ -387,62 +386,74 @@ class _LogsScreenState extends State<LogsScreen> with MountedSetStateMixin {
|
||||
child: IosStatusBarTapScrollToTop(
|
||||
controller: _scrollController,
|
||||
child: Scaffold(
|
||||
body: CustomScrollView(
|
||||
primary: true,
|
||||
slivers: [
|
||||
CustomAppBar(
|
||||
title: Text(t.screens.logs),
|
||||
pinned: true,
|
||||
actions: [
|
||||
FocusableActionBar(
|
||||
actions: [
|
||||
FocusableAction(icon: Symbols.refresh_rounded, tooltip: t.common.refresh, onPressed: _loadLogs),
|
||||
FocusableAction(
|
||||
icon: Symbols.upload_rounded,
|
||||
tooltip: t.logs.uploadLogs,
|
||||
onPressed: _hasDiagnostics ? _uploadLogs : null,
|
||||
),
|
||||
FocusableAction(
|
||||
icon: Symbols.content_copy_rounded,
|
||||
tooltip: t.logs.copyLogs,
|
||||
onPressed: _hasDiagnostics ? _copyAllLogs : null,
|
||||
),
|
||||
FocusableAction(
|
||||
icon: Symbols.delete_outline_rounded,
|
||||
tooltip: t.logs.clearLogs,
|
||||
onPressed: _hasDiagnostics ? _clearLogs : null,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
// A launch that failed the startup gate leaves nothing in the
|
||||
// in-memory buffer — that process is gone. Show its record
|
||||
// here, where the user can actually act on it (#1732).
|
||||
?_buildPreviousFailureBanner(theme),
|
||||
if (_logs.isEmpty)
|
||||
SliverFillRemaining(child: Center(child: Text(t.messages.noLogsAvailable)))
|
||||
else ...[
|
||||
SliverPadding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
sliver: SliverToBoxAdapter(
|
||||
child: SelectableText.rich(
|
||||
TextSpan(
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
fontFamily: 'monospace',
|
||||
fontSize: 12,
|
||||
height: 1.5,
|
||||
body: SelectionArea(
|
||||
focusNode: _selectionFocusNode,
|
||||
child: CustomScrollView(
|
||||
primary: true,
|
||||
slivers: [
|
||||
CustomAppBar(
|
||||
// Chrome is not log content; keep it out of drag-selection.
|
||||
title: SelectionContainer.disabled(child: Text(t.screens.logs)),
|
||||
pinned: true,
|
||||
actions: [
|
||||
FocusableActionBar(
|
||||
actions: [
|
||||
FocusableAction(
|
||||
icon: Symbols.refresh_rounded,
|
||||
tooltip: t.common.refresh,
|
||||
onPressed: _loadLogs,
|
||||
),
|
||||
children: _buildLogSpans(),
|
||||
FocusableAction(
|
||||
icon: Symbols.upload_rounded,
|
||||
tooltip: t.logs.uploadLogs,
|
||||
onPressed: _hasDiagnostics ? _uploadLogs : null,
|
||||
),
|
||||
FocusableAction(
|
||||
icon: Symbols.content_copy_rounded,
|
||||
tooltip: t.logs.copyLogs,
|
||||
onPressed: _hasDiagnostics ? _copyAllLogs : null,
|
||||
),
|
||||
FocusableAction(
|
||||
icon: Symbols.delete_outline_rounded,
|
||||
tooltip: t.logs.clearLogs,
|
||||
onPressed: _hasDiagnostics ? _clearLogs : null,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
// A launch that failed the startup gate leaves nothing in the
|
||||
// in-memory buffer — that process is gone. Show its record
|
||||
// here, where the user can actually act on it (#1732).
|
||||
?_buildPreviousFailureBanner(theme),
|
||||
if (_logs.isEmpty)
|
||||
SliverFillRemaining(child: Center(child: Text(t.messages.noLogsAvailable)))
|
||||
else ...[
|
||||
if (_deviceInfo.isNotEmpty)
|
||||
SliverPadding(
|
||||
padding: const EdgeInsets.fromLTRB(12, 12, 12, 0),
|
||||
sliver: SliverToBoxAdapter(
|
||||
child: Text.rich(TextSpan(style: _logTextStyle(theme), children: _buildDeviceInfoSpans())),
|
||||
),
|
||||
),
|
||||
SliverPadding(
|
||||
padding: EdgeInsets.fromLTRB(12, _deviceInfo.isEmpty ? 12 : 0, 12, 12),
|
||||
// One widget per entry so only the visible slice is laid
|
||||
// out. The buffer holds up to 5 MiB of text; as a single
|
||||
// paragraph that was a multi-second frame and hundreds of
|
||||
// MB of glyph data — an OOM kill on phones and TVs.
|
||||
sliver: SliverList.builder(
|
||||
itemCount: _logs.length,
|
||||
itemBuilder: (context, index) =>
|
||||
Text.rich(TextSpan(style: _logTextStyle(theme), children: _buildEntrySpans(_logs[index]))),
|
||||
),
|
||||
),
|
||||
),
|
||||
// Only the log body needs it: the empty state already fills
|
||||
// the viewport, so a trailing inset would just add slack.
|
||||
const SliverSystemBottomInset(),
|
||||
// Only the log body needs it: the empty state already fills
|
||||
// the viewport, so a trailing inset would just add slack.
|
||||
const SliverSystemBottomInset(),
|
||||
],
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -6,6 +6,7 @@ import 'package:flutter/services.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:http/testing.dart';
|
||||
import 'package:logger/logger.dart';
|
||||
import 'package:package_info_plus/package_info_plus.dart';
|
||||
import 'package:plezy/focus/focusable_action_bar.dart';
|
||||
import 'package:plezy/focus/input_mode_tracker.dart';
|
||||
@@ -226,4 +227,113 @@ void main() {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
group('log body rendering', () {
|
||||
late DeviceInfoPlugin deviceInfo;
|
||||
|
||||
setUp(() {
|
||||
PackageInfo.setMockInitialValues(
|
||||
appName: 'Plezy',
|
||||
packageName: 'com.plezy.test',
|
||||
version: '1.2.3',
|
||||
buildNumber: '45',
|
||||
buildSignature: '',
|
||||
);
|
||||
deviceInfo = DeviceInfoPlugin.setMockInitialValues(
|
||||
linuxDeviceInfo: LinuxDeviceInfo(
|
||||
name: 'Test Linux',
|
||||
id: 'test-linux',
|
||||
prettyName: 'Test Linux',
|
||||
machineId: 'test-machine',
|
||||
),
|
||||
);
|
||||
const deviceInfoChannel = MethodChannel('dev.fluttercommunity.plus/device_info');
|
||||
final messenger = TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger;
|
||||
messenger.setMockMethodCallHandler(deviceInfoChannel, (call) async {
|
||||
return <String, dynamic>{
|
||||
'computerName': 'test-mac',
|
||||
'hostName': 'test-mac.local',
|
||||
'arch': 'arm64',
|
||||
'model': 'Mac15,3',
|
||||
'modelName': 'Mac',
|
||||
'kernelVersion': 'test',
|
||||
'osRelease': '15.0',
|
||||
'majorVersion': 15,
|
||||
'minorVersion': 0,
|
||||
'patchVersion': 0,
|
||||
'activeCPUs': 8,
|
||||
'memorySize': 16 * 1024 * 1024 * 1024,
|
||||
'cpuFrequency': 0,
|
||||
'systemGUID': 'test-guid',
|
||||
};
|
||||
});
|
||||
addTearDown(() => messenger.setMockMethodCallHandler(deviceInfoChannel, null));
|
||||
});
|
||||
|
||||
// Stores entries without echoing thousands of lines to the test console.
|
||||
void seedLogs(Iterable<String> messages) {
|
||||
final printer = MemoryAwareLogPrinter(SimplePrinter());
|
||||
for (final message in messages) {
|
||||
printer.log(LogEvent(Level.debug, message));
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> pumpLogs(WidgetTester tester) async {
|
||||
await tester.pumpWidget(
|
||||
TranslationProvider(
|
||||
child: InputModeTracker(
|
||||
child: MaterialApp(home: LogsScreen(deviceInfoPlugin: deviceInfo)),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
testWidgets('lays out only the visible slice of a large buffer', (tester) async {
|
||||
// Rendering the whole buffer as one paragraph froze the frame for tens
|
||||
// of seconds and OOM-killed low-memory devices; offscreen entries must
|
||||
// never be materialized.
|
||||
seedLogs(['oldest-entry-marker', for (var i = 1; i < 1999; i++) 'entry-$i payload', 'newest-entry-marker']);
|
||||
|
||||
await pumpLogs(tester);
|
||||
|
||||
// Newest entry renders at the top; the oldest is offscreen and unbuilt.
|
||||
expect(find.textContaining('newest-entry-marker', findRichText: true), findsOneWidget);
|
||||
expect(find.textContaining('oldest-entry-marker', findRichText: true), findsNothing);
|
||||
|
||||
// The tail is still reachable by scrolling.
|
||||
final position = tester.state<ScrollableState>(find.byType(Scrollable).first).position;
|
||||
for (var i = 0; i < 10 && position.pixels < position.maxScrollExtent; i++) {
|
||||
position.jumpTo(position.maxScrollExtent);
|
||||
await tester.pump();
|
||||
}
|
||||
expect(find.textContaining('oldest-entry-marker', findRichText: true), findsOneWidget);
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
|
||||
testWidgets('caps the copied payload below the binder limit and keeps the newest lines', (tester) async {
|
||||
String? clipboardText;
|
||||
final messenger = TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger;
|
||||
messenger.setMockMethodCallHandler(SystemChannels.platform, (call) async {
|
||||
if (call.method == 'Clipboard.setData') {
|
||||
clipboardText = (call.arguments as Map<Object?, Object?>)['text'] as String?;
|
||||
}
|
||||
return null;
|
||||
});
|
||||
addTearDown(() => messenger.setMockMethodCallHandler(SystemChannels.platform, null));
|
||||
|
||||
final filler = 'x' * 1024;
|
||||
seedLogs(['oldest-copy-marker', for (var i = 1; i < 599; i++) 'copy-entry-$i $filler', 'newest-copy-marker']);
|
||||
|
||||
await pumpLogs(tester);
|
||||
await tester.tap(find.byTooltip(t.logs.copyLogs));
|
||||
await tester.pump();
|
||||
|
||||
expect(clipboardText, isNotNull);
|
||||
expect(utf8.encode(clipboardText!).length, lessThanOrEqualTo(256 * 1024));
|
||||
expect(clipboardText, contains('newest-copy-marker'));
|
||||
expect(clipboardText, isNot(contains('oldest-copy-marker')));
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user