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 = [];
|
List<LogEntry> _logs = [];
|
||||||
String _deviceInfo = '';
|
String _deviceInfo = '';
|
||||||
final ScrollController _scrollController = ScrollController();
|
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;
|
MediaServerHttpClient get _httpClient => widget.httpClient ?? httpClient;
|
||||||
|
|
||||||
@@ -109,6 +112,7 @@ class _LogsScreenState extends State<LogsScreen> with MountedSetStateMixin {
|
|||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_scrollController.dispose();
|
_scrollController.dispose();
|
||||||
|
_selectionFocusNode.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -178,8 +182,15 @@ class _LogsScreenState extends State<LogsScreen> with MountedSetStateMixin {
|
|||||||
: constrainLogUploadPayload(header: header, logs: logText, maxBytes: maxBytes);
|
: 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() {
|
void _copyAllLogs() {
|
||||||
Clipboard.setData(ClipboardData(text: _formatAllLogs()));
|
Clipboard.setData(ClipboardData(text: _formatAllLogs(maxBytes: _maxClipboardBytes)));
|
||||||
showSuccessSnackBar(context, t.messages.logsCopied);
|
showSuccessSnackBar(context, t.messages.logsCopied);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -264,57 +275,45 @@ class _LogsScreenState extends State<LogsScreen> with MountedSetStateMixin {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
List<TextSpan> _buildLogSpans() {
|
TextStyle? _logTextStyle(ThemeData theme) =>
|
||||||
final spans = <TextSpan>[];
|
theme.textTheme.bodySmall?.copyWith(fontFamily: 'monospace', fontSize: 12, height: 1.5);
|
||||||
if (_deviceInfo.isNotEmpty) {
|
|
||||||
spans.add(
|
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(
|
TextSpan(
|
||||||
text: '$_deviceInfo\n',
|
text: '\n Error: ${log.error}',
|
||||||
style: TextStyle(color: Colors.grey.withValues(alpha: 0.6)),
|
style: TextStyle(color: color),
|
||||||
),
|
),
|
||||||
);
|
if (log.stackTrace != null)
|
||||||
spans.add(
|
|
||||||
TextSpan(
|
TextSpan(
|
||||||
text: '---\n',
|
text: '\n ${log.stackTrace.toString().replaceAll('\n', '\n ')}',
|
||||||
style: TextStyle(color: Colors.grey.withValues(alpha: 0.3)),
|
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.
|
/// Banner for a startup failure recorded by an earlier launch.
|
||||||
@@ -387,62 +386,74 @@ class _LogsScreenState extends State<LogsScreen> with MountedSetStateMixin {
|
|||||||
child: IosStatusBarTapScrollToTop(
|
child: IosStatusBarTapScrollToTop(
|
||||||
controller: _scrollController,
|
controller: _scrollController,
|
||||||
child: Scaffold(
|
child: Scaffold(
|
||||||
body: CustomScrollView(
|
body: SelectionArea(
|
||||||
primary: true,
|
focusNode: _selectionFocusNode,
|
||||||
slivers: [
|
child: CustomScrollView(
|
||||||
CustomAppBar(
|
primary: true,
|
||||||
title: Text(t.screens.logs),
|
slivers: [
|
||||||
pinned: true,
|
CustomAppBar(
|
||||||
actions: [
|
// Chrome is not log content; keep it out of drag-selection.
|
||||||
FocusableActionBar(
|
title: SelectionContainer.disabled(child: Text(t.screens.logs)),
|
||||||
actions: [
|
pinned: true,
|
||||||
FocusableAction(icon: Symbols.refresh_rounded, tooltip: t.common.refresh, onPressed: _loadLogs),
|
actions: [
|
||||||
FocusableAction(
|
FocusableActionBar(
|
||||||
icon: Symbols.upload_rounded,
|
actions: [
|
||||||
tooltip: t.logs.uploadLogs,
|
FocusableAction(
|
||||||
onPressed: _hasDiagnostics ? _uploadLogs : null,
|
icon: Symbols.refresh_rounded,
|
||||||
),
|
tooltip: t.common.refresh,
|
||||||
FocusableAction(
|
onPressed: _loadLogs,
|
||||||
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,
|
|
||||||
),
|
),
|
||||||
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
|
||||||
// Only the log body needs it: the empty state already fills
|
// the viewport, so a trailing inset would just add slack.
|
||||||
// the viewport, so a trailing inset would just add slack.
|
const SliverSystemBottomInset(),
|
||||||
const SliverSystemBottomInset(),
|
],
|
||||||
],
|
],
|
||||||
],
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import 'package:flutter/services.dart';
|
|||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
import 'package:http/http.dart' as http;
|
import 'package:http/http.dart' as http;
|
||||||
import 'package:http/testing.dart';
|
import 'package:http/testing.dart';
|
||||||
|
import 'package:logger/logger.dart';
|
||||||
import 'package:package_info_plus/package_info_plus.dart';
|
import 'package:package_info_plus/package_info_plus.dart';
|
||||||
import 'package:plezy/focus/focusable_action_bar.dart';
|
import 'package:plezy/focus/focusable_action_bar.dart';
|
||||||
import 'package:plezy/focus/input_mode_tracker.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