From e6eb093cc63ba020799844905439f1cabfddcd3c Mon Sep 17 00:00:00 2001
From: Matthias Nott <mnott@mnsoft.org>
Date: Tue, 11 Aug 2026 01:30:57 +0200
Subject: [PATCH] fix: a store that cannot brick itself on its own history
---
lib/services/message_store.dart | 52 ++++++++++++++++++++++++++++++++++++----------------
1 files changed, 36 insertions(+), 16 deletions(-)
diff --git a/lib/services/message_store.dart b/lib/services/message_store.dart
index 1497228..812a5b4 100644
--- a/lib/services/message_store.dart
+++ b/lib/services/message_store.dart
@@ -88,19 +88,32 @@
}
}
+ /// Session id, without decoding the message it belongs to.
+ ///
+ /// The index needs ONE short field per line. jsonDecode gave it by parsing
+ /// the whole record — including an inline base64 attachment that can be tens
+ /// of megabytes — so building the index cost the size of the entire history.
+ /// With enough of it, startup exceeded the iOS watchdog and the app could
+ /// never open again: 9.5 seconds of CPU, killed at 0x8BADF00D, on a store
+ /// that had grown a few megabytes at a time for weeks.
+ ///
+ /// A line is written by us and always carries "sessionId":"…", so reading it
+ /// is a scan, not a parse.
+ static final RegExp _sessionIdPattern = RegExp(r'"sessionId"\s*:\s*"([^"]+)"');
+
+ static String? _sessionIdOf(String line) =>
+ _sessionIdPattern.firstMatch(line)?.group(1);
+
static Future<void> _rebuildIndex(File logFile) async {
_index.clear();
final lines = logFile.readAsLinesSync();
for (var i = 0; i < lines.length; i++) {
final line = lines[i].trim();
if (line.isEmpty) continue;
- try {
- final map = jsonDecode(line) as Map<String, dynamic>;
- final sessionId = map['sessionId'] as String?;
- if (sessionId != null) {
- _index.putIfAbsent(sessionId, () => []).add(i);
- }
- } catch (_) {}
+ final sessionId = _sessionIdOf(line);
+ if (sessionId != null) {
+ _index.putIfAbsent(sessionId, () => []).add(i);
+ }
}
}
@@ -189,6 +202,9 @@
// ------------------------------------------------------------- compact --
+ /// Above this, compact regardless of how few lines there are.
+ static const int _compactAboveBytes = 8 * 1024 * 1024;
+
/// Rewrite the log keeping at most [keepPerSession] messages per session.
/// Called once on startup after initialize(). NOT called during normal use.
static Future<void> compact({int keepPerSession = 200}) async {
@@ -198,7 +214,12 @@
if (!logFile.existsSync()) return;
final allLines = logFile.readAsLinesSync();
- if (allLines.length < 500) return; // nothing worth compacting
+ // Compact on SIZE as well as line count. A store is bounded at 200
+ // messages per session, which bounds nothing when a message carries an
+ // inline attachment: 200 screenshots is tens of megabytes, and the count
+ // never trips the threshold. Bytes are what startup has to read.
+ final bytes = logFile.lengthSync();
+ if (allLines.length < 500 && bytes < _compactAboveBytes) return;
// Build a set of line numbers to keep: last keepPerSession per session.
final keepLines = <int>{};
@@ -224,14 +245,13 @@
final line = allLines[i].trim();
if (line.isEmpty) continue;
buffer.write('$line\n');
- // Extract sessionId for new index.
- try {
- final map = jsonDecode(line) as Map<String, dynamic>;
- final sid = map['sessionId'] as String?;
- if (sid != null) {
- newIndex.putIfAbsent(sid, () => []).add(newLine);
- }
- } catch (_) {}
+ // Same scan as the index build, for the same reason: the only field
+ // needed here is short, and decoding the record to reach it makes
+ // compaction cost the size of the history it exists to bound.
+ final sid = _sessionIdOf(line);
+ if (sid != null) {
+ newIndex.putIfAbsent(sid, () => []).add(newLine);
+ }
newLine++;
}
--
Gitblit v1.3.1