From 1620f1fd16eb442f9fb7335927ee827b891fa4a1 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 16 Jul 2026 10:53:27 +0200 Subject: [PATCH 1/4] fix(mobile): Persist crash diagnostics before runtime fatals Co-authored-by: codex --- apps/mobile/index.ts | 4 + apps/mobile/src/lib/crashLog.ts | 80 +++++++++++++++++++ apps/mobile/src/lib/installCrashLog.ts | 3 + .../src/state/use-thread-outbox-drain.ts | 3 +- 4 files changed, 89 insertions(+), 1 deletion(-) create mode 100644 apps/mobile/src/lib/crashLog.ts create mode 100644 apps/mobile/src/lib/installCrashLog.ts diff --git a/apps/mobile/index.ts b/apps/mobile/index.ts index 979dc75d5f1..8add3abd386 100644 --- a/apps/mobile/index.ts +++ b/apps/mobile/index.ts @@ -1,3 +1,7 @@ +// Installed via a side-effect import listed first so the fatal-error handler +// is in place before the rest of the app module graph evaluates. +import "./src/lib/installCrashLog"; + import { registerRootComponent } from "expo"; import "react-native-gesture-handler"; import { featureFlags } from "react-native-screens"; diff --git a/apps/mobile/src/lib/crashLog.ts b/apps/mobile/src/lib/crashLog.ts new file mode 100644 index 00000000000..486b485b774 --- /dev/null +++ b/apps/mobile/src/lib/crashLog.ts @@ -0,0 +1,80 @@ +import { Directory, File, Paths } from "expo-file-system"; +import type { ErrorUtils as ErrorUtilsInterface } from "react-native"; + +const CRASH_LOG_DIRECTORY = "crash-logs"; +const MAX_PERSISTED_CRASH_LOGS = 20; +const REPORTED_ON_LAUNCH_COUNT = 3; + +let crashSequence = 0; + +export function installCrashLogger(): void { + const errorUtils = (globalThis as { ErrorUtils?: ErrorUtilsInterface }).ErrorUtils; + if (errorUtils === undefined) { + return; + } + const previousHandler = errorUtils.getGlobalHandler(); + errorUtils.setGlobalHandler((error: unknown, isFatal?: boolean) => { + if (isFatal === true) { + try { + persistCrashRecord(error); + } catch { + // Never let the logger mask the original error. + } + } + previousHandler(error, isFatal); + }); + // Deferred so install (which runs before the app module graph) does no + // file IO on the startup path. + setTimeout(() => { + reportAndPrunePreviousCrashes(); + }, 0); +} + +function persistCrashRecord(error: unknown): void { + const directory = new Directory(Paths.document, CRASH_LOG_DIRECTORY); + directory.create({ idempotent: true, intermediates: true }); + const cause = error instanceof Error ? error : null; + const record = { + capturedAt: new Date().toISOString(), + isFatal: true, + message: cause?.message ?? String(error), + name: cause?.name ?? null, + stack: cause?.stack ?? null, + }; + crashSequence += 1; + const file = new File(directory, `crash-${Date.now()}-${crashSequence}.json`); + file.create({ intermediates: true, overwrite: true }); + file.write(JSON.stringify(record, null, 2)); +} + +function reportAndPrunePreviousCrashes(): void { + try { + const directory = new Directory(Paths.document, CRASH_LOG_DIRECTORY); + if (!directory.exists) { + return; + } + const files = directory + .list() + .filter( + (entry): entry is File => + entry instanceof File && entry.name.startsWith("crash-") && entry.name.endsWith(".json"), + ) + .sort((a, b) => a.name.localeCompare(b.name)); + for (const file of files.slice(-REPORTED_ON_LAUNCH_COUNT)) { + try { + console.warn(`[crash-log] previous fatal JS error (${file.name}):`, file.textSync()); + } catch { + // Skip unreadable records. + } + } + for (const file of files.slice(0, Math.max(0, files.length - MAX_PERSISTED_CRASH_LOGS))) { + try { + file.delete(); + } catch { + // Leave undeletable records for the next prune. + } + } + } catch { + // Reporting past crashes must never affect startup. + } +} diff --git a/apps/mobile/src/lib/installCrashLog.ts b/apps/mobile/src/lib/installCrashLog.ts new file mode 100644 index 00000000000..ac3d1b76558 --- /dev/null +++ b/apps/mobile/src/lib/installCrashLog.ts @@ -0,0 +1,3 @@ +import { installCrashLogger } from "./crashLog"; + +installCrashLogger(); diff --git a/apps/mobile/src/state/use-thread-outbox-drain.ts b/apps/mobile/src/state/use-thread-outbox-drain.ts index f4de9c39492..bdeebd101de 100644 --- a/apps/mobile/src/state/use-thread-outbox-drain.ts +++ b/apps/mobile/src/state/use-thread-outbox-drain.ts @@ -15,6 +15,7 @@ import * as Cause from "effect/Cause"; import { AsyncResult, Atom } from "effect/unstable/reactivity"; import { useCallback, useEffect, useRef, useState } from "react"; +import { toUploadChatImageAttachments } from "../lib/composerImages"; import { scopedThreadKey } from "../lib/scopedEntities"; import { buildProjectThreadStartTurnInput } from "../lib/projectThreadStartTurn"; import { randomHex } from "../lib/uuid"; @@ -221,7 +222,7 @@ export function useThreadOutboxDrain(): void { messageId: queuedMessage.messageId, role: "user", text: queuedMessage.text, - attachments: queuedMessage.attachments, + attachments: toUploadChatImageAttachments(queuedMessage.attachments), }, modelSelection: settings.modelSelection, runtimeMode: settings.runtimeMode, From 959a39bf2526e5334b060f1b80f621ca82a874d1 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 16 Jul 2026 11:29:44 +0200 Subject: [PATCH 2/4] refactor(mobile): Keep outbox attachment conversion with composer uploads Co-authored-by: codex --- apps/mobile/src/state/use-thread-outbox-drain.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/apps/mobile/src/state/use-thread-outbox-drain.ts b/apps/mobile/src/state/use-thread-outbox-drain.ts index bdeebd101de..f4de9c39492 100644 --- a/apps/mobile/src/state/use-thread-outbox-drain.ts +++ b/apps/mobile/src/state/use-thread-outbox-drain.ts @@ -15,7 +15,6 @@ import * as Cause from "effect/Cause"; import { AsyncResult, Atom } from "effect/unstable/reactivity"; import { useCallback, useEffect, useRef, useState } from "react"; -import { toUploadChatImageAttachments } from "../lib/composerImages"; import { scopedThreadKey } from "../lib/scopedEntities"; import { buildProjectThreadStartTurnInput } from "../lib/projectThreadStartTurn"; import { randomHex } from "../lib/uuid"; @@ -222,7 +221,7 @@ export function useThreadOutboxDrain(): void { messageId: queuedMessage.messageId, role: "user", text: queuedMessage.text, - attachments: toUploadChatImageAttachments(queuedMessage.attachments), + attachments: queuedMessage.attachments, }, modelSelection: settings.modelSelection, runtimeMode: settings.runtimeMode, From af4ea049d393ed9b609d1ec1514d1afd9c8dc786 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 16 Jul 2026 11:39:06 +0200 Subject: [PATCH 3/4] fix(mobile): Preserve crash log capture ordering Co-authored-by: codex --- apps/mobile/src/lib/crashLog.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/apps/mobile/src/lib/crashLog.ts b/apps/mobile/src/lib/crashLog.ts index 486b485b774..e8686cde098 100644 --- a/apps/mobile/src/lib/crashLog.ts +++ b/apps/mobile/src/lib/crashLog.ts @@ -42,7 +42,10 @@ function persistCrashRecord(error: unknown): void { stack: cause?.stack ?? null, }; crashSequence += 1; - const file = new File(directory, `crash-${Date.now()}-${crashSequence}.json`); + // Keep lexical directory ordering aligned with capture order for records + // written within the same millisecond. + const sequence = String(crashSequence).padStart(6, "0"); + const file = new File(directory, `crash-${Date.now()}-${sequence}.json`); file.create({ intermediates: true, overwrite: true }); file.write(JSON.stringify(record, null, 2)); } From 6209d133ad9628bec61e7fce8b1742acfcfb9485 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 16 Jul 2026 11:45:28 +0200 Subject: [PATCH 4/4] fix(mobile): Safely serialize fatal errors Co-authored-by: codex --- apps/mobile/src/lib/crashLog.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/apps/mobile/src/lib/crashLog.ts b/apps/mobile/src/lib/crashLog.ts index e8686cde098..755ba1f723c 100644 --- a/apps/mobile/src/lib/crashLog.ts +++ b/apps/mobile/src/lib/crashLog.ts @@ -7,6 +7,14 @@ const REPORTED_ON_LAUNCH_COUNT = 3; let crashSequence = 0; +function safeErrorMessage(error: unknown): string { + try { + return String(error); + } catch { + return "[Uncoercible error]"; + } +} + export function installCrashLogger(): void { const errorUtils = (globalThis as { ErrorUtils?: ErrorUtilsInterface }).ErrorUtils; if (errorUtils === undefined) { @@ -37,7 +45,7 @@ function persistCrashRecord(error: unknown): void { const record = { capturedAt: new Date().toISOString(), isFatal: true, - message: cause?.message ?? String(error), + message: cause?.message ?? safeErrorMessage(error), name: cause?.name ?? null, stack: cause?.stack ?? null, };