Matthias Nott
yesterday e6eb093cc63ba020799844905439f1cabfddcd3c
fix: a store that cannot brick itself on its own history

Startup built its index by jsonDecode-ing every line of the message log to read
one short field. Each line carries its attachment inline as base64, so building
the index cost the size of the entire history — and once that history was large
enough, launch exceeded the iOS watchdog and the app could never be opened
again. 9.5 seconds of CPU, killed at 0x8BADF00D, on a store that had grown a
few megabytes at a time for weeks.

The index needs only the session id, which is a scan rather than a parse.
Compaction now also triggers on bytes: 200 messages per session bounds the
count and nothing else when a message can carry a screenshot.

Also: a saved file is no longer named by appending an extension to a caption
that already ends in one, which produced names with two dots and, when the type
was unknown, a .bin no player would open. The type table gained the video,
audio and image types it never had, and falls back to a self-naming subtype
instead of bin.
3 files modified
changed files
lib/services/message_store.dart patch | view | blame | history
lib/widgets/message_bubble.dart patch | view | blame | history
pubspec.yaml patch | view | blame | history
lib/services/message_store.dart
....@@ -88,19 +88,32 @@
8888 }
8989 }
9090
91
+ /// Session id, without decoding the message it belongs to.
92
+ ///
93
+ /// The index needs ONE short field per line. jsonDecode gave it by parsing
94
+ /// the whole record — including an inline base64 attachment that can be tens
95
+ /// of megabytes — so building the index cost the size of the entire history.
96
+ /// With enough of it, startup exceeded the iOS watchdog and the app could
97
+ /// never open again: 9.5 seconds of CPU, killed at 0x8BADF00D, on a store
98
+ /// that had grown a few megabytes at a time for weeks.
99
+ ///
100
+ /// A line is written by us and always carries "sessionId":"…", so reading it
101
+ /// is a scan, not a parse.
102
+ static final RegExp _sessionIdPattern = RegExp(r'"sessionId"\s*:\s*"([^"]+)"');
103
+
104
+ static String? _sessionIdOf(String line) =>
105
+ _sessionIdPattern.firstMatch(line)?.group(1);
106
+
91107 static Future<void> _rebuildIndex(File logFile) async {
92108 _index.clear();
93109 final lines = logFile.readAsLinesSync();
94110 for (var i = 0; i < lines.length; i++) {
95111 final line = lines[i].trim();
96112 if (line.isEmpty) continue;
97
- try {
98
- final map = jsonDecode(line) as Map<String, dynamic>;
99
- final sessionId = map['sessionId'] as String?;
100
- if (sessionId != null) {
101
- _index.putIfAbsent(sessionId, () => []).add(i);
102
- }
103
- } catch (_) {}
113
+ final sessionId = _sessionIdOf(line);
114
+ if (sessionId != null) {
115
+ _index.putIfAbsent(sessionId, () => []).add(i);
116
+ }
104117 }
105118 }
106119
....@@ -189,6 +202,9 @@
189202
190203 // ------------------------------------------------------------- compact --
191204
205
+ /// Above this, compact regardless of how few lines there are.
206
+ static const int _compactAboveBytes = 8 * 1024 * 1024;
207
+
192208 /// Rewrite the log keeping at most [keepPerSession] messages per session.
193209 /// Called once on startup after initialize(). NOT called during normal use.
194210 static Future<void> compact({int keepPerSession = 200}) async {
....@@ -198,7 +214,12 @@
198214 if (!logFile.existsSync()) return;
199215
200216 final allLines = logFile.readAsLinesSync();
201
- if (allLines.length < 500) return; // nothing worth compacting
217
+ // Compact on SIZE as well as line count. A store is bounded at 200
218
+ // messages per session, which bounds nothing when a message carries an
219
+ // inline attachment: 200 screenshots is tens of megabytes, and the count
220
+ // never trips the threshold. Bytes are what startup has to read.
221
+ final bytes = logFile.lengthSync();
222
+ if (allLines.length < 500 && bytes < _compactAboveBytes) return;
202223
203224 // Build a set of line numbers to keep: last keepPerSession per session.
204225 final keepLines = <int>{};
....@@ -224,14 +245,13 @@
224245 final line = allLines[i].trim();
225246 if (line.isEmpty) continue;
226247 buffer.write('$line\n');
227
- // Extract sessionId for new index.
228
- try {
229
- final map = jsonDecode(line) as Map<String, dynamic>;
230
- final sid = map['sessionId'] as String?;
231
- if (sid != null) {
232
- newIndex.putIfAbsent(sid, () => []).add(newLine);
233
- }
234
- } catch (_) {}
248
+ // Same scan as the index build, for the same reason: the only field
249
+ // needed here is short, and decoding the record to reach it makes
250
+ // compaction cost the size of the history it exists to bound.
251
+ final sid = _sessionIdOf(line);
252
+ if (sid != null) {
253
+ newIndex.putIfAbsent(sid, () => []).add(newLine);
254
+ }
235255 newLine++;
236256 }
237257
lib/widgets/message_bubble.dart
....@@ -468,8 +468,7 @@
468468 // Other files: save to temp and share
469469 final ext = _mimeToExt(mime);
470470 final dir = await getTemporaryDirectory();
471
- final fileName =
472
- '${message.content.isNotEmpty ? message.content.replaceAll(RegExp(r'[^\w\s.-]'), '').trim() : 'file'}.$ext';
471
+ final fileName = _fileNameFor(message.content, ext);
473472 final file = File('${dir.path}/$fileName');
474473 await file.writeAsBytes(bytes);
475474
....@@ -483,6 +482,23 @@
483482 ).showSnackBar(SnackBar(content: Text('Could not open file: $e')));
484483 }
485484 }
485
+ }
486
+
487
+ /// The name to save an incoming file under.
488
+ ///
489
+ /// The caption is normally the sender's own filename, so it already carries
490
+ /// an extension. Appending another unconditionally produced `clip.mp4.mp4` —
491
+ /// and where the caption had been reduced to a trailing dot, `clip..bin`,
492
+ /// which no player would open until it was renamed by hand.
493
+ ///
494
+ /// So: sanitise, drop trailing dots, and add the extension only when the name
495
+ /// does not already end in it.
496
+ String _fileNameFor(String caption, String ext) {
497
+ var base = caption.replaceAll(RegExp(r'[^\w\s.-]'), '').trim();
498
+ base = base.replaceAll(RegExp(r'\.+$'), '').trim();
499
+ if (base.isEmpty) base = 'file';
500
+ if (base.toLowerCase().endsWith('.${ext.toLowerCase()}')) return base;
501
+ return '$base.$ext';
486502 }
487503
488504 String _mimeToExt(String mime) {
....@@ -503,8 +519,33 @@
503519 'application/xml': 'xml',
504520 'application/zip': 'zip',
505521 'application/gzip': 'gz',
522
+ // Video and audio were absent, which is how an mp4 became a `.bin` even
523
+ // once the sender labelled it correctly. The hub's table is the source
524
+ // this mirrors.
525
+ 'video/mp4': 'mp4',
526
+ 'video/quicktime': 'mov',
527
+ 'video/x-msvideo': 'avi',
528
+ 'video/x-matroska': 'mkv',
529
+ 'audio/mpeg': 'mp3',
530
+ 'audio/wav': 'wav',
531
+ 'audio/ogg': 'ogg',
532
+ 'audio/mp4': 'm4a',
533
+ 'image/jpeg': 'jpg',
534
+ 'image/png': 'png',
535
+ 'image/gif': 'gif',
536
+ 'image/webp': 'webp',
537
+ 'image/svg+xml': 'svg',
506538 };
507
- return map[mime] ?? 'bin';
539
+ final normalised = mime.toLowerCase().split(';').first.trim();
540
+ final known = map[normalised];
541
+ if (known != null) return known;
542
+ // A type this table does not carry but whose subtype names itself:
543
+ // image/heic -> heic. Better than calling it `bin`.
544
+ final subtype = normalised.split('/').length > 1
545
+ ? normalised.split('/')[1]
546
+ : '';
547
+ if (RegExp(r'^[a-z0-9]{2,5}$').hasMatch(subtype)) return subtype;
548
+ return 'bin';
508549 }
509550
510551 Widget _buildFooter(BuildContext context) {
pubspec.yaml
....@@ -1,7 +1,7 @@
11 name: pailot
22 description: "Voice-first AI communicator"
33 publish_to: 'none'
4
-version: 1.1.0+2
4
+version: 1.1.2+4
55
66 environment:
77 sdk: ^3.11.1