diff --git a/packages/devtools_app/lib/src/screens/logging/logging_controller.dart b/packages/devtools_app/lib/src/screens/logging/logging_controller.dart index fa93986930c..da6f1cedf8f 100644 --- a/packages/devtools_app/lib/src/screens/logging/logging_controller.dart +++ b/packages/devtools_app/lib/src/screens/logging/logging_controller.dart @@ -293,13 +293,13 @@ class LoggingController extends DevToolsScreenController void _handleConnectionStart(VmServiceWrapper service) { // Log stdout events. - final stdoutHandler = _StdoutEventHandler(this, 'stdout'); + final stdoutHandler = StdoutEventHandler(this, 'stdout'); autoDisposeStreamSubscription( service.onStdoutEventWithHistorySafe.listen(stdoutHandler.handle), ); // Log stderr events. - final stderrHandler = _StdoutEventHandler(this, 'stderr', isError: true); + final stderrHandler = StdoutEventHandler(this, 'stderr', isError: true); autoDisposeStreamSubscription( service.onStderrEventWithHistorySafe.listen(stderrHandler.handle), ); @@ -841,44 +841,21 @@ extension type _LogRecord(Map json) { /// stdout message and its newline. Currently, `foo\n` is sent as two VM events; /// we wait for up to 1ms when we get the `foo` event, to see if the next event /// is a single newline. If so, we add the newline to the previous log message. -class _StdoutEventHandler { - _StdoutEventHandler( - this.loggingController, - this.name, { - this.isError = false, - }); +@visibleForTesting +class StdoutEventHandler { + StdoutEventHandler(this.loggingController, this.name, {this.isError = false}); final LoggingController loggingController; final String name; final bool isError; - LogData? buffer; - Timer? timer; + LogData? _buffer; + Timer? _timer; void handle(Event e) { final message = decodeBase64(e.bytes!); - if (buffer != null) { - timer?.cancel(); - - if (message == '\n') { - loggingController.log( - LogData( - buffer!.kind, - buffer!.details! + message, - buffer!.timestamp, - summary: buffer!.summary! + message, - isError: buffer!.isError, - isolateRef: e.isolateRef, - ), - ); - buffer = null; - return; - } - - loggingController.log(buffer!); - buffer = null; - } + if (_handleBufferedMessage(message, e)) return; const maxLength = 200; @@ -899,13 +876,70 @@ class _StdoutEventHandler { if (message == '\n') { loggingController.log(data); } else { - buffer = data; - timer = Timer(const Duration(milliseconds: 1), () { - loggingController.log(buffer!); - buffer = null; - }); + _setBuffer(data); } } + + bool _handleBufferedMessage(String message, Event e) { + if (_buffer case final currentBuffer?) { + _timer?.cancel(); + + if (message == '\n') { + loggingController.log( + LogData( + currentBuffer.kind, + currentBuffer.details! + message, + currentBuffer.timestamp, + summary: currentBuffer.summary! + message, + isError: currentBuffer.isError, + isolateRef: e.isolateRef, + ), + ); + _buffer = null; + return true; + } + + // If the buffered message ends with a newline, the next message is a + // continuation of the same print statement (e.g. debugPrint('line1\nline2') + // is sent by the VM as two events: 'line1\n' and 'line2'). Combine them + // into a single log entry. + // See: https://github.com/flutter/devtools/issues/9557 + if (currentBuffer.details!.endsWith('\n')) { + _setBuffer( + LogData( + currentBuffer.kind, + currentBuffer.details! + message, + currentBuffer.timestamp, + summary: currentBuffer.summary, + isError: currentBuffer.isError, + isolateRef: e.isolateRef, + ), + ); + return true; + } + + loggingController.log(currentBuffer); + _buffer = null; + } + return false; + } + + void _setBuffer(LogData data) { + _buffer = data; + _timer?.cancel(); + _timer = Timer(const Duration(milliseconds: 1), () { + if (_buffer case final currentBuffer?) { + loggingController.log(currentBuffer); + _buffer = null; + } + }); + } + + @visibleForTesting + LogData? get buffer => _buffer; + + @visibleForTesting + Timer? get timer => _timer; } bool _isNotNull(InstanceRef? serviceRef) { diff --git a/packages/devtools_app/release_notes/NEXT_RELEASE_NOTES.md b/packages/devtools_app/release_notes/NEXT_RELEASE_NOTES.md index 6893cc7d4f4..ad3604848e8 100644 --- a/packages/devtools_app/release_notes/NEXT_RELEASE_NOTES.md +++ b/packages/devtools_app/release_notes/NEXT_RELEASE_NOTES.md @@ -45,6 +45,7 @@ TODO: Remove this section if there are not any updates. - Added support for searching within the log details view (raw text mode). [#9712](https://github.com/flutter/devtools/pull/9712) ![Search in log details](images/log_details_search.png "Searching within the log details view") +- Fixed an issue where log messages containing newline characters were incorrectly split into multiple separate entries in the Logging screen (#9557). ## App size tool updates diff --git a/packages/devtools_app/test/screens/logging/logging_controller_test.dart b/packages/devtools_app/test/screens/logging/logging_controller_test.dart index 4362b58423c..85b534e7741 100644 --- a/packages/devtools_app/test/screens/logging/logging_controller_test.dart +++ b/packages/devtools_app/test/screens/logging/logging_controller_test.dart @@ -381,4 +381,55 @@ void main() { }, ); }); + + group('StdoutEventHandler', () { + late LoggingController controller; + late StdoutEventHandler stdoutHandler; + + Event stdoutEvent(String message, {int? timestamp}) { + return Event( + bytes: base64Encode(utf8.encode(message)), + timestamp: timestamp ?? ++timestampCounter, + ); + } + + setUp(() { + setGlobal(ServiceConnectionManager, FakeServiceConnectionManager()); + setGlobal(MessageBus, MessageBus()); + setGlobal(PreferencesController, PreferencesController()); + + controller = LoggingController()..init(); + stdoutHandler = StdoutEventHandler(controller, 'stdout'); + }); + + test('combines newline-terminated continuation into one log', () async { + stdoutHandler.handle(stdoutEvent('line1\n')); + stdoutHandler.handle(stdoutEvent('line2')); + + await Future.delayed(const Duration(milliseconds: 10)); + + expect(controller.data, hasLength(1)); + expect(controller.data.single.kind, 'stdout'); + expect(controller.data.single.details, 'line1\nline2'); + }); + + test('still combines message followed by lone newline', () { + stdoutHandler.handle(stdoutEvent('line1')); + stdoutHandler.handle(stdoutEvent('\n')); + + expect(controller.data, hasLength(1)); + expect(controller.data.single.details, 'line1\n'); + }); + + test('keeps separate entries for distinct non-newline chunks', () async { + stdoutHandler.handle(stdoutEvent('line1')); + stdoutHandler.handle(stdoutEvent('line2')); + + await Future.delayed(const Duration(milliseconds: 10)); + + expect(controller.data, hasLength(2)); + expect(controller.data[0].details, 'line1'); + expect(controller.data[1].details, 'line2'); + }); + }); }