From 7b5f6650cf13d07cfc3aff044c74dbd07f562d61 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 --- lib/utilities/logger.dart | 269 +++++++++++++-------- lib/utilities/logger_dispatcher.dart | 143 +++++++++++ test/utilities/logger_dispatcher_test.dart | 160 ++++++++++++ 3 files changed, 475 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..dcc873036c 100644 --- a/lib/utilities/logger.dart +++ b/lib/utilities/logger.dart @@ -8,14 +8,11 @@ * */ -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'; @@ -27,15 +24,20 @@ class 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,73 +49,109 @@ 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 readyPort = ReceivePort(); + final errorPort = ReceivePort(); + final exitPort = ReceivePort(); + var workerExited = false; + _loggerErrorPort = errorPort..listen(_handleLoggerError); + _loggerExitPort = exitPort + ..listen((_) { + workerExited = true; + _handleLoggerExit(); + }); - final consoleLogger = Logger( - printer: PrefixPrinter(prettyPrinter(false)), - filter: ProductionFilter(), - level: debugConsoleLevel ?? level, - ); + Isolate? loggerIsolate; + try { + loggerIsolate = await Isolate.spawn( + (sendPort) { + final receivePort = ReceivePort(); + sendPort.send(receivePort.sendPort); - 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], - ), - ); + PrettyPrinter prettyPrinter(bool toFile) => PrettyPrinter( + printEmojis: false, + methodCount: 0, + dateTimeFormat: toFile + ? DateTimeFormat.none + : DateTimeFormat.dateAndTime, + colors: !toFile, + noBoxingByDefault: toFile, + ); - 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, + final consoleLogger = Logger( + printer: PrefixPrinter(prettyPrinter(false)), + filter: ProductionFilter(), + level: debugConsoleLevel ?? level, ); - } - }); - }, receivePort.sendPort); - final loggerPort = await receivePort.first as SendPort; - IsolateNameServer.registerPortWithName(loggerPort, _kLoggerPortName); - } - String _stringifyMessage(dynamic message) => - !(message is Map || message is Iterable) - ? message.toString() - : JsonEncoder.withIndent(' ', (o) => o.toString()).convert(message); + final fileLogger = Logger( + printer: PrefixPrinter(prettyPrinter(true)), + filter: ProductionFilter(), + level: level, + output: AdvancedFileOutput( + path: 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, + ); + } + }); + }, + readyPort.sendPort, + onError: errorPort.sendPort, + onExit: exitPort.sendPort, + errorsAreFatal: true, + debugName: "logger", + ); + _loggerIsolate = loggerIsolate; + + 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(); + } + } void log( Level level, @@ -123,38 +161,75 @@ 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( dynamic message, { DateTime? time, 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 3ba81a5573525eb99bff64e3c16d932abb78b0ed 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 --- lib/utilities/logger.dart | 38 +++++++-- lib/utilities/logger_dispatcher.dart | 73 +++++++++++++++-- test/utilities/logger_dispatcher_test.dart | 92 +++++++++++++++++++--- 3 files changed, 177 insertions(+), 26 deletions(-) diff --git a/lib/utilities/logger.dart b/lib/utilities/logger.dart index dcc873036c..60f5166b69 100644 --- a/lib/utilities/logger.dart +++ b/lib/utilities/logger.dart @@ -24,7 +24,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, @@ -32,6 +33,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; @@ -52,6 +55,7 @@ class Logging { if (_loggerIsolate != null || _portRegistry.lookup() != null) { throw Exception("Logging was already initialized"); } + _logsDirPath = logsPath; final readyPort = ReceivePort(); final errorPort = ReceivePort(); @@ -137,8 +141,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; @@ -147,6 +150,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(); @@ -192,7 +205,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", @@ -215,7 +228,7 @@ class Logging { _stopMonitoring(); if (hadActiveWorker) { - developerLoggerFallback( + _fallback( LogEvent(Level.error, "Logger isolate exited unexpectedly"), StateError("Logger isolate exited"), StackTrace.current, @@ -223,6 +236,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..7641dcc603 100644 --- a/lib/utilities/logger_dispatcher.dart +++ b/lib/utilities/logger_dispatcher.dart @@ -10,6 +10,7 @@ import 'dart:convert'; import 'dart:developer' as developer; +import 'dart:io'; import 'dart:isolate'; import 'dart:ui'; @@ -19,12 +20,21 @@ 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 +66,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 +106,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 +143,40 @@ 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( + "$directoryPath${Platform.pathSeparator}$emergencyLogFileName", + ); + file.parent.createSync(recursive: true); + file.writeAsStringSync(text, mode: FileMode.append, flush: true); +} + +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..e0ce8abddb 100644 --- a/test/utilities/logger_dispatcher_test.dart +++ b/test/utilities/logger_dispatcher_test.dart @@ -1,3 +1,4 @@ +import 'dart:io'; import 'dart:isolate'; import 'package:flutter_test/flutter_test.dart'; @@ -12,7 +13,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 +32,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 +45,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 +65,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 +81,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 +92,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( + "${directory.path}${Platform.pathSeparator}$emergencyLogFileName", + ).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,10 +161,13 @@ void main() { ); expect( - () => developerLoggerFallback( + () => emergencyLoggerFallback( event, + true, _UnprintableMessage(), StackTrace.current, + logsDirectoryPath: "unwritable", + writeToFile: (_, _) => throw StateError("write failed"), ), returnsNormally, ); From c79c1f3ad81d74f8f61b8485e795cc31e01e3e83 Mon Sep 17 00:00:00 2001 From: sneurlax Date: Thu, 20 Aug 2026 23:10:36 -0500 Subject: [PATCH 3/3] fix(logger): use portable fallback paths --- lib/utilities/logger_dispatcher.dart | 8 ++++--- test/utilities/logger_dispatcher_test.dart | 27 +++++++++++++++++++++- 2 files changed, 31 insertions(+), 4 deletions(-) diff --git a/lib/utilities/logger_dispatcher.dart b/lib/utilities/logger_dispatcher.dart index 7641dcc603..766db7b6c7 100644 --- a/lib/utilities/logger_dispatcher.dart +++ b/lib/utilities/logger_dispatcher.dart @@ -15,6 +15,7 @@ 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?); @@ -153,13 +154,14 @@ void emergencyLoggerFallback( } void _writeEmergencyLog(String directoryPath, String text) { - final file = File( - "$directoryPath${Platform.pathSeparator}$emergencyLogFileName", - ); + 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, diff --git a/test/utilities/logger_dispatcher_test.dart b/test/utilities/logger_dispatcher_test.dart index e0ce8abddb..2f7ef5c0ce 100644 --- a/test/utilities/logger_dispatcher_test.dart +++ b/test/utilities/logger_dispatcher_test.dart @@ -3,6 +3,7 @@ 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() { @@ -126,7 +127,7 @@ void main() { ); final contents = File( - "${directory.path}${Platform.pathSeparator}$emergencyLogFileName", + emergencyLogPath(directory.path), ).readAsStringSync(); expect(contents, contains(timestamp.toIso8601String())); expect(contents, contains("[error] wallet recovery failed")); @@ -172,6 +173,30 @@ void main() { 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", () {