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:
edde746
2026-09-02 11:31:47 +02:00
parent d2b5039bb3
commit 8fe7e4bcfd
2 changed files with 220 additions and 99 deletions
+110 -99
View File
@@ -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(),
],
],
],
),
),
),
),
+110
View File
@@ -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);
});
});
}