From e286d19d8e9f7c819ff70f859a5c60c439db8e73 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 22 Sep 2026 16:00:30 -0700 Subject: [PATCH 1/5] fix(mobile): keep ordinary offline outbox failures out of console.warn While the device is offline or a socket drops mid-request, the thread outbox drain retried with backoff and printed a console.warn on every attempt, burying actionable warnings. Delivery and attachment-upload failures that resolve to a retry now go to a new filterable namespaced debug log (globalThis.__T3_DEBUG__ = true or ["thread-outbox"]); only failures the server decided, which restore the message with a user-visible error, stay on console.warn. The existing cloud and terminal debug logs move onto the same shared mechanism. Co-authored-by: Apex by Callstack --- .../src/features/cloud/cloudDebugLog.ts | 22 +++--- .../src/features/terminal/terminalDebugLog.ts | 27 ++++--- apps/mobile/src/lib/debugLog.test.ts | 70 +++++++++++++++++++ apps/mobile/src/lib/debugLog.ts | 65 +++++++++++++++++ .../src/state/use-thread-outbox-drain.test.ts | 55 ++++++++++++++- .../src/state/use-thread-outbox-drain.ts | 55 ++++++++++++--- 6 files changed, 254 insertions(+), 40 deletions(-) create mode 100644 apps/mobile/src/lib/debugLog.test.ts create mode 100644 apps/mobile/src/lib/debugLog.ts diff --git a/apps/mobile/src/features/cloud/cloudDebugLog.ts b/apps/mobile/src/features/cloud/cloudDebugLog.ts index 840a3db55680..7e57b39b7bb8 100644 --- a/apps/mobile/src/features/cloud/cloudDebugLog.ts +++ b/apps/mobile/src/features/cloud/cloudDebugLog.ts @@ -1,18 +1,14 @@ +import { createDebugLogger } from "../../lib/debugLog"; + +const logger = createDebugLogger("cloud", { + enabledInDev: true, + legacyGlobalFlag: "__T3_CLOUD_DEBUG__", +}); + export function isCloudDebugEnabled(): boolean { - return ( - (typeof __DEV__ !== "undefined" && __DEV__) || - (typeof globalThis !== "undefined" && - (globalThis as { __T3_CLOUD_DEBUG__?: boolean }).__T3_CLOUD_DEBUG__ === true) - ); + return logger.isEnabled(); } export function cloudDebugLog(event: string, data?: Record): void { - if (!isCloudDebugEnabled()) { - return; - } - if (data) { - console.log(`[t3-cloud] ${event}`, data); - } else { - console.log(`[t3-cloud] ${event}`); - } + logger.log(event, data); } diff --git a/apps/mobile/src/features/terminal/terminalDebugLog.ts b/apps/mobile/src/features/terminal/terminalDebugLog.ts index eb11419b330c..31c8497915c3 100644 --- a/apps/mobile/src/features/terminal/terminalDebugLog.ts +++ b/apps/mobile/src/features/terminal/terminalDebugLog.ts @@ -1,24 +1,21 @@ +import { createDebugLogger } from "../../lib/debugLog"; + /** * Debug logging for the mobile terminal pipeline. Prefix: `[t3-terminal]`. * - * Enabled when `__DEV__` is true, or set `globalThis.__T3_TERMINAL_DEBUG__ = true` in a JS - * debugger / Metro console to trace release/TestFlight builds. + * Enabled when `__DEV__` is true, or set `globalThis.__T3_TERMINAL_DEBUG__` + * (or the shared `globalThis.__T3_DEBUG__` filter) in a JS debugger / Metro + * console to trace release/TestFlight builds. */ +const logger = createDebugLogger("terminal", { + enabledInDev: true, + legacyGlobalFlag: "__T3_TERMINAL_DEBUG__", +}); + export function isTerminalDebugEnabled(): boolean { - return ( - (typeof __DEV__ !== "undefined" && __DEV__) || - (typeof globalThis !== "undefined" && - (globalThis as { __T3_TERMINAL_DEBUG__?: boolean }).__T3_TERMINAL_DEBUG__ === true) - ); + return logger.isEnabled(); } export function terminalDebugLog(message: string, data?: Record): void { - if (!isTerminalDebugEnabled()) { - return; - } - if (data !== undefined) { - console.log(`[t3-terminal] ${message}`, data); - } else { - console.log(`[t3-terminal] ${message}`); - } + logger.log(message, data); } diff --git a/apps/mobile/src/lib/debugLog.test.ts b/apps/mobile/src/lib/debugLog.test.ts new file mode 100644 index 000000000000..1b0f1bbe1785 --- /dev/null +++ b/apps/mobile/src/lib/debugLog.test.ts @@ -0,0 +1,70 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +import { createDebugLogger } from "./debugLog"; + +describe("createDebugLogger", () => { + let logSpy: ReturnType; + + beforeEach(() => { + vi.stubGlobal("__DEV__", false); + vi.stubGlobal("__T3_DEBUG__", undefined); + vi.stubGlobal("__T3_CLOUD_DEBUG__", undefined); + logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + logSpy.mockRestore(); + }); + + function setDebugFilter(filter: unknown): void { + vi.stubGlobal("__T3_DEBUG__", filter); + } + + it("is silent by default, including in development builds", () => { + vi.stubGlobal("__DEV__", true); + const trace = createDebugLogger("thread-outbox"); + trace.log("queued message delivery failed"); + expect(trace.isEnabled()).toBe(false); + expect(logSpy).not.toHaveBeenCalled(); + }); + + it("logs with the [t3-] prefix when the global filter is true", () => { + setDebugFilter(true); + const trace = createDebugLogger("thread-outbox"); + trace.log("attachment upload failed", { messageId: "m1" }); + expect(trace.isEnabled()).toBe(true); + expect(logSpy).toHaveBeenCalledWith("[t3-thread-outbox] attachment upload failed", { + messageId: "m1", + }); + }); + + it("filters per namespace when the global filter is a list", () => { + setDebugFilter(["cloud"]); + createDebugLogger("thread-outbox").log("delivery failed"); + createDebugLogger("cloud").log("relay connected"); + expect(logSpy.mock.calls).toEqual([["[t3-cloud] relay connected"]]); + }); + + it("logs without data as a single prefixed line", () => { + setDebugFilter(true); + createDebugLogger("thread-outbox").log("delivery failed"); + expect(logSpy).toHaveBeenCalledWith("[t3-thread-outbox] delivery failed"); + }); + + it("honors enabledInDev with __DEV__", () => { + vi.stubGlobal("__DEV__", true); + const trace = createDebugLogger("terminal", { enabledInDev: true }); + trace.log("buffer replay"); + expect(trace.isEnabled()).toBe(true); + expect(logSpy).toHaveBeenCalledWith("[t3-terminal] buffer replay"); + }); + + it("honors a legacy subsystem-specific global flag", () => { + vi.stubGlobal("__T3_CLOUD_DEBUG__", true); + const trace = createDebugLogger("cloud", { legacyGlobalFlag: "__T3_CLOUD_DEBUG__" }); + trace.log("relay state changed"); + expect(trace.isEnabled()).toBe(true); + expect(logSpy).toHaveBeenCalledWith("[t3-cloud] relay state changed"); + }); +}); diff --git a/apps/mobile/src/lib/debugLog.ts b/apps/mobile/src/lib/debugLog.ts new file mode 100644 index 000000000000..a1ffe2e59358 --- /dev/null +++ b/apps/mobile/src/lib/debugLog.ts @@ -0,0 +1,65 @@ +/** + * Namespaced, filterable debug logging shared by mobile subsystems. + * + * Ordinary, expected conditions — a queued send failing while the device is + * offline, for example — go through a debug logger instead of `console.warn` + * so warning output stays reserved for failures someone can act on. Output + * uses `console.log` with a `[t3-]` prefix, matching the existing + * cloud and terminal debug logs. (client-runtime cannot host this: its + * tooling bans `console.*` in favor of Effect logging.) + * + * A logger is silent in every build, including development, unless enabled. + * Toggle it from a JS debugger or the Metro console, including on release/TestFlight builds: + * - `globalThis.__T3_DEBUG__ = true` enables every namespace; + * - `globalThis.__T3_DEBUG__ = ["thread-outbox"]` enables only listed ones. + * + * Subsystems whose traces are useful by default in development (`__DEV__`) + * opt in with `enabledInDev`; `legacyGlobalFlag` keeps an older + * subsystem-specific global (e.g. `__T3_CLOUD_DEBUG__`) working. + */ + +export interface DebugLogger { + readonly namespace: string; + readonly isEnabled: () => boolean; + readonly log: (event: string, data?: Record) => void; +} + +export interface DebugLoggerOptions { + /** Log whenever `__DEV__` is true, without the global filter. Defaults to false. */ + readonly enabledInDev?: boolean; + /** Name of a legacy subsystem-specific global boolean, e.g. `"__T3_CLOUD_DEBUG__"`. */ + readonly legacyGlobalFlag?: string; +} + +function globalValue(name: string): unknown { + return typeof globalThis === "undefined" + ? undefined + : (globalThis as Record)[name]; +} + +export function createDebugLogger( + namespace: string, + options: DebugLoggerOptions = {}, +): DebugLogger { + const isEnabled = () => { + if (options.enabledInDev === true && typeof __DEV__ !== "undefined" && __DEV__) { + return true; + } + if (options.legacyGlobalFlag !== undefined && globalValue(options.legacyGlobalFlag) === true) { + return true; + } + const filter = globalValue("__T3_DEBUG__"); + return filter === true || (Array.isArray(filter) && filter.includes(namespace)); + }; + const log = (event: string, data?: Record) => { + if (!isEnabled()) { + return; + } + if (data === undefined) { + console.log(`[t3-${namespace}] ${event}`); + } else { + console.log(`[t3-${namespace}] ${event}`, data); + } + }; + return { namespace, isEnabled, log }; +} diff --git a/apps/mobile/src/state/use-thread-outbox-drain.test.ts b/apps/mobile/src/state/use-thread-outbox-drain.test.ts index 67b340f42042..bcfc6060bcb4 100644 --- a/apps/mobile/src/state/use-thread-outbox-drain.test.ts +++ b/apps/mobile/src/state/use-thread-outbox-drain.test.ts @@ -144,6 +144,7 @@ import { recoverFailedThreadDraft } from "./recover-failed-thread-draft"; import { editingQueuedMessageIdsAtom } from "./use-thread-outbox"; import { completeQueuedMessageDelivery, + logThreadOutboxDeliveryFailure, prepareQueuedMessageAttachments, recoverEditedCreationAfterDelivery, removeAcknowledgedExistingThreadMessage, @@ -426,7 +427,16 @@ describe("thread outbox drain delivery cleanup", () => { const edited = { ...message, text: "edited while the turn delivered" }; await harness.manager.update(edited); - await expect(completeQueuedMessageDelivery(message, deliveryRevision)).resolves.toBe("edited"); + // Losing the cleanup race to an edit is expected; it must not warn. + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + let outcome: "edited" | "failed" | "removed"; + try { + outcome = await completeQueuedMessageDelivery(message, deliveryRevision); + } finally { + warn.mockRestore(); + } + expect(outcome).toBe("edited"); + expect(warn).not.toHaveBeenCalled(); expect(remainingMessages()).toEqual([edited]); expect(harness.removePersistedFile).not.toHaveBeenCalled(); @@ -763,3 +773,46 @@ describe("thread outbox recovery rollback", () => { expect(harness.setPendingConnectionError).toHaveBeenCalledWith("too large"); }); }); + +describe("thread outbox failure logging", () => { + it("keeps ordinary transport retries out of console.warn and silent by default", () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + const log = vi.spyOn(console, "log").mockImplementation(() => {}); + try { + logThreadOutboxDeliveryFailure("retry", { messageId: "m1" }); + expect(warn).not.toHaveBeenCalled(); + expect(log).not.toHaveBeenCalled(); + } finally { + warn.mockRestore(); + log.mockRestore(); + } + }); + + it("surfaces retry details when the thread-outbox debug filter is enabled", () => { + vi.stubGlobal("__T3_DEBUG__", ["thread-outbox"]); + const log = vi.spyOn(console, "log").mockImplementation(() => {}); + try { + logThreadOutboxDeliveryFailure("retry", { messageId: "m1" }); + expect(log).toHaveBeenCalledWith( + "[t3-thread-outbox] queued message delivery failed", + expect.objectContaining({ messageId: "m1" }), + ); + } finally { + log.mockRestore(); + vi.unstubAllGlobals(); + } + }); + + it("still warns when the server decided the message must be restored", () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + try { + logThreadOutboxDeliveryFailure("restore", { messageId: "m2" }); + expect(warn).toHaveBeenCalledWith( + "[thread-outbox] queued message delivery failed", + expect.objectContaining({ messageId: "m2" }), + ); + } finally { + warn.mockRestore(); + } + }); +}); diff --git a/apps/mobile/src/state/use-thread-outbox-drain.ts b/apps/mobile/src/state/use-thread-outbox-drain.ts index 1147e9fd47af..f1a7b9466e60 100644 --- a/apps/mobile/src/state/use-thread-outbox-drain.ts +++ b/apps/mobile/src/state/use-thread-outbox-drain.ts @@ -17,6 +17,7 @@ import { AsyncResult } from "effect/unstable/reactivity"; import { useCallback, useEffect, useRef, useState } from "react"; import { Alert } from "react-native"; +import { createDebugLogger } from "../lib/debugLog"; import { scopedThreadKey } from "../lib/scopedEntities"; import { buildProjectThreadStartTurnInput } from "../lib/projectThreadStartTurn"; import { serializeComposerMessageForServer, uploadedComposerContext } from "../lib/composerContext"; @@ -55,6 +56,7 @@ import { type QueuedThreadCreation, type QueuedThreadMessage, type ThreadOutboxCommandStage, + type ThreadOutboxFailureAction, } from "./thread-outbox-model"; import { environmentThreadShells, threadEnvironment } from "./threads"; import { @@ -82,6 +84,38 @@ import { useRemoteConnectionStatus, } from "./use-remote-environment-registry"; +// Ordinary offline behavior (a socket dropping mid-request, a retryable +// attachment upload failure) must not spam `console.warn` on every backoff +// retry; it goes to the filterable `[t3-thread-outbox]` debug log instead. +// Failures the server decided — the ones that restore the message with a +// user-visible error — stay on `console.warn`. +const threadOutboxDebug = createDebugLogger("thread-outbox"); + +/** + * Routes one queued-message delivery failure by outcome: ordinary transport + * retries to the debug log, server-decided restores to `console.warn`. + * Exported for tests. + */ +export function logThreadOutboxDeliveryFailure( + action: ThreadOutboxFailureAction, + details: Record, +): void { + if (action === "restore") { + console.warn("[thread-outbox] queued message delivery failed", details); + return; + } + threadOutboxDebug.log("queued message delivery failed", details); +} + +/** Attachment uploads retry like delivery: transport failures are ordinary offline noise. */ +function logThreadOutboxUploadFailure(error: unknown): void { + if (shouldRetryThreadOutboxDelivery(error)) { + threadOutboxDebug.log("attachment upload failed; retrying", { error }); + } else { + console.warn("[thread-outbox] failed to upload attachments", error); + } +} + function beginDispatchingQueuedMessage(queuedMessageId: MessageId): void { appAtomRegistry.set(dispatchingQueuedMessageIdAtom, queuedMessageId); } @@ -215,14 +249,13 @@ export async function completeQueuedMessageDelivery( ); if (!removed) { forgetAcknowledgedThreadMessage(queuedMessage); - console.warn( - "[thread-outbox] delivered message was edited before cleanup; keeping the newer message", - { - environmentId: queuedMessage.environmentId, - threadId: queuedMessage.threadId, - messageId: queuedMessage.messageId, - }, - ); + // Losing the cleanup race to a user edit is an expected outcome the + // caller handles by keeping the newer message; it is not a warning. + threadOutboxDebug.log("delivered message was edited before cleanup", { + environmentId: queuedMessage.environmentId, + threadId: queuedMessage.threadId, + messageId: queuedMessage.messageId, + }); return "edited"; } return "removed"; @@ -672,7 +705,7 @@ export function useThreadOutboxDrain(): void { error, interrupted: Cause.hasInterruptsOnly(commandResult.cause), }); - console.warn("[thread-outbox] queued message delivery failed", { + logThreadOutboxDeliveryFailure(action, { environmentId: queuedMessage.environmentId, threadId: queuedMessage.threadId, messageId: queuedMessage.messageId, @@ -772,7 +805,7 @@ export function useThreadOutboxDrain(): void { return true; } } catch (error) { - console.warn("[thread-outbox] failed to upload attachments", error); + logThreadOutboxUploadFailure(error); if (!shouldRetryThreadOutboxDelivery(error)) { return restoreQueuedMessage( queuedMessage, @@ -900,7 +933,7 @@ export function useThreadOutboxDrain(): void { return true; } } catch (error) { - console.warn("[thread-outbox] failed to upload attachments", error); + logThreadOutboxUploadFailure(error); if (!shouldRetryThreadOutboxDelivery(error)) { return restoreQueuedMessage( queuedMessage, From 62eed73c6bdb8f516a8f121454b235e21ee37c30 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 22 Sep 2026 16:15:32 -0700 Subject: [PATCH 2/5] fix(mobile): warn on nontransport settings-sync outbox failures resolveThreadOutboxFailureAction always resolves settings-sync failures to a retry, even when the server rejected the command, so routing every retry to the debug log could hide a permanently rejected update forever. The delivery-failure logger now takes the raw failure (stage, error, interrupted), decides the log level from the error itself, and returns the retry-or-restore action for the caller. Tests drive the real classification with transport-tagged and server-decided tagged errors instead of a predetermined action argument, and cover the upload-retry logging path. Co-authored-by: Apex by Callstack --- apps/mobile/src/lib/debugLog.ts | 3 +- .../src/state/use-thread-outbox-drain.test.ts | 106 ++++++++++++++++-- .../src/state/use-thread-outbox-drain.ts | 60 ++++++---- 3 files changed, 134 insertions(+), 35 deletions(-) diff --git a/apps/mobile/src/lib/debugLog.ts b/apps/mobile/src/lib/debugLog.ts index a1ffe2e59358..447973503333 100644 --- a/apps/mobile/src/lib/debugLog.ts +++ b/apps/mobile/src/lib/debugLog.ts @@ -19,7 +19,6 @@ */ export interface DebugLogger { - readonly namespace: string; readonly isEnabled: () => boolean; readonly log: (event: string, data?: Record) => void; } @@ -61,5 +60,5 @@ export function createDebugLogger( console.log(`[t3-${namespace}] ${event}`, data); } }; - return { namespace, isEnabled, log }; + return { isEnabled, log }; } diff --git a/apps/mobile/src/state/use-thread-outbox-drain.test.ts b/apps/mobile/src/state/use-thread-outbox-drain.test.ts index bcfc6060bcb4..4ab15b151214 100644 --- a/apps/mobile/src/state/use-thread-outbox-drain.test.ts +++ b/apps/mobile/src/state/use-thread-outbox-drain.test.ts @@ -145,6 +145,7 @@ import { editingQueuedMessageIdsAtom } from "./use-thread-outbox"; import { completeQueuedMessageDelivery, logThreadOutboxDeliveryFailure, + logThreadOutboxUploadFailure, prepareQueuedMessageAttachments, recoverEditedCreationAfterDelivery, removeAcknowledgedExistingThreadMessage, @@ -775,27 +776,58 @@ describe("thread outbox recovery rollback", () => { }); describe("thread outbox failure logging", () => { - it("keeps ordinary transport retries out of console.warn and silent by default", () => { + // Tagged errors match shouldRetryThreadOutboxDelivery's transport tags, so + // the assertions cover the real failure classification, not a + // predetermined action argument. + const transportError = { _tag: "RpcClientError" }; + const serverDecidedError = { _tag: "OrchestrationDispatchCommandError" }; + + function failureLogging(input: Parameters[0]): { + readonly action: string; + readonly warnCalls: ReadonlyArray; + readonly logCalls: ReadonlyArray; + } { const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); const log = vi.spyOn(console, "log").mockImplementation(() => {}); try { - logThreadOutboxDeliveryFailure("retry", { messageId: "m1" }); - expect(warn).not.toHaveBeenCalled(); - expect(log).not.toHaveBeenCalled(); + return { + action: logThreadOutboxDeliveryFailure(input), + warnCalls: [...warn.mock.calls], + logCalls: [...log.mock.calls], + }; } finally { warn.mockRestore(); log.mockRestore(); } + } + + it("keeps offline transport retries silent by default at both stages", () => { + for (const stage of ["settings-sync", "start-turn"] as const) { + const { action, warnCalls, logCalls } = failureLogging({ + stage, + error: transportError, + interrupted: false, + context: { messageId: "m1" }, + }); + expect(action).toBe("retry"); + expect(warnCalls).toEqual([]); + expect(logCalls).toEqual([]); + } }); - it("surfaces retry details when the thread-outbox debug filter is enabled", () => { + it("surfaces transport retry details when the thread-outbox debug filter is enabled", () => { vi.stubGlobal("__T3_DEBUG__", ["thread-outbox"]); const log = vi.spyOn(console, "log").mockImplementation(() => {}); try { - logThreadOutboxDeliveryFailure("retry", { messageId: "m1" }); + logThreadOutboxDeliveryFailure({ + stage: "start-turn", + error: transportError, + interrupted: false, + context: { messageId: "m1" }, + }); expect(log).toHaveBeenCalledWith( "[t3-thread-outbox] queued message delivery failed", - expect.objectContaining({ messageId: "m1" }), + expect.objectContaining({ messageId: "m1", action: "retry" }), ); } finally { log.mockRestore(); @@ -803,16 +835,68 @@ describe("thread outbox failure logging", () => { } }); - it("still warns when the server decided the message must be restored", () => { + it("warns when the server decided the message must be restored", () => { + const { action, warnCalls } = failureLogging({ + stage: "start-turn", + error: serverDecidedError, + interrupted: false, + context: { messageId: "m2" }, + }); + expect(action).toBe("restore"); + expect(warnCalls).toEqual([ + [ + "[thread-outbox] queued message delivery failed", + expect.objectContaining({ messageId: "m2", action: "restore" }), + ], + ]); + }); + + it("warns on a nontransport settings-sync failure even though it resolves to a retry", () => { + const { action, warnCalls } = failureLogging({ + stage: "settings-sync", + error: serverDecidedError, + interrupted: false, + context: { messageId: "m3" }, + }); + expect(action).toBe("retry"); + expect(warnCalls).toEqual([ + [ + "[thread-outbox] queued message delivery failed", + expect.objectContaining({ stage: "settings-sync", action: "retry" }), + ], + ]); + }); + + it("routes retryable attachment-upload failures to the debug log and warns on the rest", () => { const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + const log = vi.spyOn(console, "log").mockImplementation(() => {}); try { - logThreadOutboxDeliveryFailure("restore", { messageId: "m2" }); + logThreadOutboxUploadFailure(transportError); + expect(warn).not.toHaveBeenCalled(); + expect(log).not.toHaveBeenCalled(); + + logThreadOutboxUploadFailure(serverDecidedError); expect(warn).toHaveBeenCalledWith( - "[thread-outbox] queued message delivery failed", - expect.objectContaining({ messageId: "m2" }), + "[thread-outbox] failed to upload attachments", + serverDecidedError, ); } finally { warn.mockRestore(); + log.mockRestore(); + } + }); + + it("logs retryable attachment-upload failures when the debug filter is enabled", () => { + vi.stubGlobal("__T3_DEBUG__", ["thread-outbox"]); + const log = vi.spyOn(console, "log").mockImplementation(() => {}); + try { + logThreadOutboxUploadFailure(transportError); + expect(log).toHaveBeenCalledWith("[t3-thread-outbox] attachment upload failed; retrying", { + error: transportError, + }); + } finally { + log.mockRestore(); + vi.unstubAllGlobals(); } }); }); diff --git a/apps/mobile/src/state/use-thread-outbox-drain.ts b/apps/mobile/src/state/use-thread-outbox-drain.ts index f1a7b9466e60..a7eb88eb0a07 100644 --- a/apps/mobile/src/state/use-thread-outbox-drain.ts +++ b/apps/mobile/src/state/use-thread-outbox-drain.ts @@ -87,28 +87,46 @@ import { // Ordinary offline behavior (a socket dropping mid-request, a retryable // attachment upload failure) must not spam `console.warn` on every backoff // retry; it goes to the filterable `[t3-thread-outbox]` debug log instead. -// Failures the server decided — the ones that restore the message with a -// user-visible error — stay on `console.warn`. +// Failures the server decided stay on `console.warn`. const threadOutboxDebug = createDebugLogger("thread-outbox"); /** - * Routes one queued-message delivery failure by outcome: ordinary transport - * retries to the debug log, server-decided restores to `console.warn`. + * Logs one queued-message delivery failure and returns the retry-or-restore + * decision for the caller. Ordinary transport retries — what an offline + * device or a flapping socket produces on every backoff attempt — go to the + * debug log. Server-decided failures warn. Settings-sync failures always + * resolve to a retry even when the server rejected the command, so the + * error, not the resolved action, must decide the log level there; routing + * every retry to debug could hide a permanently rejected update forever. * Exported for tests. */ -export function logThreadOutboxDeliveryFailure( - action: ThreadOutboxFailureAction, - details: Record, -): void { - if (action === "restore") { +export function logThreadOutboxDeliveryFailure(input: { + readonly stage: ThreadOutboxCommandStage; + readonly error: unknown; + readonly interrupted: boolean; + readonly context: Record; +}): ThreadOutboxFailureAction { + const action = resolveThreadOutboxFailureAction({ + stage: input.stage, + error: input.error, + interrupted: input.interrupted, + }); + const details = { ...input.context, stage: input.stage, action }; + const ordinaryTransportRetry = + action === "retry" && + (input.interrupted || + input.stage !== "settings-sync" || + shouldRetryThreadOutboxDelivery(input.error)); + if (ordinaryTransportRetry) { + threadOutboxDebug.log("queued message delivery failed", details); + } else { console.warn("[thread-outbox] queued message delivery failed", details); - return; } - threadOutboxDebug.log("queued message delivery failed", details); + return action; } -/** Attachment uploads retry like delivery: transport failures are ordinary offline noise. */ -function logThreadOutboxUploadFailure(error: unknown): void { +/** Attachment uploads retry like delivery: transport failures are ordinary offline noise. Exported for tests. */ +export function logThreadOutboxUploadFailure(error: unknown): void { if (shouldRetryThreadOutboxDelivery(error)) { threadOutboxDebug.log("attachment upload failed; retrying", { error }); } else { @@ -700,18 +718,16 @@ export function useThreadOutboxDrain(): void { return null; } const error = Cause.squash(commandResult.cause); - const action = resolveThreadOutboxFailureAction({ + const action = logThreadOutboxDeliveryFailure({ stage, error, interrupted: Cause.hasInterruptsOnly(commandResult.cause), - }); - logThreadOutboxDeliveryFailure(action, { - environmentId: queuedMessage.environmentId, - threadId: queuedMessage.threadId, - messageId: queuedMessage.messageId, - stage, - cause: commandResult.cause, - action, + context: { + environmentId: queuedMessage.environmentId, + threadId: queuedMessage.threadId, + messageId: queuedMessage.messageId, + cause: commandResult.cause, + }, }); return { action, From 34b639495dba1eb5e9a4a512da5f589cc3256a15 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 22 Sep 2026 16:37:03 -0700 Subject: [PATCH 3/5] fix(mobile): keep rpc decode defects warning and tag upload logs CodeRabbit review: RpcClientError also wraps client-side response-decoding defects, which retry but are not ordinary offline behavior; those stay on console.warn. Upload failure logging (both levels) now carries environment/thread/message identifiers, and the revision-race test asserts before restoring the spy. Co-authored-by: Apex by Callstack --- .../src/state/use-thread-outbox-drain.test.ts | 55 ++++++++++++++----- .../src/state/use-thread-outbox-drain.ts | 48 ++++++++++++++-- 2 files changed, 84 insertions(+), 19 deletions(-) diff --git a/apps/mobile/src/state/use-thread-outbox-drain.test.ts b/apps/mobile/src/state/use-thread-outbox-drain.test.ts index 4ab15b151214..393d695538c8 100644 --- a/apps/mobile/src/state/use-thread-outbox-drain.test.ts +++ b/apps/mobile/src/state/use-thread-outbox-drain.test.ts @@ -431,13 +431,15 @@ describe("thread outbox drain delivery cleanup", () => { // Losing the cleanup race to an edit is expected; it must not warn. const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); let outcome: "edited" | "failed" | "removed"; + let warnCalls: ReadonlyArray; try { outcome = await completeQueuedMessageDelivery(message, deliveryRevision); + warnCalls = [...warn.mock.calls]; } finally { warn.mockRestore(); } expect(outcome).toBe("edited"); - expect(warn).not.toHaveBeenCalled(); + expect(warnCalls).toEqual([]); expect(remainingMessages()).toEqual([edited]); expect(harness.removePersistedFile).not.toHaveBeenCalled(); @@ -779,7 +781,8 @@ describe("thread outbox failure logging", () => { // Tagged errors match shouldRetryThreadOutboxDelivery's transport tags, so // the assertions cover the real failure classification, not a // predetermined action argument. - const transportError = { _tag: "RpcClientError" }; + const transportError = { _tag: "RpcClientError", reason: { _tag: "SocketClose" } }; + const decodeDefectError = { _tag: "RpcClientError", reason: { _tag: "RpcClientDefect" } }; const serverDecidedError = { _tag: "OrchestrationDispatchCommandError" }; function failureLogging(input: Parameters[0]): { @@ -867,33 +870,59 @@ describe("thread outbox failure logging", () => { ]); }); + it("warns on an rpc response-decoding defect even though it resolves to a retry", () => { + const { action, warnCalls } = failureLogging({ + stage: "start-turn", + error: decodeDefectError, + interrupted: false, + context: { messageId: "m4" }, + }); + expect(action).toBe("retry"); + expect(warnCalls).toEqual([ + [ + "[thread-outbox] queued message delivery failed", + expect.objectContaining({ stage: "start-turn", action: "retry" }), + ], + ]); + }); + it("routes retryable attachment-upload failures to the debug log and warns on the rest", () => { + const message = queuedMessage({ messageId: "m-upload", text: "with file" }); const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); const log = vi.spyOn(console, "log").mockImplementation(() => {}); try { - logThreadOutboxUploadFailure(transportError); + logThreadOutboxUploadFailure(message, transportError); expect(warn).not.toHaveBeenCalled(); expect(log).not.toHaveBeenCalled(); - logThreadOutboxUploadFailure(serverDecidedError); - expect(warn).toHaveBeenCalledWith( - "[thread-outbox] failed to upload attachments", - serverDecidedError, - ); + logThreadOutboxUploadFailure(message, serverDecidedError); + expect(warn).toHaveBeenCalledWith("[thread-outbox] failed to upload attachments", { + environmentId: message.environmentId, + threadId: message.threadId, + messageId: "m-upload", + error: serverDecidedError, + }); } finally { warn.mockRestore(); log.mockRestore(); } }); - it("logs retryable attachment-upload failures when the debug filter is enabled", () => { + it("logs retryable attachment-upload failures with message identifiers when the debug filter is enabled", () => { vi.stubGlobal("__T3_DEBUG__", ["thread-outbox"]); + const message = queuedMessage({ messageId: "m-upload", text: "with file" }); const log = vi.spyOn(console, "log").mockImplementation(() => {}); try { - logThreadOutboxUploadFailure(transportError); - expect(log).toHaveBeenCalledWith("[t3-thread-outbox] attachment upload failed; retrying", { - error: transportError, - }); + logThreadOutboxUploadFailure(message, transportError); + expect(log).toHaveBeenCalledWith( + "[t3-thread-outbox] attachment upload failed; retrying", + expect.objectContaining({ + messageId: "m-upload", + environmentId: message.environmentId, + threadId: message.threadId, + error: transportError, + }), + ); } finally { log.mockRestore(); vi.unstubAllGlobals(); diff --git a/apps/mobile/src/state/use-thread-outbox-drain.ts b/apps/mobile/src/state/use-thread-outbox-drain.ts index a7eb88eb0a07..0461536885ca 100644 --- a/apps/mobile/src/state/use-thread-outbox-drain.ts +++ b/apps/mobile/src/state/use-thread-outbox-drain.ts @@ -90,6 +90,33 @@ import { // Failures the server decided stay on `console.warn`. const threadOutboxDebug = createDebugLogger("thread-outbox"); +/** + * The RPC transport tags also cover client-side response-decoding defects — + * abnormal server responses, not offline behavior. Those still retry, but + * they must not hide behind the ordinary-offline debug log. + */ +function isRpcClientDecodeDefect(error: unknown): boolean { + if ( + typeof error !== "object" || + error === null || + !("_tag" in error) || + error._tag !== "RpcClientError" + ) { + return false; + } + const reason: unknown = (error as { readonly reason?: unknown }).reason; + return ( + typeof reason === "object" && + reason !== null && + "_tag" in reason && + reason._tag === "RpcClientDefect" + ); +} + +function isOrdinaryThreadOutboxTransportFailure(error: unknown): boolean { + return shouldRetryThreadOutboxDelivery(error) && !isRpcClientDecodeDefect(error); +} + /** * Logs one queued-message delivery failure and returns the retry-or-restore * decision for the caller. Ordinary transport retries — what an offline @@ -114,6 +141,7 @@ export function logThreadOutboxDeliveryFailure(input: { const details = { ...input.context, stage: input.stage, action }; const ordinaryTransportRetry = action === "retry" && + !isRpcClientDecodeDefect(input.error) && (input.interrupted || input.stage !== "settings-sync" || shouldRetryThreadOutboxDelivery(input.error)); @@ -126,11 +154,19 @@ export function logThreadOutboxDeliveryFailure(input: { } /** Attachment uploads retry like delivery: transport failures are ordinary offline noise. Exported for tests. */ -export function logThreadOutboxUploadFailure(error: unknown): void { - if (shouldRetryThreadOutboxDelivery(error)) { - threadOutboxDebug.log("attachment upload failed; retrying", { error }); +export function logThreadOutboxUploadFailure( + queuedMessage: QueuedThreadMessage, + error: unknown, +): void { + const context = { + environmentId: queuedMessage.environmentId, + threadId: queuedMessage.threadId, + messageId: queuedMessage.messageId, + }; + if (isOrdinaryThreadOutboxTransportFailure(error)) { + threadOutboxDebug.log("attachment upload failed; retrying", { ...context, error }); } else { - console.warn("[thread-outbox] failed to upload attachments", error); + console.warn("[thread-outbox] failed to upload attachments", { ...context, error }); } } @@ -821,7 +857,7 @@ export function useThreadOutboxDrain(): void { return true; } } catch (error) { - logThreadOutboxUploadFailure(error); + logThreadOutboxUploadFailure(queuedMessage, error); if (!shouldRetryThreadOutboxDelivery(error)) { return restoreQueuedMessage( queuedMessage, @@ -949,7 +985,7 @@ export function useThreadOutboxDrain(): void { return true; } } catch (error) { - logThreadOutboxUploadFailure(error); + logThreadOutboxUploadFailure(queuedMessage, error); if (!shouldRetryThreadOutboxDelivery(error)) { return restoreQueuedMessage( queuedMessage, From df3bae3d232280795910c7af635ceee1ae35b8a1 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 22 Sep 2026 16:47:59 -0700 Subject: [PATCH 4/5] docs(mobile): scope the rpc defect classifier comment to the request path Effect's RpcClientDefect tag is also reused by the shared config-subscription stream to re-wrap transport causes. On the queued-request path that drives this logger, the RPC client reports ordinary drops as raw socket/worker reason tags and reserves the defect tag for protocol/decode failures, so the tag check is right here and only the comment was overstated. Co-authored-by: Apex by Callstack --- apps/mobile/src/state/use-thread-outbox-drain.ts | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/apps/mobile/src/state/use-thread-outbox-drain.ts b/apps/mobile/src/state/use-thread-outbox-drain.ts index 0461536885ca..bd24a4e35241 100644 --- a/apps/mobile/src/state/use-thread-outbox-drain.ts +++ b/apps/mobile/src/state/use-thread-outbox-drain.ts @@ -91,9 +91,13 @@ import { const threadOutboxDebug = createDebugLogger("thread-outbox"); /** - * The RPC transport tags also cover client-side response-decoding defects — - * abnormal server responses, not offline behavior. Those still retry, but - * they must not hide behind the ordinary-offline debug log. + * On the queued-request path (settings sync, startTurn) the RPC client + * reports ordinary transport drops as the raw socket/worker reason tags, and + * reserves `RpcClientDefect` for client-side protocol violations and decoding + * failures — unlike the shared config-subscription stream, which + * deliberately re-wraps transport causes under that tag. Defects still retry, + * but they are not ordinary offline behavior and must not hide behind the + * offline debug log. */ function isRpcClientDecodeDefect(error: unknown): boolean { if ( From c110833d8e05fd78ccadc89ae205479ced99e76a Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 22 Sep 2026 17:39:56 -0700 Subject: [PATCH 5/5] chore(mobile): drop the dedicated logging tests Per maintainer request: the concern is small enough that the test count outweighed its value. The behavior was verified locally (classification tests for offline retry silence, settings-sync warnings, decode-defect warnings, and upload context all passed before removal). Co-authored-by: Apex by Callstack --- apps/mobile/src/lib/debugLog.test.ts | 70 -------- .../src/state/use-thread-outbox-drain.test.ts | 168 +----------------- .../src/state/use-thread-outbox-drain.ts | 10 +- 3 files changed, 4 insertions(+), 244 deletions(-) delete mode 100644 apps/mobile/src/lib/debugLog.test.ts diff --git a/apps/mobile/src/lib/debugLog.test.ts b/apps/mobile/src/lib/debugLog.test.ts deleted file mode 100644 index 1b0f1bbe1785..000000000000 --- a/apps/mobile/src/lib/debugLog.test.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; - -import { createDebugLogger } from "./debugLog"; - -describe("createDebugLogger", () => { - let logSpy: ReturnType; - - beforeEach(() => { - vi.stubGlobal("__DEV__", false); - vi.stubGlobal("__T3_DEBUG__", undefined); - vi.stubGlobal("__T3_CLOUD_DEBUG__", undefined); - logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); - }); - - afterEach(() => { - vi.unstubAllGlobals(); - logSpy.mockRestore(); - }); - - function setDebugFilter(filter: unknown): void { - vi.stubGlobal("__T3_DEBUG__", filter); - } - - it("is silent by default, including in development builds", () => { - vi.stubGlobal("__DEV__", true); - const trace = createDebugLogger("thread-outbox"); - trace.log("queued message delivery failed"); - expect(trace.isEnabled()).toBe(false); - expect(logSpy).not.toHaveBeenCalled(); - }); - - it("logs with the [t3-] prefix when the global filter is true", () => { - setDebugFilter(true); - const trace = createDebugLogger("thread-outbox"); - trace.log("attachment upload failed", { messageId: "m1" }); - expect(trace.isEnabled()).toBe(true); - expect(logSpy).toHaveBeenCalledWith("[t3-thread-outbox] attachment upload failed", { - messageId: "m1", - }); - }); - - it("filters per namespace when the global filter is a list", () => { - setDebugFilter(["cloud"]); - createDebugLogger("thread-outbox").log("delivery failed"); - createDebugLogger("cloud").log("relay connected"); - expect(logSpy.mock.calls).toEqual([["[t3-cloud] relay connected"]]); - }); - - it("logs without data as a single prefixed line", () => { - setDebugFilter(true); - createDebugLogger("thread-outbox").log("delivery failed"); - expect(logSpy).toHaveBeenCalledWith("[t3-thread-outbox] delivery failed"); - }); - - it("honors enabledInDev with __DEV__", () => { - vi.stubGlobal("__DEV__", true); - const trace = createDebugLogger("terminal", { enabledInDev: true }); - trace.log("buffer replay"); - expect(trace.isEnabled()).toBe(true); - expect(logSpy).toHaveBeenCalledWith("[t3-terminal] buffer replay"); - }); - - it("honors a legacy subsystem-specific global flag", () => { - vi.stubGlobal("__T3_CLOUD_DEBUG__", true); - const trace = createDebugLogger("cloud", { legacyGlobalFlag: "__T3_CLOUD_DEBUG__" }); - trace.log("relay state changed"); - expect(trace.isEnabled()).toBe(true); - expect(logSpy).toHaveBeenCalledWith("[t3-cloud] relay state changed"); - }); -}); diff --git a/apps/mobile/src/state/use-thread-outbox-drain.test.ts b/apps/mobile/src/state/use-thread-outbox-drain.test.ts index 393d695538c8..67b340f42042 100644 --- a/apps/mobile/src/state/use-thread-outbox-drain.test.ts +++ b/apps/mobile/src/state/use-thread-outbox-drain.test.ts @@ -144,8 +144,6 @@ import { recoverFailedThreadDraft } from "./recover-failed-thread-draft"; import { editingQueuedMessageIdsAtom } from "./use-thread-outbox"; import { completeQueuedMessageDelivery, - logThreadOutboxDeliveryFailure, - logThreadOutboxUploadFailure, prepareQueuedMessageAttachments, recoverEditedCreationAfterDelivery, removeAcknowledgedExistingThreadMessage, @@ -428,18 +426,7 @@ describe("thread outbox drain delivery cleanup", () => { const edited = { ...message, text: "edited while the turn delivered" }; await harness.manager.update(edited); - // Losing the cleanup race to an edit is expected; it must not warn. - const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); - let outcome: "edited" | "failed" | "removed"; - let warnCalls: ReadonlyArray; - try { - outcome = await completeQueuedMessageDelivery(message, deliveryRevision); - warnCalls = [...warn.mock.calls]; - } finally { - warn.mockRestore(); - } - expect(outcome).toBe("edited"); - expect(warnCalls).toEqual([]); + await expect(completeQueuedMessageDelivery(message, deliveryRevision)).resolves.toBe("edited"); expect(remainingMessages()).toEqual([edited]); expect(harness.removePersistedFile).not.toHaveBeenCalled(); @@ -776,156 +763,3 @@ describe("thread outbox recovery rollback", () => { expect(harness.setPendingConnectionError).toHaveBeenCalledWith("too large"); }); }); - -describe("thread outbox failure logging", () => { - // Tagged errors match shouldRetryThreadOutboxDelivery's transport tags, so - // the assertions cover the real failure classification, not a - // predetermined action argument. - const transportError = { _tag: "RpcClientError", reason: { _tag: "SocketClose" } }; - const decodeDefectError = { _tag: "RpcClientError", reason: { _tag: "RpcClientDefect" } }; - const serverDecidedError = { _tag: "OrchestrationDispatchCommandError" }; - - function failureLogging(input: Parameters[0]): { - readonly action: string; - readonly warnCalls: ReadonlyArray; - readonly logCalls: ReadonlyArray; - } { - const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); - const log = vi.spyOn(console, "log").mockImplementation(() => {}); - try { - return { - action: logThreadOutboxDeliveryFailure(input), - warnCalls: [...warn.mock.calls], - logCalls: [...log.mock.calls], - }; - } finally { - warn.mockRestore(); - log.mockRestore(); - } - } - - it("keeps offline transport retries silent by default at both stages", () => { - for (const stage of ["settings-sync", "start-turn"] as const) { - const { action, warnCalls, logCalls } = failureLogging({ - stage, - error: transportError, - interrupted: false, - context: { messageId: "m1" }, - }); - expect(action).toBe("retry"); - expect(warnCalls).toEqual([]); - expect(logCalls).toEqual([]); - } - }); - - it("surfaces transport retry details when the thread-outbox debug filter is enabled", () => { - vi.stubGlobal("__T3_DEBUG__", ["thread-outbox"]); - const log = vi.spyOn(console, "log").mockImplementation(() => {}); - try { - logThreadOutboxDeliveryFailure({ - stage: "start-turn", - error: transportError, - interrupted: false, - context: { messageId: "m1" }, - }); - expect(log).toHaveBeenCalledWith( - "[t3-thread-outbox] queued message delivery failed", - expect.objectContaining({ messageId: "m1", action: "retry" }), - ); - } finally { - log.mockRestore(); - vi.unstubAllGlobals(); - } - }); - - it("warns when the server decided the message must be restored", () => { - const { action, warnCalls } = failureLogging({ - stage: "start-turn", - error: serverDecidedError, - interrupted: false, - context: { messageId: "m2" }, - }); - expect(action).toBe("restore"); - expect(warnCalls).toEqual([ - [ - "[thread-outbox] queued message delivery failed", - expect.objectContaining({ messageId: "m2", action: "restore" }), - ], - ]); - }); - - it("warns on a nontransport settings-sync failure even though it resolves to a retry", () => { - const { action, warnCalls } = failureLogging({ - stage: "settings-sync", - error: serverDecidedError, - interrupted: false, - context: { messageId: "m3" }, - }); - expect(action).toBe("retry"); - expect(warnCalls).toEqual([ - [ - "[thread-outbox] queued message delivery failed", - expect.objectContaining({ stage: "settings-sync", action: "retry" }), - ], - ]); - }); - - it("warns on an rpc response-decoding defect even though it resolves to a retry", () => { - const { action, warnCalls } = failureLogging({ - stage: "start-turn", - error: decodeDefectError, - interrupted: false, - context: { messageId: "m4" }, - }); - expect(action).toBe("retry"); - expect(warnCalls).toEqual([ - [ - "[thread-outbox] queued message delivery failed", - expect.objectContaining({ stage: "start-turn", action: "retry" }), - ], - ]); - }); - - it("routes retryable attachment-upload failures to the debug log and warns on the rest", () => { - const message = queuedMessage({ messageId: "m-upload", text: "with file" }); - const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); - const log = vi.spyOn(console, "log").mockImplementation(() => {}); - try { - logThreadOutboxUploadFailure(message, transportError); - expect(warn).not.toHaveBeenCalled(); - expect(log).not.toHaveBeenCalled(); - - logThreadOutboxUploadFailure(message, serverDecidedError); - expect(warn).toHaveBeenCalledWith("[thread-outbox] failed to upload attachments", { - environmentId: message.environmentId, - threadId: message.threadId, - messageId: "m-upload", - error: serverDecidedError, - }); - } finally { - warn.mockRestore(); - log.mockRestore(); - } - }); - - it("logs retryable attachment-upload failures with message identifiers when the debug filter is enabled", () => { - vi.stubGlobal("__T3_DEBUG__", ["thread-outbox"]); - const message = queuedMessage({ messageId: "m-upload", text: "with file" }); - const log = vi.spyOn(console, "log").mockImplementation(() => {}); - try { - logThreadOutboxUploadFailure(message, transportError); - expect(log).toHaveBeenCalledWith( - "[t3-thread-outbox] attachment upload failed; retrying", - expect.objectContaining({ - messageId: "m-upload", - environmentId: message.environmentId, - threadId: message.threadId, - error: transportError, - }), - ); - } finally { - log.mockRestore(); - vi.unstubAllGlobals(); - } - }); -}); diff --git a/apps/mobile/src/state/use-thread-outbox-drain.ts b/apps/mobile/src/state/use-thread-outbox-drain.ts index bd24a4e35241..022c0ad6addb 100644 --- a/apps/mobile/src/state/use-thread-outbox-drain.ts +++ b/apps/mobile/src/state/use-thread-outbox-drain.ts @@ -129,9 +129,8 @@ function isOrdinaryThreadOutboxTransportFailure(error: unknown): boolean { * resolve to a retry even when the server rejected the command, so the * error, not the resolved action, must decide the log level there; routing * every retry to debug could hide a permanently rejected update forever. - * Exported for tests. */ -export function logThreadOutboxDeliveryFailure(input: { +function logThreadOutboxDeliveryFailure(input: { readonly stage: ThreadOutboxCommandStage; readonly error: unknown; readonly interrupted: boolean; @@ -157,11 +156,8 @@ export function logThreadOutboxDeliveryFailure(input: { return action; } -/** Attachment uploads retry like delivery: transport failures are ordinary offline noise. Exported for tests. */ -export function logThreadOutboxUploadFailure( - queuedMessage: QueuedThreadMessage, - error: unknown, -): void { +/** Attachment uploads retry like delivery: transport failures are ordinary offline noise. */ +function logThreadOutboxUploadFailure(queuedMessage: QueuedThreadMessage, error: unknown): void { const context = { environmentId: queuedMessage.environmentId, threadId: queuedMessage.threadId,