fix(app): Absturzschleife — Auto-Re-Download holte ungefragt ein 150-MB-Video

Die Absturzschleife (alle ~70s, stundenlang) war selbsttragend:

  App startet → Auto-Re-Download fordert 2s spaeter ALLE fehlenden Anhaenge an,
  ohne Groessen-Grenze → Bridge schickt das 150-MB-Video als ~200 MB base64 in
  EINER WebSocket-Nachricht → die App muss das puffern und durch JSON.parse
  schicken → nativer OOM (kein JS-Fehler, darum nie ein Stacktrace) → Android
  startet den Vordergrund-Dienst neu → App startet → von vorn.

Dieselbe Nachricht hat parallel whisper/f5tts/voxtral mit Code 1009 umgebracht
— darum lag der Absturz immer direkt hinter dem Audio-Abbau, und darum kam
jede gerade laufende Sprachaufnahme nie an.

Drei Bremsen:
- Auto-Re-Download ueberspringt alles ueber 25 MB. Bleibt als "150 MB —
  tippen zum Laden" stehen; der User entscheidet.
- Ausdrueckliches Antippen bremst bei 64 MB mit einer ehrlichen Meldung samt
  Server-Pfad, statt die App zu killen.
- Der Brotkrumen wird von der Crash-Meldung nicht mehr ueberschrieben. Bisher
  merkte sich der zweite Absturz nur noch "letzte Aktivitaet: Crash-Meldung" —
  in einer Schleife war nach dem ersten Durchlauf jede Spur weg.

Diagnose kam aus /shared/logs/app.log (app_log via RVS), kein ADB noetig.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-10-02 01:48:27 +02:00
co-authored by Claude Opus 5
parent 73b6827f6b
commit b70623bb78
2 changed files with 80 additions and 14 deletions
+72 -13
View File
@@ -200,6 +200,28 @@ function formatDur(sec: number): string {
return `${m}:${r.toString().padStart(2, '0')}`;
}
// ─── Grenzen fuer den Anhang-Re-Download ────────────────────────────
//
// Ein Re-Download kommt als base64 in EINER WebSocket-Nachricht. Die muss die
// App komplett im Speicher puffern und dann durch JSON.parse schicken — bei
// einem 150-MB-Video sind das ~200 MB base64, und das Handy stirbt daran mit
// einem nativen OOM (kein JS-Fehler, darum tauchte nie ein Stacktrace auf).
//
// Genau das war die Absturzschleife: App startet → Auto-Re-Download fordert
// ungefragt das 150-MB-Video an → OOM → Android startet den Vordergrund-
// Dienst neu → App startet → von vorn. Alle ~70s, stundenlang.
//
// Darum zwei Grenzen:
// AUTO — was die App OHNE Nachfrage holt. Klein halten; Bilder und
// Dokumente passen rein, Videos nicht.
// TAP — was sie auf ausdruecklichen Tipp holt. Hoeher, aber immer noch
// weit unter der Schmerzgrenze. Drueber gibt's eine ehrliche
// Meldung statt eines Absturzes.
const AUTO_DL_MAX_BYTES = 25 * 1024 * 1024;
const TAP_DL_MAX_BYTES = 64 * 1024 * 1024;
const fmtMB = (n: number) => `${(n / 1024 / 1024).toFixed(0)} MB`;
const DEFAULT_ATTACHMENT_DIR = `${RNFS.DocumentDirectoryPath}/chat_attachments`;
const STORAGE_PATH_KEY = 'aria_attachment_storage_path';
@@ -425,6 +447,33 @@ const ChatScreen: React.FC = () => {
// Intent geoeffnet (PDF-Viewer, Galerie, etc.).
const autoOpenPaths = useRef<Set<string>>(new Set());
/**
* Re-Download anfordern — mit Groessen-Bremse.
* Gibt false zurueck (und sagt warum), wenn die Datei zu gross fuer einen
* Transfer durch die WebSocket ist. Ohne das holt sich die App ein 150-MB-
* Video als ~200 MB base64 und stirbt am nativen OOM (siehe TAP_DL_MAX_BYTES).
*/
const requestAttachment = useCallback((att: Attachment, reqId: string): boolean => {
if (!att.serverPath) {
if (Platform.OS === 'android') {
ToastAndroid.show('Datei kann nicht nachgeladen werden (kein serverPath)', ToastAndroid.LONG);
}
return false;
}
if (typeof att.size === 'number' && att.size > TAP_DL_MAX_BYTES) {
if (Platform.OS === 'android') {
ToastAndroid.show(
`${att.name}: ${fmtMB(att.size)} — zu gross fuers Handy. `
+ `Liegt auf dem Server unter ${att.serverPath}`,
ToastAndroid.LONG,
);
}
return false;
}
rvs.send('file_request' as any, { serverPath: att.serverPath, requestId: reqId });
return true;
}, []);
// Eindeutige Message-ID generieren
const nextId = (): string => {
messageIdCounter.current += 1;
@@ -860,13 +909,26 @@ const ChatScreen: React.FC = () => {
setTimeout(() => {
setMessages(prev => {
const missing: {id: string, serverPath: string}[] = [];
const skipped: string[] = [];
for (const msg of prev) {
for (const att of msg.attachments || []) {
if (att.serverPath && !att.uri) {
missing.push({ id: msg.id, serverPath: att.serverPath });
if (!att.serverPath || att.uri) continue;
// Grosse Dateien NICHT ungefragt holen (siehe AUTO_DL_MAX_BYTES).
// Sie bleiben als "tippen zum Laden" stehen — der User
// entscheidet, ob er sie wirklich aufs Handy zieht.
if (typeof att.size === 'number' && att.size > AUTO_DL_MAX_BYTES) {
skipped.push(`${att.name} (${fmtMB(att.size)})`);
continue;
}
missing.push({ id: msg.id, serverPath: att.serverPath });
}
}
if (skipped.length > 0) {
console.log('[Chat] Auto-Download uebersprungen (zu gross): %s', skipped.join(', '));
import('../services/logger')
.then(m => m.reportAppDebug('chat.autodl', `uebersprungen: ${skipped.join(', ')}`))
.catch(() => {});
}
if (missing.length > 0) {
console.log(`[Chat] ${missing.length} fehlende Anhaenge — lade nach...`);
for (const m of missing) {
@@ -2869,16 +2931,15 @@ const ChatScreen: React.FC = () => {
) : att.type === 'image' && !att.uri ? (
<TouchableOpacity
style={styles.attachmentFile}
onPress={() => {
if (att.serverPath) {
rvs.send('file_request' as any, { serverPath: att.serverPath, requestId: item.id });
}
}}
onPress={() => { requestAttachment(att, item.id); }}
>
<Text style={styles.attachmentFileIcon}>{'\uD83D\uDDBC\uFE0F'}</Text>
<Text style={styles.attachmentFileName} numberOfLines={1}>{att.name}</Text>
<Text style={styles.attachmentFileSize}>
{att.serverPath ? '(tippen zum Laden)' : '(nicht verfuegbar)'}
{!att.serverPath ? '(nicht verfuegbar)'
: typeof att.size === 'number' && att.size > AUTO_DL_MAX_BYTES
? `(${fmtMB(att.size)} — tippen zum Laden)`
: '(tippen zum Laden)'}
</Text>
</TouchableOpacity>
) : att.type === 'audio' ? (
@@ -2915,11 +2976,9 @@ const ChatScreen: React.FC = () => {
}
// Re-Download via file_request \u2192 bei file_response wird die
// Datei gespeichert UND geoeffnet (autoOpenPaths-Tracking).
if (att.serverPath) {
autoOpenPaths.current.add(att.serverPath);
rvs.send('file_request' as any, { serverPath: att.serverPath, requestId: item.id });
} else if (Platform.OS === 'android') {
ToastAndroid.show('Datei kann nicht nachgeladen werden (kein serverPath)', ToastAndroid.LONG);
if (att.serverPath) autoOpenPaths.current.add(att.serverPath);
if (!requestAttachment(att, item.id) && att.serverPath) {
autoOpenPaths.current.delete(att.serverPath);
}
}}
>
+8 -1
View File
@@ -49,8 +49,15 @@ const BREADCRUMB_KEY = 'aria_last_breadcrumb';
let _breadcrumb: { ts: number; scope: string; message: string } = { ts: 0, scope: '', message: '' };
let _breadcrumbDirty = false;
/** Letzte App-Aktivitaet merken — Crash-Kontext fuer den naechsten Boot. */
/** Letzte App-Aktivitaet merken — Crash-Kontext fuer den naechsten Boot.
*
* Die Crash-Meldung selbst darf den Brotkrumen NICHT ueberschreiben: sonst
* merkt sich der zweite Absturz nur noch "letzte Aktivitaet: Crash-Meldung",
* der dritte "letzte Aktivitaet: Crash-Meldung ueber Crash-Meldung" — und bei
* einer Absturzschleife ist nach dem ersten Durchlauf jede Spur weg. Genau
* das ist passiert; die eigentliche Ursache stand nur in der ERSTEN Meldung. */
export function noteBreadcrumb(scope: string, message: string): void {
if (scope === 'app.crash-detected') return;
_breadcrumb = { ts: Date.now(), scope: scope || '', message: String(message || '').slice(0, 120) };
_breadcrumbDirty = true;
}