From b5261fcb3595a91cee50a137fd43ea1a27a98a17 Mon Sep 17 00:00:00 2001 From: sneurlax Date: Thu, 20 Aug 2026 19:50:46 -0500 Subject: [PATCH 1/3] fix(logger): handle dispatch failures safely Route every log through LoggerDispatcher, whose failure path reports to a fallback instead of re-entering log() and recursing until the stack overflows. Spawn the worker from a top-level entry taking a record so the Logging instance is never copied into the isolate, and drop the port name once the worker reports an error or exits. --- lib/utilities/logger.dart | 281 ++++++++++++++------- lib/utilities/logger_dispatcher.dart | 143 +++++++++++ test/utilities/logger_dispatcher_test.dart | 160 ++++++++++++ 3 files changed, 487 insertions(+), 97 deletions(-) create mode 100644 lib/utilities/logger_dispatcher.dart create mode 100644 test/utilities/logger_dispatcher_test.dart diff --git a/lib/utilities/logger.dart b/lib/utilities/logger.dart index b0b37cd55e..fcb4078625 100644 --- a/lib/utilities/logger.dart +++ b/lib/utilities/logger.dart @@ -8,34 +8,95 @@ * */ -import 'dart:convert'; -import 'dart:core' as core; -import 'dart:core'; import 'dart:isolate'; -import 'dart:ui'; import 'package:logger/logger.dart'; +import 'logger_dispatcher.dart'; import 'util.dart'; export 'enums/log_level_enum.dart'; const _kLoggerPortName = "logger_port"; +typedef _LoggerIsolateConfig = ({ + SendPort readyPort, + String logsPath, + Level level, + Level? debugConsoleLevel, +}); + +void _runLoggerIsolate(_LoggerIsolateConfig config) { + final receivePort = ReceivePort(); + config.readyPort.send(receivePort.sendPort); + + PrettyPrinter prettyPrinter(bool toFile) => PrettyPrinter( + printEmojis: false, + methodCount: 0, + dateTimeFormat: toFile ? DateTimeFormat.none : DateTimeFormat.dateAndTime, + colors: !toFile, + noBoxingByDefault: toFile, + ); + + final consoleLogger = Logger( + printer: PrefixPrinter(prettyPrinter(false)), + filter: ProductionFilter(), + level: config.debugConsoleLevel ?? config.level, + ); + + final fileLogger = Logger( + printer: PrefixPrinter(prettyPrinter(true)), + filter: ProductionFilter(), + level: config.level, + output: AdvancedFileOutput( + path: config.logsPath, + overrideExisting: false, + latestFileName: "latest.txt", + writeImmediately: [Level.error, Level.fatal, Level.warning], + ), + ); + + receivePort.listen((message) { + final loggerMessage = message as LoggerIsolateMessage; + final event = loggerMessage.$1; + consoleLogger.log( + event.level, + event.message, + stackTrace: event.stackTrace, + error: event.error, + time: event.time.toUtc(), + ); + if (loggerMessage.$2) { + fileLogger.log( + event.level, + "${event.time.toUtc().toIso8601String()} ${event.message}", + stackTrace: event.stackTrace, + error: event.error, + time: event.time, + ); + } + }); +} + class Logging { Logging._(); static final Logging _instance = Logging._(); static Logging get instance => _instance; - late final String logsDirPath; + late String logsDirPath; - SendPort get _sendPort { - final port = IsolateNameServer.lookupPortByName(_kLoggerPortName); - if (port == null) { - throw Exception("Did you forget to call Logging.initialize()?"); - } - return port; - } + late final LoggerPortRegistry _portRegistry = LoggerPortRegistry.named( + _kLoggerPortName, + ); + late final LoggerDispatcher _dispatcher = LoggerDispatcher( + lookupSender: () => + Util.isTestEnv ? _printTestMessage : _portRegistry.lookup()?.send, + ); + + Isolate? _loggerIsolate; + SendPort? _loggerPort; + ReceivePort? _loggerErrorPort; + ReceivePort? _loggerExitPort; Future initialize( String logsPath, { @@ -47,74 +108,63 @@ class Logging { "Logging.initialize() must be called on the main isolate.", ); } - if (IsolateNameServer.lookupPortByName(_kLoggerPortName) != null) { + if (_loggerIsolate != null || _portRegistry.lookup() != null) { throw Exception("Logging was already initialized"); } - logsDirPath = logsPath; - - final receivePort = ReceivePort(); - await Isolate.spawn((sendPort) { - final ReceivePort receivePort = ReceivePort(); - sendPort.send(receivePort.sendPort); - - PrettyPrinter prettyPrinter(bool toFile) => PrettyPrinter( - printEmojis: false, - methodCount: 0, - dateTimeFormat: toFile - ? DateTimeFormat.none - : DateTimeFormat.dateAndTime, - colors: !toFile, - noBoxingByDefault: toFile, - ); - - final consoleLogger = Logger( - printer: PrefixPrinter(prettyPrinter(false)), - filter: ProductionFilter(), - level: debugConsoleLevel ?? level, - ); + final readyPort = ReceivePort(); + final errorPort = ReceivePort(); + final exitPort = ReceivePort(); + var workerExited = false; + _loggerErrorPort = errorPort..listen(_handleLoggerError); + _loggerExitPort = exitPort + ..listen((_) { + workerExited = true; + _handleLoggerExit(); + }); - final fileLogger = Logger( - printer: PrefixPrinter(prettyPrinter(true)), - filter: ProductionFilter(), - level: level, - output: AdvancedFileOutput( - path: logsDirPath, - overrideExisting: false, - latestFileName: "latest.txt", - writeImmediately: [Level.error, Level.fatal, Level.warning], + Isolate? loggerIsolate; + try { + loggerIsolate = await Isolate.spawn( + _runLoggerIsolate, + ( + readyPort: readyPort.sendPort, + logsPath: logsPath, + level: level, + debugConsoleLevel: debugConsoleLevel, ), + onError: errorPort.sendPort, + onExit: exitPort.sendPort, + errorsAreFatal: true, + debugName: "logger", ); + _loggerIsolate = loggerIsolate; - receivePort.listen((message) { - final event = (message as (LogEvent, bool)).$1; - consoleLogger.log( - event.level, - event.message, - stackTrace: event.stackTrace, - error: event.error, - time: event.time.toUtc(), - ); - if (message.$2) { - fileLogger.log( - event.level, - "${event.time.toUtc().toIso8601String()} ${event.message}", - stackTrace: event.stackTrace, - error: event.error, - time: event.time, - ); - } - }); - }, receivePort.sendPort); - final loggerPort = await receivePort.first as SendPort; - IsolateNameServer.registerPortWithName(loggerPort, _kLoggerPortName); + final result = await readyPort.first.timeout(const Duration(seconds: 30)); + if (result is! SendPort || workerExited) { + throw StateError("Logger isolate failed to start"); + } + _loggerPort = result; + if (!_portRegistry.register(result)) { + _loggerPort = null; + throw StateError("Logger port registration failed"); + } + logsDirPath = logsPath; + } catch (_) { + loggerIsolate?.kill(priority: Isolate.immediate); + _loggerIsolate = null; + final loggerPort = _loggerPort; + if (loggerPort != null) { + _portRegistry.removeIfCurrent(loggerPort); + } + _loggerPort = null; + _stopMonitoring(); + rethrow; + } finally { + readyPort.close(); + } } - String _stringifyMessage(dynamic message) => - !(message is Map || message is Iterable) - ? message.toString() - : JsonEncoder.withIndent(' ', (o) => o.toString()).convert(message); - void log( Level level, dynamic message, { @@ -123,36 +173,73 @@ class Logging { StackTrace? stackTrace, bool toFile = true, // false will print to console only }) { - if (Util.isTestEnv) { - // Persistent isolates may not work correctly during tests - // just print to console instead - - // ignore: avoid_print - print( - "${level.name} [$time] ${_stringifyMessage(message)}" - ", ERROR: $error" - ", STRACE: $stackTrace", - ); - return; - } - if (Util.isTestEnv || Util.isArmLinux) { toFile = false; } - try { - _sendPort.send(( - LogEvent( - level, - _stringifyMessage(message), - time: time, - error: error, - stackTrace: stackTrace, - ), - toFile, - )); - } catch (e, s) { - t("Isolates suck", error: e, stackTrace: s); + _dispatcher.log( + level, + message, + time: time, + error: error, + stackTrace: stackTrace, + toFile: toFile, + ); + } + + void _printTestMessage(Object? message) { + // Persistent isolates are unreliable under flutter test. + final event = (message! as LoggerIsolateMessage).$1; + // ignore: avoid_print + print( + "${event.level.name} [${event.time}] ${event.message}" + ", ERROR: ${event.error}" + ", STRACE: ${event.stackTrace}", + ); + } + + void _handleLoggerError(Object? message) { + final error = message is List && message.isNotEmpty + ? message.first ?? "Unknown logger isolate error" + : message ?? "Unknown logger isolate error"; + final stackTrace = message is List && message.length > 1 + ? StackTrace.fromString(message[1]?.toString() ?? "") + : StackTrace.current; + developerLoggerFallback( + LogEvent( + Level.error, + "Logger isolate failed", + error: error, + stackTrace: stackTrace, + ), + StateError("Logger isolate reported an error"), + stackTrace, + ); + } + + void _handleLoggerExit() { + final hadActiveWorker = _loggerIsolate != null; + final loggerPort = _loggerPort; + if (loggerPort != null) { + _portRegistry.removeIfCurrent(loggerPort); } + _loggerPort = null; + _loggerIsolate = null; + _stopMonitoring(); + + if (hadActiveWorker) { + developerLoggerFallback( + LogEvent(Level.error, "Logger isolate exited unexpectedly"), + StateError("Logger isolate exited"), + StackTrace.current, + ); + } + } + + void _stopMonitoring() { + _loggerErrorPort?.close(); + _loggerExitPort?.close(); + _loggerErrorPort = null; + _loggerExitPort = null; } void t( diff --git a/lib/utilities/logger_dispatcher.dart b/lib/utilities/logger_dispatcher.dart new file mode 100644 index 0000000000..5ca6229397 --- /dev/null +++ b/lib/utilities/logger_dispatcher.dart @@ -0,0 +1,143 @@ +/* + * This file is part of Stack Wallet. + * + * Copyright (c) 2023 Cypher Stack + * All Rights Reserved. + * The code is distributed under GPLv3 license, see LICENSE file for details. + * Generated by Cypher Stack on 2023-05-26 + * + */ + +import 'dart:convert'; +import 'dart:developer' as developer; +import 'dart:isolate'; +import 'dart:ui'; + +import 'package:logger/logger.dart'; + +typedef LoggerIsolateMessage = (LogEvent, bool); +typedef LoggerMessageSender = void Function(Object?); +typedef LoggerMessageSenderLookup = LoggerMessageSender? Function(); +typedef LoggerFallback = + void Function(LogEvent event, Object error, StackTrace stackTrace); + +final class LoggerDispatcher { + LoggerDispatcher({ + required LoggerMessageSenderLookup lookupSender, + LoggerFallback fallback = developerLoggerFallback, + }) : _lookupSender = lookupSender, + _fallback = fallback; + + final LoggerMessageSenderLookup _lookupSender; + final LoggerFallback _fallback; + + bool log( + Level level, + dynamic message, { + DateTime? time, + Object? error, + StackTrace? stackTrace, + bool toFile = true, + }) { + final event = LogEvent( + level, + _stringifyMessage(message), + time: time, + error: error, + stackTrace: stackTrace, + ); + + try { + final sender = _lookupSender(); + if (sender == null) { + throw StateError("Logger isolate is unavailable"); + } + sender((event, toFile)); + return true; + } catch (dispatchError, dispatchStackTrace) { + try { + _fallback(event, dispatchError, dispatchStackTrace); + } catch (_) { + // Logging must never break its caller. + } + return false; + } + } +} + +final class LoggerPortRegistry { + LoggerPortRegistry({ + required SendPort? Function() lookupPort, + required bool Function(SendPort) registerPort, + required bool Function() removePort, + }) : _lookupPort = lookupPort, + _registerPort = registerPort, + _removePort = removePort; + + factory LoggerPortRegistry.named(String name) => LoggerPortRegistry( + lookupPort: () => IsolateNameServer.lookupPortByName(name), + registerPort: (port) => IsolateNameServer.registerPortWithName(port, name), + removePort: () => IsolateNameServer.removePortNameMapping(name), + ); + + final SendPort? Function() _lookupPort; + final bool Function(SendPort) _registerPort; + final bool Function() _removePort; + + SendPort? lookup() => _lookupPort(); + + bool register(SendPort port) => _registerPort(port); + + bool removeIfCurrent(SendPort port) { + if (_lookupPort() != port) { + return false; + } + return _removePort(); + } +} + +void developerLoggerFallback( + LogEvent event, + Object dispatchError, + StackTrace dispatchStackTrace, +) { + try { + final originalError = event.error == null + ? "" + : "\nOriginal error: ${_safeToString(event.error)}"; + developer.log( + _safeToString(event.message), + name: "StackWallet.Logging", + level: event.level.value ~/ 5, + time: event.time, + error: + "Logger dispatch failed: ${_safeToString(dispatchError)}" + "$originalError", + stackTrace: event.stackTrace ?? dispatchStackTrace, + ); + } catch (_) { + // The emergency path must remain independent of application logging. + } +} + +String _stringifyMessage(dynamic message) { + try { + return !(message is Map || message is Iterable) + ? message.toString() + : JsonEncoder.withIndent( + ' ', + (object) => object.toString(), + ).convert(message); + } catch (error) { + return ""; + } +} + +String _safeToString(Object? value) { + try { + return value.toString(); + } catch (_) { + return ""; + } +} diff --git a/test/utilities/logger_dispatcher_test.dart b/test/utilities/logger_dispatcher_test.dart new file mode 100644 index 0000000000..178478e2b6 --- /dev/null +++ b/test/utilities/logger_dispatcher_test.dart @@ -0,0 +1,160 @@ +import 'dart:isolate'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:logger/logger.dart'; +import 'package:stackwallet/utilities/logger_dispatcher.dart'; + +void main() { + group("LoggerDispatcher", () { + test("sends the formatted event to the logger isolate", () async { + final receivePort = ReceivePort(); + addTearDown(receivePort.close); + final timestamp = DateTime.utc(2026, 8, 20); + final dispatcher = LoggerDispatcher( + lookupSender: () => receivePort.sendPort.send, + fallback: (_, _, _) => fail("fallback should not run"), + ); + + final didSend = dispatcher.log( + Level.info, + {"status": "ready"}, + time: timestamp, + toFile: false, + ); + + expect(didSend, isTrue); + final message = await receivePort.first as LoggerIsolateMessage; + expect(message.$1.level, Level.info); + expect(message.$1.message, '{\n "status": "ready"\n}'); + expect(message.$1.time, timestamp); + expect(message.$2, isFalse); + }); + + test("uses the fallback when the logger isolate is unavailable", () { + final fallbackCalls = <(LogEvent, Object, StackTrace)>[]; + final dispatcher = LoggerDispatcher( + lookupSender: () => null, + fallback: (event, error, stackTrace) { + fallbackCalls.add((event, error, stackTrace)); + }, + ); + + final didSend = dispatcher.log(Level.warning, "not ready"); + + expect(didSend, isFalse); + expect(fallbackCalls, hasLength(1)); + expect(fallbackCalls.single.$1.message, "not ready"); + expect(fallbackCalls.single.$2, isA()); + }); + + test("uses the fallback when sending fails", () { + final dispatchError = StateError("send failed"); + final fallbackCalls = <(LogEvent, Object, StackTrace)>[]; + final dispatcher = LoggerDispatcher( + lookupSender: () => + (_) => throw dispatchError, + fallback: (event, error, stackTrace) { + fallbackCalls.add((event, error, stackTrace)); + }, + ); + + final didSend = dispatcher.log(Level.error, "important"); + + expect(didSend, isFalse); + expect(fallbackCalls, hasLength(1)); + expect(fallbackCalls.single.$1.message, "important"); + expect(fallbackCalls.single.$2, same(dispatchError)); + }); + + test("does not throw when the fallback itself fails", () { + final dispatcher = LoggerDispatcher( + lookupSender: () => null, + fallback: (_, _, _) => throw StateError("fallback failed"), + ); + + expect(() => dispatcher.log(Level.error, "important"), returnsNormally); + }); + + test("replaces a message that cannot be stringified", () { + final sentMessages = []; + final dispatcher = LoggerDispatcher( + lookupSender: () => sentMessages.add, + fallback: (_, _, _) => fail("fallback should not run"), + ); + + final didSend = dispatcher.log(Level.info, _UnprintableMessage()); + + expect(didSend, isTrue); + final message = sentMessages.single! as LoggerIsolateMessage; + expect(message.$1.message, contains("_UnprintableMessage")); + expect(message.$1.message, contains("could not stringify")); + }); + + test("the developer fallback tolerates unprintable details", () { + final event = LogEvent( + Level.error, + _UnprintableMessage(), + error: _UnprintableMessage(), + ); + + expect( + () => developerLoggerFallback( + event, + _UnprintableMessage(), + StackTrace.current, + ), + returnsNormally, + ); + }); + }); + + group("LoggerPortRegistry", () { + test("removes the logger port when its worker exits", () { + final receivePort = ReceivePort(); + addTearDown(receivePort.close); + SendPort? registeredPort = receivePort.sendPort; + var removeCalls = 0; + final registry = LoggerPortRegistry( + lookupPort: () => registeredPort, + registerPort: (_) => true, + removePort: () { + removeCalls++; + registeredPort = null; + return true; + }, + ); + + final didRemove = registry.removeIfCurrent(receivePort.sendPort); + + expect(didRemove, isTrue); + expect(removeCalls, 1); + expect(registeredPort, isNull); + }); + + test("does not remove a replacement logger port", () { + final workerPort = ReceivePort(); + final replacementPort = ReceivePort(); + addTearDown(workerPort.close); + addTearDown(replacementPort.close); + var removeCalls = 0; + final registry = LoggerPortRegistry( + lookupPort: () => replacementPort.sendPort, + registerPort: (_) => true, + removePort: () { + removeCalls++; + return true; + }, + ); + + final didRemove = registry.removeIfCurrent(workerPort.sendPort); + + expect(didRemove, isFalse); + expect(removeCalls, 0); + }); + }); +} + +class _UnprintableMessage { + @override + String toString() => throw StateError("broken toString"); +} From 9fd45e68cbbbf9a39db2570b6b8bcbbfe31823f9 Mon Sep 17 00:00:00 2001 From: sneurlax Date: Thu, 20 Aug 2026 23:04:42 -0500 Subject: [PATCH 2/3] fix(logger): persist dispatch failures Write failed dispatches to emergency.txt beside latest.txt so a log lost to an unavailable worker still leaves a record. The emergency path builds its file paths with package:path, so it stays correct on every platform. --- lib/utilities/logger.dart | 38 ++++++- lib/utilities/logger_dispatcher.dart | 75 +++++++++++-- test/utilities/logger_dispatcher_test.dart | 117 ++++++++++++++++++--- 3 files changed, 204 insertions(+), 26 deletions(-) diff --git a/lib/utilities/logger.dart b/lib/utilities/logger.dart index fcb4078625..2c373ed350 100644 --- a/lib/utilities/logger.dart +++ b/lib/utilities/logger.dart @@ -83,7 +83,8 @@ class Logging { static final Logging _instance = Logging._(); static Logging get instance => _instance; - late String logsDirPath; + String? _logsDirPath; + String get logsDirPath => _logsDirPath!; late final LoggerPortRegistry _portRegistry = LoggerPortRegistry.named( _kLoggerPortName, @@ -91,6 +92,8 @@ class Logging { late final LoggerDispatcher _dispatcher = LoggerDispatcher( lookupSender: () => Util.isTestEnv ? _printTestMessage : _portRegistry.lookup()?.send, + fallback: (event, toFile, error, stackTrace) => + _fallback(event, error, stackTrace, toFile: toFile), ); Isolate? _loggerIsolate; @@ -111,6 +114,7 @@ class Logging { if (_loggerIsolate != null || _portRegistry.lookup() != null) { throw Exception("Logging was already initialized"); } + _logsDirPath = logsPath; final readyPort = ReceivePort(); final errorPort = ReceivePort(); @@ -149,8 +153,7 @@ class Logging { _loggerPort = null; throw StateError("Logger port registration failed"); } - logsDirPath = logsPath; - } catch (_) { + } catch (error, stackTrace) { loggerIsolate?.kill(priority: Isolate.immediate); _loggerIsolate = null; final loggerPort = _loggerPort; @@ -159,6 +162,16 @@ class Logging { } _loggerPort = null; _stopMonitoring(); + _fallback( + LogEvent( + Level.error, + "Logger initialization failed", + error: error, + stackTrace: stackTrace, + ), + StateError("Logger initialization failed"), + stackTrace, + ); rethrow; } finally { readyPort.close(); @@ -204,7 +217,7 @@ class Logging { final stackTrace = message is List && message.length > 1 ? StackTrace.fromString(message[1]?.toString() ?? "") : StackTrace.current; - developerLoggerFallback( + _fallback( LogEvent( Level.error, "Logger isolate failed", @@ -227,7 +240,7 @@ class Logging { _stopMonitoring(); if (hadActiveWorker) { - developerLoggerFallback( + _fallback( LogEvent(Level.error, "Logger isolate exited unexpectedly"), StateError("Logger isolate exited"), StackTrace.current, @@ -235,6 +248,21 @@ class Logging { } } + void _fallback( + LogEvent event, + Object error, + StackTrace stackTrace, { + bool toFile = true, + }) { + emergencyLoggerFallback( + event, + toFile, + error, + stackTrace, + logsDirectoryPath: _logsDirPath, + ); + } + void _stopMonitoring() { _loggerErrorPort?.close(); _loggerExitPort?.close(); diff --git a/lib/utilities/logger_dispatcher.dart b/lib/utilities/logger_dispatcher.dart index 5ca6229397..766db7b6c7 100644 --- a/lib/utilities/logger_dispatcher.dart +++ b/lib/utilities/logger_dispatcher.dart @@ -10,21 +10,32 @@ import 'dart:convert'; import 'dart:developer' as developer; +import 'dart:io'; import 'dart:isolate'; import 'dart:ui'; import 'package:logger/logger.dart'; +import 'package:path/path.dart' as path; typedef LoggerIsolateMessage = (LogEvent, bool); typedef LoggerMessageSender = void Function(Object?); typedef LoggerMessageSenderLookup = LoggerMessageSender? Function(); typedef LoggerFallback = - void Function(LogEvent event, Object error, StackTrace stackTrace); + void Function( + LogEvent event, + bool toFile, + Object error, + StackTrace stackTrace, + ); +typedef EmergencyLogWriter = void Function(String directoryPath, String text); + +// Keep the emergency writer independent of the isolate's latest.txt sink. +const emergencyLogFileName = "emergency.txt"; final class LoggerDispatcher { LoggerDispatcher({ required LoggerMessageSenderLookup lookupSender, - LoggerFallback fallback = developerLoggerFallback, + LoggerFallback fallback = emergencyLoggerFallback, }) : _lookupSender = lookupSender, _fallback = fallback; @@ -56,7 +67,7 @@ final class LoggerDispatcher { return true; } catch (dispatchError, dispatchStackTrace) { try { - _fallback(event, dispatchError, dispatchStackTrace); + _fallback(event, toFile, dispatchError, dispatchStackTrace); } catch (_) { // Logging must never break its caller. } @@ -96,15 +107,36 @@ final class LoggerPortRegistry { } } -void developerLoggerFallback( +void emergencyLoggerFallback( LogEvent event, + bool toFile, Object dispatchError, - StackTrace dispatchStackTrace, -) { + StackTrace dispatchStackTrace, { + String? logsDirectoryPath, + EmergencyLogWriter writeToFile = _writeEmergencyLog, +}) { + Object? fileError; + StackTrace? fileStackTrace; + + if (toFile && logsDirectoryPath != null) { + try { + writeToFile( + logsDirectoryPath, + _formatEmergencyLog(event, dispatchError, dispatchStackTrace), + ); + } catch (error, stackTrace) { + fileError = error; + fileStackTrace = stackTrace; + } + } + try { final originalError = event.error == null ? "" : "\nOriginal error: ${_safeToString(event.error)}"; + final emergencyFileError = fileError == null + ? "" + : "\nEmergency file write failed: ${_safeToString(fileError)}"; developer.log( _safeToString(event.message), name: "StackWallet.Logging", @@ -112,14 +144,41 @@ void developerLoggerFallback( time: event.time, error: "Logger dispatch failed: ${_safeToString(dispatchError)}" - "$originalError", - stackTrace: event.stackTrace ?? dispatchStackTrace, + "$originalError" + "$emergencyFileError", + stackTrace: event.stackTrace ?? fileStackTrace ?? dispatchStackTrace, ); } catch (_) { // The emergency path must remain independent of application logging. } } +void _writeEmergencyLog(String directoryPath, String text) { + final file = File(emergencyLogPath(directoryPath)); + file.parent.createSync(recursive: true); + file.writeAsStringSync(text, mode: FileMode.append, flush: true); +} + +String emergencyLogPath(String directoryPath, {path.Context? context}) => + (context ?? path.context).join(directoryPath, emergencyLogFileName); + +String _formatEmergencyLog( + LogEvent event, + Object dispatchError, + StackTrace dispatchStackTrace, +) { + final lines = [ + "${event.time.toUtc().toIso8601String()} " + "[${event.level.name}] ${_safeToString(event.message)}", + "Logger dispatch failed: ${_safeToString(dispatchError)}", + if (event.error != null) "Original error: ${_safeToString(event.error)}", + if (event.stackTrace != null) + "Original stack trace: ${_safeToString(event.stackTrace)}", + "Dispatch stack trace: ${_safeToString(dispatchStackTrace)}", + ]; + return "${lines.join("\n")}\n\n"; +} + String _stringifyMessage(dynamic message) { try { return !(message is Map || message is Iterable) diff --git a/test/utilities/logger_dispatcher_test.dart b/test/utilities/logger_dispatcher_test.dart index 178478e2b6..2f7ef5c0ce 100644 --- a/test/utilities/logger_dispatcher_test.dart +++ b/test/utilities/logger_dispatcher_test.dart @@ -1,7 +1,9 @@ +import 'dart:io'; import 'dart:isolate'; import 'package:flutter_test/flutter_test.dart'; import 'package:logger/logger.dart'; +import 'package:path/path.dart' as path; import 'package:stackwallet/utilities/logger_dispatcher.dart'; void main() { @@ -12,7 +14,7 @@ void main() { final timestamp = DateTime.utc(2026, 8, 20); final dispatcher = LoggerDispatcher( lookupSender: () => receivePort.sendPort.send, - fallback: (_, _, _) => fail("fallback should not run"), + fallback: (_, _, _, _) => fail("fallback should not run"), ); final didSend = dispatcher.log( @@ -31,11 +33,11 @@ void main() { }); test("uses the fallback when the logger isolate is unavailable", () { - final fallbackCalls = <(LogEvent, Object, StackTrace)>[]; + final fallbackCalls = <(LogEvent, bool, Object, StackTrace)>[]; final dispatcher = LoggerDispatcher( lookupSender: () => null, - fallback: (event, error, stackTrace) { - fallbackCalls.add((event, error, stackTrace)); + fallback: (event, toFile, error, stackTrace) { + fallbackCalls.add((event, toFile, error, stackTrace)); }, ); @@ -44,17 +46,18 @@ void main() { expect(didSend, isFalse); expect(fallbackCalls, hasLength(1)); expect(fallbackCalls.single.$1.message, "not ready"); - expect(fallbackCalls.single.$2, isA()); + expect(fallbackCalls.single.$2, isTrue); + expect(fallbackCalls.single.$3, isA()); }); test("uses the fallback when sending fails", () { final dispatchError = StateError("send failed"); - final fallbackCalls = <(LogEvent, Object, StackTrace)>[]; + final fallbackCalls = <(LogEvent, bool, Object, StackTrace)>[]; final dispatcher = LoggerDispatcher( lookupSender: () => (_) => throw dispatchError, - fallback: (event, error, stackTrace) { - fallbackCalls.add((event, error, stackTrace)); + fallback: (event, toFile, error, stackTrace) { + fallbackCalls.add((event, toFile, error, stackTrace)); }, ); @@ -63,13 +66,13 @@ void main() { expect(didSend, isFalse); expect(fallbackCalls, hasLength(1)); expect(fallbackCalls.single.$1.message, "important"); - expect(fallbackCalls.single.$2, same(dispatchError)); + expect(fallbackCalls.single.$3, same(dispatchError)); }); test("does not throw when the fallback itself fails", () { final dispatcher = LoggerDispatcher( lookupSender: () => null, - fallback: (_, _, _) => throw StateError("fallback failed"), + fallback: (_, _, _, _) => throw StateError("fallback failed"), ); expect(() => dispatcher.log(Level.error, "important"), returnsNormally); @@ -79,7 +82,7 @@ void main() { final sentMessages = []; final dispatcher = LoggerDispatcher( lookupSender: () => sentMessages.add, - fallback: (_, _, _) => fail("fallback should not run"), + fallback: (_, _, _, _) => fail("fallback should not run"), ); final didSend = dispatcher.log(Level.info, _UnprintableMessage()); @@ -90,7 +93,68 @@ void main() { expect(message.$1.message, contains("could not stringify")); }); - test("the developer fallback tolerates unprintable details", () { + test("writes dispatch failures to the emergency log", () { + final directory = Directory.systemTemp.createTempSync( + "logger_dispatcher_test_", + ); + addTearDown(() => directory.deleteSync(recursive: true)); + final timestamp = DateTime.utc(2026, 8, 20, 12, 34, 56); + final event = LogEvent( + Level.error, + "wallet recovery failed", + time: timestamp, + error: StateError("original failure"), + stackTrace: StackTrace.fromString("original stack"), + ); + + emergencyLoggerFallback( + event, + true, + StateError("logger isolate unavailable"), + StackTrace.fromString("dispatch stack"), + logsDirectoryPath: directory.path, + ); + emergencyLoggerFallback( + LogEvent( + Level.warning, + "subsequent failure", + time: timestamp.add(const Duration(seconds: 1)), + ), + true, + StateError("logger still unavailable"), + StackTrace.fromString("second dispatch stack"), + logsDirectoryPath: directory.path, + ); + + final contents = File( + emergencyLogPath(directory.path), + ).readAsStringSync(); + expect(contents, contains(timestamp.toIso8601String())); + expect(contents, contains("[error] wallet recovery failed")); + expect(contents, contains("original failure")); + expect(contents, contains("logger isolate unavailable")); + expect(contents, contains("original stack")); + expect(contents, contains("dispatch stack")); + expect(contents, contains("subsequent failure")); + expect(contents, contains("logger still unavailable")); + }); + + test("does not persist console-only messages", () { + var writeCalls = 0; + + emergencyLoggerFallback( + LogEvent(Level.info, "console only"), + false, + StateError("logger isolate unavailable"), + StackTrace.current, + logsDirectoryPath: "unused", + writeToFile: (_, _) => writeCalls++, + ); + + expect(writeCalls, isZero); + }); + + test("tolerates emergency file and formatting failures", () { final event = LogEvent( Level.error, _UnprintableMessage(), @@ -98,14 +162,41 @@ void main() { ); expect( - () => developerLoggerFallback( + () => emergencyLoggerFallback( event, + true, _UnprintableMessage(), StackTrace.current, + logsDirectoryPath: "unwritable", + writeToFile: (_, _) => throw StateError("write failed"), ), returnsNormally, ); }); + + test("builds emergency log paths for every native platform", () { + final posix = path.Context(style: path.Style.posix); + final windows = path.Context(style: path.Style.windows); + + for (final directory in [ + "/home/stack/Documents/StackWallet_Logs/", + "/Users/stack/Documents/StackWallet_Logs/", + "/data/user/0/com.cypherstack.stackwallet/files/logs/", + "/var/mobile/Containers/Data/Application/id/Documents/logs/", + ]) { + expect( + emergencyLogPath(directory, context: posix), + "${directory}emergency.txt", + ); + } + expect( + emergencyLogPath( + r"C:\Users\Stack\Documents\StackWallet_Logs\", + context: windows, + ), + r"C:\Users\Stack\Documents\StackWallet_Logs\emergency.txt", + ); + }); }); group("LoggerPortRegistry", () { From 9eb882e6606d720d121fbf655aeb6d6b2a332f35 Mon Sep 17 00:00:00 2001 From: sneurlax Date: Fri, 21 Aug 2026 13:31:37 -0500 Subject: [PATCH 3/3] fix(logger): recover after worker failure Watch error and exit on one port so a worker failure is recorded once, then restart the worker up to three times per process. Emergency writes are coalesced to one record per second and the emergency log is rotated across two files of at most 1 MiB each. --- lib/utilities/logger.dart | 155 +++++++++++++++----- lib/utilities/logger_dispatcher.dart | 157 +++++++++++++++++++-- test/utilities/logger_crash_test.dart | 73 ++++++++++ test/utilities/logger_dispatcher_test.dart | 63 +++++++++ 4 files changed, 402 insertions(+), 46 deletions(-) create mode 100644 test/utilities/logger_crash_test.dart diff --git a/lib/utilities/logger.dart b/lib/utilities/logger.dart index 2c373ed350..78f73424d2 100644 --- a/lib/utilities/logger.dart +++ b/lib/utilities/logger.dart @@ -8,6 +8,7 @@ * */ +import 'dart:async'; import 'dart:isolate'; import 'package:logger/logger.dart'; @@ -89,17 +90,26 @@ class Logging { late final LoggerPortRegistry _portRegistry = LoggerPortRegistry.named( _kLoggerPortName, ); + late final EmergencyLogThrottle _fallbackThrottle = EmergencyLogThrottle( + write: _writeFallback, + ); late final LoggerDispatcher _dispatcher = LoggerDispatcher( lookupSender: () => Util.isTestEnv ? _printTestMessage : _portRegistry.lookup()?.send, - fallback: (event, toFile, error, stackTrace) => - _fallback(event, error, stackTrace, toFile: toFile), + fallback: _fallback, ); Isolate? _loggerIsolate; SendPort? _loggerPort; - ReceivePort? _loggerErrorPort; - ReceivePort? _loggerExitPort; + ReceivePort? _loggerMonitorPort; + Level? _level; + Level? _debugConsoleLevel; + bool _initialized = false; + bool _workerFailureReported = false; + bool _restartInProgress = false; + int _restartAttempts = 0; + + static const int _maxRestartAttempts = 3; Future initialize( String logsPath, { @@ -115,14 +125,38 @@ class Logging { throw Exception("Logging was already initialized"); } _logsDirPath = logsPath; + _level = level; + _debugConsoleLevel = debugConsoleLevel; + try { + await _startWorker(); + _initialized = true; + } catch (error, stackTrace) { + _criticalFallback( + LogEvent( + Level.error, + "Logger initialization failed", + error: error, + stackTrace: stackTrace, + ), + StateError("Logger initialization failed"), + stackTrace, + ); + rethrow; + } + } + + Future _startWorker() async { final readyPort = ReceivePort(); - final errorPort = ReceivePort(); - final exitPort = ReceivePort(); + final monitorPort = ReceivePort(); var workerExited = false; - _loggerErrorPort = errorPort..listen(_handleLoggerError); - _loggerExitPort = exitPort - ..listen((_) { + _workerFailureReported = false; + _loggerMonitorPort = monitorPort + ..listen((message) { + if (message != null) { + _handleLoggerError(message); + return; + } workerExited = true; _handleLoggerExit(); }); @@ -133,12 +167,12 @@ class Logging { _runLoggerIsolate, ( readyPort: readyPort.sendPort, - logsPath: logsPath, - level: level, - debugConsoleLevel: debugConsoleLevel, + logsPath: logsDirPath, + level: _level!, + debugConsoleLevel: _debugConsoleLevel, ), - onError: errorPort.sendPort, - onExit: exitPort.sendPort, + onError: monitorPort.sendPort, + onExit: monitorPort.sendPort, errorsAreFatal: true, debugName: "logger", ); @@ -153,7 +187,7 @@ class Logging { _loggerPort = null; throw StateError("Logger port registration failed"); } - } catch (error, stackTrace) { + } catch (_) { loggerIsolate?.kill(priority: Isolate.immediate); _loggerIsolate = null; final loggerPort = _loggerPort; @@ -162,16 +196,6 @@ class Logging { } _loggerPort = null; _stopMonitoring(); - _fallback( - LogEvent( - Level.error, - "Logger initialization failed", - error: error, - stackTrace: stackTrace, - ), - StateError("Logger initialization failed"), - stackTrace, - ); rethrow; } finally { readyPort.close(); @@ -217,7 +241,8 @@ class Logging { final stackTrace = message is List && message.length > 1 ? StackTrace.fromString(message[1]?.toString() ?? "") : StackTrace.current; - _fallback( + _workerFailureReported = true; + _criticalFallback( LogEvent( Level.error, "Logger isolate failed", @@ -231,6 +256,7 @@ class Logging { void _handleLoggerExit() { final hadActiveWorker = _loggerIsolate != null; + final failureWasReported = _workerFailureReported; final loggerPort = _loggerPort; if (loggerPort != null) { _portRegistry.removeIfCurrent(loggerPort); @@ -239,21 +265,82 @@ class Logging { _loggerIsolate = null; _stopMonitoring(); - if (hadActiveWorker) { - _fallback( + if (hadActiveWorker && !failureWasReported) { + _workerFailureReported = true; + _criticalFallback( LogEvent(Level.error, "Logger isolate exited unexpectedly"), StateError("Logger isolate exited"), StackTrace.current, ); } + + if (hadActiveWorker && + _initialized && + !_restartInProgress && + _restartAttempts < _maxRestartAttempts) { + _restartInProgress = true; + unawaited(_restartWorker()); + } } void _fallback( LogEvent event, + bool toFile, Object error, - StackTrace stackTrace, { - bool toFile = true, - }) { + StackTrace stackTrace, + ) { + if (!toFile || _logsDirPath == null) { + _writeFallback(event, false, error, stackTrace); + return; + } + _fallbackThrottle.add(event, true, error, stackTrace); + } + + Future _restartWorker() async { + Object? lastError; + StackTrace? lastStackTrace; + try { + while (_restartAttempts < _maxRestartAttempts) { + _restartAttempts++; + await Future.delayed( + Duration(milliseconds: 100 * _restartAttempts), + ); + try { + await _startWorker(); + return; + } catch (error, stackTrace) { + lastError = error; + lastStackTrace = stackTrace; + } + } + + if (!_workerFailureReported) { + _criticalFallback( + LogEvent( + Level.error, + "Logger isolate restart failed", + error: lastError, + stackTrace: lastStackTrace, + ), + StateError("Logger isolate restart failed"), + lastStackTrace ?? StackTrace.current, + ); + } + } finally { + _restartInProgress = false; + } + } + + void _criticalFallback(LogEvent event, Object error, StackTrace stackTrace) { + _writeFallback(event, true, error, stackTrace); + } + + void _writeFallback( + LogEvent event, + bool toFile, + Object error, + StackTrace stackTrace, + ) { emergencyLoggerFallback( event, toFile, @@ -264,10 +351,8 @@ class Logging { } void _stopMonitoring() { - _loggerErrorPort?.close(); - _loggerExitPort?.close(); - _loggerErrorPort = null; - _loggerExitPort = null; + _loggerMonitorPort?.close(); + _loggerMonitorPort = null; } void t( diff --git a/lib/utilities/logger_dispatcher.dart b/lib/utilities/logger_dispatcher.dart index 766db7b6c7..249af7e20b 100644 --- a/lib/utilities/logger_dispatcher.dart +++ b/lib/utilities/logger_dispatcher.dart @@ -8,6 +8,7 @@ * */ +import 'dart:async'; import 'dart:convert'; import 'dart:developer' as developer; import 'dart:io'; @@ -29,15 +30,17 @@ typedef LoggerFallback = ); typedef EmergencyLogWriter = void Function(String directoryPath, String text); -// Keep the emergency writer independent of the isolate's latest.txt sink. const emergencyLogFileName = "emergency.txt"; +const previousEmergencyLogFileName = "emergency.previous.txt"; +const maxEmergencyLogBytes = 1024 * 1024; final class LoggerDispatcher { - LoggerDispatcher({ + factory LoggerDispatcher({ required LoggerMessageSenderLookup lookupSender, LoggerFallback fallback = emergencyLoggerFallback, - }) : _lookupSender = lookupSender, - _fallback = fallback; + }) => LoggerDispatcher._(lookupSender, fallback); + + LoggerDispatcher._(this._lookupSender, this._fallback); final LoggerMessageSenderLookup _lookupSender; final LoggerFallback _fallback; @@ -77,13 +80,13 @@ final class LoggerDispatcher { } final class LoggerPortRegistry { - LoggerPortRegistry({ + factory LoggerPortRegistry({ required SendPort? Function() lookupPort, required bool Function(SendPort) registerPort, required bool Function() removePort, - }) : _lookupPort = lookupPort, - _registerPort = registerPort, - _removePort = removePort; + }) => LoggerPortRegistry._(lookupPort, registerPort, removePort); + + LoggerPortRegistry._(this._lookupPort, this._registerPort, this._removePort); factory LoggerPortRegistry.named(String name) => LoggerPortRegistry( lookupPort: () => IsolateNameServer.lookupPortByName(name), @@ -107,13 +110,85 @@ final class LoggerPortRegistry { } } +final class EmergencyLogThrottle { + factory EmergencyLogThrottle({ + required LoggerFallback write, + Duration interval = const Duration(seconds: 1), + }) => EmergencyLogThrottle._(write, interval); + + EmergencyLogThrottle._(this._write, this.interval); + + final LoggerFallback _write; + final Duration interval; + + Timer? _timer; + int _coalescedCount = 0; + LogEvent? _lastEvent; + Object? _lastDispatchError; + StackTrace? _lastDispatchStackTrace; + + void add( + LogEvent event, + bool toFile, + Object dispatchError, + StackTrace dispatchStackTrace, + ) { + if (!toFile || (_timer == null && _coalescedCount == 0)) { + _write(event, toFile, dispatchError, dispatchStackTrace); + if (toFile) { + _timer = Timer(interval, _flush); + } + return; + } + + _coalescedCount++; + _lastEvent = event; + _lastDispatchError = dispatchError; + _lastDispatchStackTrace = dispatchStackTrace; + } + + void _flush() { + _timer = null; + final lastEvent = _lastEvent; + final lastDispatchError = _lastDispatchError; + final lastDispatchStackTrace = _lastDispatchStackTrace; + final count = _coalescedCount; + + _coalescedCount = 0; + _lastEvent = null; + _lastDispatchError = null; + _lastDispatchStackTrace = null; + + if (lastEvent == null || + lastDispatchError == null || + lastDispatchStackTrace == null) { + return; + } + + _write( + LogEvent( + lastEvent.level, + "$count log messages coalesced while the logger was unavailable. " + "Last message: ${lastEvent.message}", + time: lastEvent.time, + error: lastEvent.error, + stackTrace: lastEvent.stackTrace, + ), + true, + lastDispatchError, + lastDispatchStackTrace, + ); + _timer = Timer(interval, _flush); + } +} + void emergencyLoggerFallback( LogEvent event, bool toFile, Object dispatchError, StackTrace dispatchStackTrace, { String? logsDirectoryPath, - EmergencyLogWriter writeToFile = _writeEmergencyLog, + EmergencyLogWriter writeToFile = writeEmergencyLog, }) { Object? fileError; StackTrace? fileStackTrace; @@ -153,15 +228,75 @@ void emergencyLoggerFallback( } } -void _writeEmergencyLog(String directoryPath, String text) { +void writeEmergencyLog( + String directoryPath, + String text, { + int maxBytes = maxEmergencyLogBytes, +}) { final file = File(emergencyLogPath(directoryPath)); file.parent.createSync(recursive: true); - file.writeAsStringSync(text, mode: FileMode.append, flush: true); + final boundedText = _boundEmergencyText(text, maxBytes); + final incomingBytes = utf8.encode(boundedText).length; + + if (file.existsSync() && file.lengthSync() + incomingBytes > maxBytes) { + final previousFile = File(previousEmergencyLogPath(directoryPath)); + if (previousFile.existsSync()) { + previousFile.deleteSync(); + } + if (file.lengthSync() <= maxBytes) { + file.renameSync(previousFile.path); + } else { + file.deleteSync(); + } + } + + file.writeAsStringSync(boundedText, mode: FileMode.append, flush: true); } String emergencyLogPath(String directoryPath, {path.Context? context}) => (context ?? path.context).join(directoryPath, emergencyLogFileName); +String previousEmergencyLogPath( + String directoryPath, { + path.Context? context, +}) => + (context ?? path.context).join(directoryPath, previousEmergencyLogFileName); + +String _boundEmergencyText(String text, int maxBytes) { + if (maxBytes <= 0) { + return ""; + } + + final bytes = utf8.encode(text); + if (bytes.length <= maxBytes) { + return text; + } + + const marker = "\n\n"; + final markerBytes = utf8.encode(marker); + if (markerBytes.length >= maxBytes) { + return _prefixWithinUtf8Bytes(text, maxBytes); + } + + final prefix = _prefixWithinUtf8Bytes(text, maxBytes - markerBytes.length); + return "$prefix$marker"; +} + +String _prefixWithinUtf8Bytes(String text, int maxBytes) { + final result = StringBuffer(); + var length = 0; + for (final rune in text.runes) { + final character = String.fromCharCode(rune); + final characterLength = utf8.encode(character).length; + if (length + characterLength > maxBytes) { + break; + } + result.write(character); + length += characterLength; + } + return result.toString(); +} + String _formatEmergencyLog( LogEvent event, Object dispatchError, diff --git a/test/utilities/logger_crash_test.dart b/test/utilities/logger_crash_test.dart new file mode 100644 index 0000000000..d11c6ea638 --- /dev/null +++ b/test/utilities/logger_crash_test.dart @@ -0,0 +1,73 @@ +import 'dart:io'; +import 'dart:ui'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:logger/logger.dart'; +import 'package:path/path.dart' as path; +import 'package:stackwallet/utilities/logger.dart'; +import 'package:stackwallet/utilities/logger_dispatcher.dart'; + +const _loggerPortName = "logger_port"; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + test("persists a worker crash and restarts logging", () async { + final logsDirectory = Directory.systemTemp.createTempSync( + "logger_crash_test_", + ); + addTearDown(() => logsDirectory.deleteSync(recursive: true)); + + await Logging.instance.initialize( + logsDirectory.path, + level: Level.trace, + debugConsoleLevel: Level.off, + ); + final initialPort = IsolateNameServer.lookupPortByName(_loggerPortName); + expect(initialPort, isNotNull); + + initialPort!.send("malformed logger message"); + + final emergencyLog = File(emergencyLogPath(logsDirectory.path)); + await _waitFor(() { + final currentPort = IsolateNameServer.lookupPortByName(_loggerPortName); + return emergencyLog.existsSync() && + emergencyLog.readAsStringSync().contains("Logger isolate failed") && + currentPort != null && + currentPort != initialPort; + }); + + final contents = emergencyLog.readAsStringSync(); + expect(contents, contains("Logger isolate failed")); + expect(contents, contains("Logger isolate reported an error")); + expect(contents, contains("is not a subtype of type")); + expect(contents, isNot(contains("Logger isolate exited unexpectedly"))); + + final restartedPort = IsolateNameServer.lookupPortByName(_loggerPortName)!; + for (var i = 0; i < 200; i++) { + restartedPort.send((LogEvent(Level.info, "post-restart $i"), true)); + } + restartedPort.send(( + LogEvent(Level.warning, "post-restart complete"), + true, + )); + + final latestLog = File(path.join(logsDirectory.path, "latest.txt")); + await _waitFor( + () => + latestLog.existsSync() && + latestLog.readAsStringSync().contains("post-restart complete"), + ); + expect(emergencyLog.readAsStringSync(), contents); + }); +} + +Future _waitFor(bool Function() condition) async { + final deadline = DateTime.now().add(const Duration(seconds: 5)); + while (!condition()) { + if (DateTime.now().isAfter(deadline)) { + fail("Timed out waiting for the logger isolate to fail"); + } + await Future.delayed(const Duration(milliseconds: 20)); + } +} diff --git a/test/utilities/logger_dispatcher_test.dart b/test/utilities/logger_dispatcher_test.dart index 2f7ef5c0ce..9552a70762 100644 --- a/test/utilities/logger_dispatcher_test.dart +++ b/test/utilities/logger_dispatcher_test.dart @@ -1,3 +1,4 @@ +import 'dart:convert'; import 'dart:io'; import 'dart:isolate'; @@ -243,6 +244,68 @@ void main() { expect(removeCalls, 0); }); }); + + group("EmergencyLogThrottle", () { + test("coalesces repeated fallback writes", () async { + final writes = []; + final throttle = EmergencyLogThrottle( + write: (event, _, _, _) => writes.add(event), + interval: const Duration(milliseconds: 10), + ); + + for (var i = 0; i < 100; i++) { + throttle.add( + LogEvent(Level.warning, "message $i"), + true, + StateError("unavailable"), + StackTrace.current, + ); + } + + expect(writes, hasLength(1)); + await Future.delayed(const Duration(milliseconds: 40)); + expect(writes, hasLength(2)); + expect(writes.last.message, contains("99 log messages coalesced")); + expect(writes.last.message, contains("message 99")); + }); + }); + + group("emergency log files", () { + test("rotates and bounds emergency files", () { + final directory = Directory.systemTemp.createTempSync( + "logger_rotation_test_", + ); + addTearDown(() => directory.deleteSync(recursive: true)); + + writeEmergencyLog( + directory.path, + "first-${List.filled(100, "x").join()}", + maxBytes: 64, + ); + writeEmergencyLog( + directory.path, + "second-${List.filled(100, "🙂").join()}", + maxBytes: 64, + ); + + final current = File(emergencyLogPath(directory.path)); + final previous = File(previousEmergencyLogPath(directory.path)); + expect(current.lengthSync(), lessThanOrEqualTo(64)); + expect(previous.lengthSync(), lessThanOrEqualTo(64)); + expect(current.readAsStringSync(), startsWith("second-")); + expect(previous.readAsStringSync(), startsWith("first-")); + expect( + utf8.encode(current.readAsStringSync()), + hasLength(current.lengthSync()), + ); + + current.writeAsBytesSync(List.filled(256, 0x61)); + writeEmergencyLog(directory.path, "third", maxBytes: 64); + expect(current.lengthSync(), lessThanOrEqualTo(64)); + expect(current.readAsStringSync(), "third"); + expect(previous.existsSync(), isFalse); + }); + }); } class _UnprintableMessage {