From 252fabc1389c9edaa27802dc794179b24d8b276d Mon Sep 17 00:00:00 2001 From: Void Date: Sun, 8 Nov 2026 17:08:51 +0000 Subject: [PATCH 1/5] Upload failures keep their name, size and file readability The catch in uploadContent set errcode from e.message and dropped an empty message, so the SDK's status-0 AbortError (DOMException with empty message) surfaced as "MatrixError: Unknown message". uploadFailure now uses e.name for errcode, keeps the original as cause, and puts size, type and whether the file can still be read into the message the upload card shows. Co-Authored-By: Claude Sonnet 5.5 --- src/app/utils/matrix.ts | 26 +++++++++++++++-- src/app/utils/uploadFailure.test.ts | 44 +++++++++++++++++++++++++++++ 2 files changed, 67 insertions(+), 3 deletions(-) create mode 100644 src/app/utils/uploadFailure.test.ts diff --git a/src/app/utils/matrix.ts b/src/app/utils/matrix.ts index 2859dbc3..34497de0 100644 --- a/src/app/utils/matrix.ts +++ b/src/app/utils/matrix.ts @@ -145,6 +145,28 @@ export type ContentUploadOptions = { onError: (error: MatrixError) => void; }; +/** + * Describe why an upload died. The SDK reports an XHR with status 0 as an + * AbortError DOMException with an empty message, so name, size, type and + * whether the browser can still read the file all go into the message. + */ +export const uploadFailure = async (e: any, file: TUploadContent): Promise => { + const name = typeof e?.name === 'string' && e.name ? e.name : 'Error'; + const message = typeof e?.message === 'string' ? e.message : ''; + let readable: string; + try { + await file.arrayBuffer(); + readable = 'file readable'; + } catch (readErr: any) { + readable = `file unreadable (${readErr?.name ?? 'unknown'})`; + } + const detail = `${file.size} bytes, ${file.type || 'unknown type'}, ${readable}`; + const error = `Upload failed: ${message ? `${name}: ${message}` : name} (${detail})`; + const err = new MatrixError({ error, errcode: name }); + (err as any).cause = e; + return err; +}; + export const uploadContent = async ( mx: MatrixClient, file: TUploadContent, @@ -165,9 +187,7 @@ export const uploadContent = async ( if (mxc) onSuccess(mxc); else onError(new MatrixError(data)); } catch (e: any) { - const error = typeof e?.message === 'string' ? e.message : undefined; - const errcode = typeof e?.name === 'string' ? e.message : undefined; - onError(new MatrixError({ error, errcode })); + onError(await uploadFailure(e, file)); } }; diff --git a/src/app/utils/uploadFailure.test.ts b/src/app/utils/uploadFailure.test.ts new file mode 100644 index 00000000..7a2e62b1 --- /dev/null +++ b/src/app/utils/uploadFailure.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from 'vitest'; +import { uploadFailure } from './matrix'; + +const abort = () => new DOMException('', 'AbortError'); + +describe('uploadFailure', () => { + it('keeps the error name as errcode, never the message', async () => { + const err = await uploadFailure(abort(), new Blob(['abc'], { type: 'image/png' })); + expect(err.errcode).toBe('AbortError'); + }); + + it('never reduces an empty-message error to "Unknown message"', async () => { + const err = await uploadFailure(abort(), new Blob(['abc'])); + expect(err.message).not.toContain('Unknown message'); + expect(err.message).toContain('AbortError'); + }); + + it('reports size and type of the file', async () => { + const err = await uploadFailure(abort(), new Blob(['abcd'], { type: 'image/png' })); + expect(err.message).toContain('4 bytes'); + expect(err.message).toContain('image/png'); + }); + + it('keeps the original error as cause', async () => { + const e = abort(); + const err = await uploadFailure(e, new Blob(['x'])); + expect((err as any).cause).toBe(e); + }); + + it('says whether the browser could still read the file', async () => { + const ok = await uploadFailure(abort(), new Blob(['x'])); + expect(ok.message).toContain('file readable'); + const bad = new Blob(['x']); + (bad as any).arrayBuffer = () => Promise.reject(new DOMException('gone', 'NotReadableError')); + const err = await uploadFailure(abort(), bad); + expect(err.message).toContain('file unreadable (NotReadableError)'); + }); + + it('keeps a real message when the error has one', async () => { + const err = await uploadFailure(new Error('Timeout'), new Blob(['x'])); + expect(err.message).toContain('Timeout'); + expect(err.errcode).toBe('Error'); + }); +}); -- 2.47.3 From 95f0dce05f99cfa7c51b7a19a90e56fb577785e3 Mon Sep 17 00:00:00 2001 From: Void Date: Sun, 8 Nov 2026 17:32:32 +0000 Subject: [PATCH 2/5] Upload failure text carries what the client can observe Adds connectivity (onLine, effectiveType, rtt, downlink, saveData), tab visibility, ms since last user activity, time to first progress and bytes loaded, and ms to failure. Nothing patches XMLHttpRequest; the progress handler and window input events are the only sources. Co-Authored-By: Claude Sonnet 5.5 --- src/app/utils/matrix.ts | 69 +++++++++++++++++++++++++---- src/app/utils/uploadFailure.test.ts | 60 ++++++++++++++++++++++++- 2 files changed, 119 insertions(+), 10 deletions(-) diff --git a/src/app/utils/matrix.ts b/src/app/utils/matrix.ts index 34497de0..7890a17c 100644 --- a/src/app/utils/matrix.ts +++ b/src/app/utils/matrix.ts @@ -145,12 +145,48 @@ export type ContentUploadOptions = { onError: (error: MatrixError) => void; }; +let lastUserActivity = Date.now(); +export const noteUserActivity = () => { + lastUserActivity = Date.now(); +}; +if (typeof window !== 'undefined') { + ['pointerdown', 'keydown', 'touchstart'].forEach((type) => + window.addEventListener(type, noteUserActivity, { capture: true, passive: true }) + ); +} + +/** What the progress handler saw of one upload, for the failure text. */ +export type UploadTrace = { + startedAt: number; + firstProgressAt?: number; + loaded?: number; + total?: number; +}; + +const describeNetwork = (): string => { + const online = + typeof navigator !== 'undefined' && navigator.onLine === false ? 'offline' : 'online'; + const c = typeof navigator !== 'undefined' ? (navigator as any).connection : undefined; + const net = c + ? `${c.effectiveType ?? '?'} rtt ${c.rtt ?? '?'}ms down ${c.downlink ?? '?'}Mbps saveData ${ + c.saveData ? 'on' : 'off' + }` + : 'n/a'; + const tab = typeof document !== 'undefined' ? document.visibilityState : 'n/a'; + return `${online}, net ${net}, tab ${tab}`; +}; + /** - * Describe why an upload died. The SDK reports an XHR with status 0 as an - * AbortError DOMException with an empty message, so name, size, type and - * whether the browser can still read the file all go into the message. + * Describe why an upload died. The SDK reports an XHR with status 0 as an AbortError + * DOMException with an empty message, so the text carries what the client can observe: file + * size, type and readability, connectivity, tab visibility, idle time, progress, time to fail. */ -export const uploadFailure = async (e: any, file: TUploadContent): Promise => { +export const uploadFailure = async ( + e: any, + file: TUploadContent, + trace?: UploadTrace +): Promise => { + const now = Date.now(); const name = typeof e?.name === 'string' && e.name ? e.name : 'Error'; const message = typeof e?.message === 'string' ? e.message : ''; let readable: string; @@ -160,8 +196,19 @@ export const uploadFailure = async (e: any, file: TUploadContent): Promise { const { name, fileType, hideFilename, onProgress, onPromise, onSuccess, onError } = options; + const trace: UploadTrace = { startedAt: Date.now() }; const uploadPromise = mx.uploadContent(file, { name, type: fileType, includeFilename: !hideFilename, - progressHandler: onProgress, + progressHandler: (progress) => { + if (trace.firstProgressAt === undefined) trace.firstProgressAt = Date.now(); + trace.loaded = progress.loaded; + trace.total = progress.total; + onProgress?.(progress); + }, }); onPromise?.(uploadPromise); try { @@ -187,7 +240,7 @@ export const uploadContent = async ( if (mxc) onSuccess(mxc); else onError(new MatrixError(data)); } catch (e: any) { - onError(await uploadFailure(e, file)); + onError(await uploadFailure(e, file, trace)); } }; diff --git a/src/app/utils/uploadFailure.test.ts b/src/app/utils/uploadFailure.test.ts index 7a2e62b1..ec1e8115 100644 --- a/src/app/utils/uploadFailure.test.ts +++ b/src/app/utils/uploadFailure.test.ts @@ -1,5 +1,5 @@ -import { describe, expect, it } from 'vitest'; -import { uploadFailure } from './matrix'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { noteUserActivity, uploadFailure } from './matrix'; const abort = () => new DOMException('', 'AbortError'); @@ -42,3 +42,59 @@ describe('uploadFailure', () => { expect(err.errcode).toBe('Error'); }); }); + +describe('uploadFailure client trace', () => { + const blob = new Blob(['abcd'], { type: 'image/jpeg' }); + + afterEach(() => { + vi.useRealTimers(); + vi.unstubAllGlobals(); + }); + + it('reports connectivity, visibility and idle time at the moment of failure', async () => { + vi.useFakeTimers(); + vi.setSystemTime(10_000); + vi.stubGlobal('navigator', { + onLine: false, + connection: { effectiveType: '3g', rtt: 300, downlink: 1.5, saveData: true }, + }); + vi.stubGlobal('document', { visibilityState: 'hidden' }); + noteUserActivity(); + vi.setSystemTime(13_000); + const err = await uploadFailure(abort(), blob, { startedAt: 12_000 }); + expect(err.message).toContain('offline'); + expect(err.message).toContain('3g rtt 300ms down 1.5Mbps saveData on'); + expect(err.message).toContain('tab hidden'); + expect(err.message).toContain('idle 3000ms'); + expect(err.message).toContain('failed after 1000ms'); + }); + + it('says so when no progress event ever arrived', async () => { + vi.useFakeTimers(); + vi.setSystemTime(5_000); + const err = await uploadFailure(abort(), blob, { startedAt: 4_000 }); + expect(err.message).toContain('no progress'); + }); + + it('reports time to first progress and bytes loaded when some arrived', async () => { + vi.useFakeTimers(); + vi.setSystemTime(9_000); + const err = await uploadFailure(abort(), blob, { + startedAt: 5_000, + firstProgressAt: 5_120, + loaded: 65536, + total: 700000, + }); + expect(err.message).toContain('first progress 120ms'); + expect(err.message).toContain('65536/700000B'); + expect(err.message).toContain('failed after 4000ms'); + }); + + it('copes with a browser that has no connection info or document', async () => { + vi.stubGlobal('navigator', { onLine: true }); + vi.stubGlobal('document', undefined); + const err = await uploadFailure(abort(), blob, { startedAt: Date.now() }); + expect(err.message).toContain('online'); + expect(err.message).toContain('net n/a'); + }); +}); -- 2.47.3 From be947a0bf929873ced3ae0580fdb7ead38144317 Mon Sep 17 00:00:00 2001 From: Void Date: Sun, 8 Nov 2026 18:05:11 +0000 Subject: [PATCH 3/5] Retry an upload once when it aborts at once with no progress An AbortError within 3s of starting, with no progress event and no user cancel, is retried once; the failed attempt discards its connection, so the retry cannot reuse it. The final failure text says "retried" with attempt 1's timing. Never retried after progress, a user cancel, a different error, or a slow abort. The cause (a reused dead keep-alive socket) is unproven. Co-Authored-By: Claude Sonnet 5.5 --- src/app/utils/matrix.ts | 67 ++++++++++++------ src/app/utils/uploadRetry.test.ts | 113 ++++++++++++++++++++++++++++++ 2 files changed, 160 insertions(+), 20 deletions(-) create mode 100644 src/app/utils/uploadRetry.test.ts diff --git a/src/app/utils/matrix.ts b/src/app/utils/matrix.ts index 7890a17c..3eef6b21 100644 --- a/src/app/utils/matrix.ts +++ b/src/app/utils/matrix.ts @@ -161,6 +161,8 @@ export type UploadTrace = { firstProgressAt?: number; loaded?: number; total?: number; + /** Summary of an earlier attempt of the same upload, when this one is a retry. */ + previous?: string; }; const describeNetwork = (): string => { @@ -207,6 +209,7 @@ export const uploadFailure = async ( }B` ); parts.push(`failed after ${now - trace.startedAt}ms`); + if (trace.previous) parts.push(`retried (attempt 1: ${trace.previous})`); } const error = `Upload failed: ${message ? `${name}: ${message}` : name} (${parts.join(', ')})`; const err = new MatrixError({ error, errcode: name }); @@ -214,6 +217,11 @@ export const uploadFailure = async ( return err; }; +// An abort before any progress, within this long, is retried once: the request most likely died on +// a reused connection before a byte left, and the failed attempt discards that connection. Nothing +// here has shown that is the cause; the failure text records both attempts so it can be checked. +const RETRY_WINDOW_MS = 3000; + export const uploadContent = async ( mx: MatrixClient, file: TUploadContent, @@ -221,26 +229,45 @@ export const uploadContent = async ( ) => { const { name, fileType, hideFilename, onProgress, onPromise, onSuccess, onError } = options; - const trace: UploadTrace = { startedAt: Date.now() }; - const uploadPromise = mx.uploadContent(file, { - name, - type: fileType, - includeFilename: !hideFilename, - progressHandler: (progress) => { - if (trace.firstProgressAt === undefined) trace.firstProgressAt = Date.now(); - trace.loaded = progress.loaded; - trace.total = progress.total; - onProgress?.(progress); - }, - }); - onPromise?.(uploadPromise); - try { - const data = await uploadPromise; - const mxc = data.content_uri; - if (mxc) onSuccess(mxc); - else onError(new MatrixError(data)); - } catch (e: any) { - onError(await uploadFailure(e, file, trace)); + let previous: string | undefined; + for (let attempt = 1; ; attempt += 1) { + const trace: UploadTrace = { startedAt: Date.now(), previous }; + const abortController = new AbortController(); + const uploadPromise = mx.uploadContent(file, { + name, + type: fileType, + includeFilename: !hideFilename, + abortController, + progressHandler: (progress) => { + if (trace.firstProgressAt === undefined) trace.firstProgressAt = Date.now(); + trace.loaded = progress.loaded; + trace.total = progress.total; + onProgress?.(progress); + }, + }); + onPromise?.(uploadPromise); + try { + // eslint-disable-next-line no-await-in-loop + const data = await uploadPromise; + const mxc = data.content_uri; + if (mxc) onSuccess(mxc); + else onError(new MatrixError(data)); + return; + } catch (e: any) { + const elapsed = Date.now() - trace.startedAt; + const retry = + attempt === 1 && + e?.name === 'AbortError' && + !abortController.signal.aborted && + trace.firstProgressAt === undefined && + elapsed < RETRY_WINDOW_MS; + if (!retry) { + // eslint-disable-next-line no-await-in-loop + onError(await uploadFailure(e, file, trace)); + return; + } + previous = `AbortError after ${elapsed}ms, no progress`; + } } }; diff --git a/src/app/utils/uploadRetry.test.ts b/src/app/utils/uploadRetry.test.ts new file mode 100644 index 00000000..3e9957c2 --- /dev/null +++ b/src/app/utils/uploadRetry.test.ts @@ -0,0 +1,113 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { uploadContent } from './matrix'; + +const abort = () => new DOMException('', 'AbortError'); +const blob = new Blob(['abcd'], { type: 'image/jpeg' }); + +type Step = (opts: any) => Promise; +const fakeMx = (steps: Step[]) => { + const calls: any[] = []; + const mx = { + uploadContent: vi.fn((_file: unknown, opts: any) => { + calls.push(opts); + return steps[calls.length - 1](opts); + }), + }; + return { mx: mx as any, calls }; +}; +const run = async (mx: any) => { + const onSuccess = vi.fn(); + const onError = vi.fn(); + const onPromise = vi.fn(); + await uploadContent(mx, blob, { onSuccess, onError, onPromise }); + return { onSuccess, onError, onPromise }; +}; + +describe('uploadContent retry', () => { + afterEach(() => vi.useRealTimers()); + + it('retries once when the first attempt aborts at once with no progress', async () => { + const { mx } = fakeMx([ + () => Promise.reject(abort()), + async () => ({ content_uri: 'mxc://a/b' }), + ]); + const r = await run(mx); + expect(mx.uploadContent).toHaveBeenCalledTimes(2); + expect(r.onSuccess).toHaveBeenCalledWith('mxc://a/b'); + expect(r.onError).not.toHaveBeenCalled(); + expect(r.onPromise).toHaveBeenCalledTimes(2); + }); + + it('retries at most once and reports both attempts when the retry fails too', async () => { + const { mx } = fakeMx([() => Promise.reject(abort()), () => Promise.reject(abort())]); + const r = await run(mx); + expect(mx.uploadContent).toHaveBeenCalledTimes(2); + const msg = r.onError.mock.calls[0][0].message; + expect(msg).toContain('retried'); + expect(msg).toContain('attempt 1: AbortError'); + expect(msg).toContain('no progress'); + }); + + it('never retries after a progress event', async () => { + const { mx } = fakeMx([ + (opts) => { + opts.progressHandler({ loaded: 10, total: 100 }); + return Promise.reject(abort()); + }, + async () => ({ content_uri: 'mxc://a/b' }), + ]); + const r = await run(mx); + expect(mx.uploadContent).toHaveBeenCalledTimes(1); + expect(r.onError).toHaveBeenCalledTimes(1); + expect(r.onError.mock.calls[0][0].message).not.toContain('retried'); + }); + + it('never retries after the user cancelled', async () => { + const { mx } = fakeMx([ + (opts) => { + opts.abortController.abort(); + return Promise.reject(abort()); + }, + async () => ({ content_uri: 'mxc://a/b' }), + ]); + const r = await run(mx); + expect(mx.uploadContent).toHaveBeenCalledTimes(1); + expect(r.onError).toHaveBeenCalledTimes(1); + }); + + it('never retries an error that is not an abort', async () => { + const { mx } = fakeMx([() => Promise.reject(new Error('Timeout')), async () => ({})]); + const r = await run(mx); + expect(mx.uploadContent).toHaveBeenCalledTimes(1); + expect(r.onError).toHaveBeenCalledTimes(1); + }); + + it('never retries an abort that took three seconds or more', async () => { + vi.useFakeTimers(); + vi.setSystemTime(0); + const { mx } = fakeMx([ + () => { + vi.setSystemTime(3000); + return Promise.reject(abort()); + }, + async () => ({ content_uri: 'mxc://a/b' }), + ]); + const r = await run(mx); + expect(mx.uploadContent).toHaveBeenCalledTimes(1); + expect(r.onError).toHaveBeenCalledTimes(1); + }); + + it('retries the same abort at 2999ms', async () => { + vi.useFakeTimers(); + vi.setSystemTime(0); + const { mx } = fakeMx([ + () => { + vi.setSystemTime(2999); + return Promise.reject(abort()); + }, + async () => ({ content_uri: 'mxc://a/b' }), + ]); + await run(mx); + expect(mx.uploadContent).toHaveBeenCalledTimes(2); + }); +}); -- 2.47.3 From 3e57865eca4ea5d1f96905d3795996b5e497e91c Mon Sep 17 00:00:00 2001 From: Void Date: Sun, 8 Nov 2026 18:45:29 +0000 Subject: [PATCH 4/5] Upload failure text carries who called abort and what the owning component did For each upload attempt, XMLHttpRequest.prototype.abort and AbortController.prototype.abort are wrapped (reference-counted across overlapping attempts, restored in finally) and every call is recorded with a six-frame file:line:col stack; "abort calls: none" says the page called neither. The upload hook also counts renders after attach (and those within 300ms) and any unmount. The in-memory copy and the once-only retry are unchanged. Co-Authored-By: Claude Sonnet 5.5 --- src/app/state/upload.ts | 43 ++++-- src/app/utils/matrix.ts | 187 ++++++++++++++++++++----- src/app/utils/uploadAbortProbe.test.ts | 171 ++++++++++++++++++++++ 3 files changed, 356 insertions(+), 45 deletions(-) create mode 100644 src/app/utils/uploadAbortProbe.test.ts diff --git a/src/app/state/upload.ts b/src/app/state/upload.ts index 810c0d2e..84786a29 100644 --- a/src/app/state/upload.ts +++ b/src/app/state/upload.ts @@ -1,9 +1,14 @@ import { atom, useAtom } from 'jotai'; import { atomFamily } from 'jotai/utils'; import { MatrixClient, UploadResponse, UploadProgress, MatrixError } from 'matrix-js-sdk'; -import { useCallback } from 'react'; +import { useCallback, useEffect, useRef } from 'react'; import { useThrottle } from '../hooks/useThrottle'; -import { uploadContent, TUploadContent } from '../utils/matrix'; +import { + ComponentLifecycle, + describeComponentLifecycle, + uploadContent, + TUploadContent, +} from '../utils/matrix'; export enum UploadStatus { Idle = 'idle', @@ -105,22 +110,34 @@ export const useBindUploadAtom = ( const [upload, setUpload] = useAtom(uploadAtom); const { file } = upload; + // For the failure text: renders and unmount of the component that owns this upload. + const life = useRef({ attachedAt: 0, rendersAtAttach: 0, renders: [] }); + if (life.current.renders.length < 50) life.current.renders.push(Date.now()); + useEffect(() => { + const tracked = life.current; + tracked.unmountedAt = undefined; + return () => { + tracked.unmountedAt = Date.now(); + }; + }, []); + const handleProgress = useThrottle( useCallback((progress: UploadProgress) => setUpload({ progress }), [setUpload]), { immediate: true, wait: 200 } ); - const startUpload = useCallback( - () => - uploadContent(mx, file, { - hideFilename, - onPromise: (promise: Promise) => setUpload({ promise }), - onProgress: handleProgress, - onSuccess: (mxc) => setUpload({ mxc }), - onError: (error) => setUpload({ error }), - }), - [mx, file, hideFilename, setUpload, handleProgress] - ); + const startUpload = useCallback(() => { + life.current.attachedAt = Date.now(); + life.current.rendersAtAttach = life.current.renders.length; + return uploadContent(mx, file, { + hideFilename, + describeLifecycle: () => describeComponentLifecycle(life.current), + onPromise: (promise: Promise) => setUpload({ promise }), + onProgress: handleProgress, + onSuccess: (mxc) => setUpload({ mxc }), + onError: (error) => setUpload({ error }), + }); + }, [mx, file, hideFilename, setUpload, handleProgress]); const cancelUpload = useCallback(async () => { if (upload.status === UploadStatus.Loading) { diff --git a/src/app/utils/matrix.ts b/src/app/utils/matrix.ts index 3eef6b21..a3257f26 100644 --- a/src/app/utils/matrix.ts +++ b/src/app/utils/matrix.ts @@ -143,6 +143,8 @@ export type ContentUploadOptions = { onProgress?: (progress: UploadProgress) => void; onSuccess: (mxc: string) => void; onError: (error: MatrixError) => void; + /** Read at failure time: what the owning component did since the upload attached. */ + describeLifecycle?: () => string; }; let lastUserActivity = Date.now(); @@ -155,6 +157,8 @@ if (typeof window !== 'undefined') { ); } +export type AbortCall = { api: string; frames: string }; + /** What the progress handler saw of one upload, for the failure text. */ export type UploadTrace = { startedAt: number; @@ -163,6 +167,98 @@ export type UploadTrace = { total?: number; /** Summary of an earlier attempt of the same upload, when this one is a retry. */ previous?: string; + /** abort() calls the page made while this attempt ran; absent when not probed. */ + aborts?: AbortCall[]; + /** What the owning component did since the upload attached. */ + lifecycle?: string; +}; + +const MAX_FRAMES = 6; +const MAX_ABORT_CALLS = 4; + +/** + * An Error stack as `file:line:col < file:line:col`, at most six frames. The error line and the + * probe's own frame are dropped; origins and function names are not kept. + */ +export const formatAbortStack = (stack: string): string => { + const frames = stack + .split('\n') + .map((l) => l.trim()) + .filter((l) => l.startsWith('at ') || l.includes('@')) + .map((l) => { + const m = /([^/\s()@]+):(\d+):(\d+)\)?$/.exec(l); + return m ? `${m[1]}:${m[2]}:${m[3]}` : undefined; + }) + .filter((f): f is string => f !== undefined) + .slice(1, 1 + MAX_FRAMES); + return frames.length > 0 ? frames.join(' < ') : 'no stack'; +}; + +export type ComponentLifecycle = { + attachedAt: number; + rendersAtAttach: number; + renders: number[]; + unmountedAt?: number; +}; + +/** What the component that owns an upload did after the upload attached to it. */ +export const describeComponentLifecycle = (life: ComponentLifecycle): string => { + const since = life.renders.slice(life.rendersAtAttach); + const quick = since.filter((t) => t - life.attachedAt <= 300).length; + const unmount = + life.unmountedAt === undefined + ? 'not unmounted' + : `unmounted +${life.unmountedAt - life.attachedAt}ms`; + return `component: ${since.length} renders since attach (${quick} within 300ms), ${unmount}`; +}; + +const collectors = new Set(); +let restoreProbe: (() => void) | undefined; + +/* eslint-disable no-param-reassign */ +const patchAbort = (proto: { abort: (...a: any[]) => any }, api: string) => { + const original = proto.abort; + const wrapper = function abortProbe(this: unknown, ...args: any[]) { + const frames = formatAbortStack(new Error().stack ?? ''); + collectors.forEach((calls) => { + if (calls.length < MAX_ABORT_CALLS) calls.push({ api, frames }); + }); + return original.apply(this, args); + }; + proto.abort = wrapper; + return () => { + if (proto.abort === wrapper) proto.abort = original; + }; +}; +/* eslint-enable no-param-reassign */ + +/** + * Records every XMLHttpRequest.abort and AbortController.abort the page makes until stopped. The + * prototypes are wrapped while at least one probe runs and restored when the last one stops. + */ +export const startAbortProbe = () => { + const calls: AbortCall[] = []; + if (collectors.size === 0) { + const restores: (() => void)[] = []; + if (typeof XMLHttpRequest !== 'undefined') { + restores.push(patchAbort(XMLHttpRequest.prototype, 'XHR.abort')); + } + if (typeof AbortController !== 'undefined') { + restores.push(patchAbort(AbortController.prototype, 'AbortController.abort')); + } + restoreProbe = () => restores.forEach((r) => r()); + } + collectors.add(calls); + return { + calls, + stop: () => { + collectors.delete(calls); + if (collectors.size === 0) { + restoreProbe?.(); + restoreProbe = undefined; + } + }, + }; }; const describeNetwork = (): string => { @@ -209,6 +305,14 @@ export const uploadFailure = async ( }B` ); parts.push(`failed after ${now - trace.startedAt}ms`); + if (trace.aborts) { + parts.push( + trace.aborts.length === 0 + ? 'abort calls: none' + : `abort calls: ${trace.aborts.map((a) => `${a.api} ${a.frames}`).join('; ')}` + ); + } + if (trace.lifecycle) parts.push(trace.lifecycle); if (trace.previous) parts.push(`retried (attempt 1: ${trace.previous})`); } const error = `Upload failed: ${message ? `${name}: ${message}` : name} (${parts.join(', ')})`; @@ -227,46 +331,65 @@ export const uploadContent = async ( file: TUploadContent, options: ContentUploadOptions ) => { - const { name, fileType, hideFilename, onProgress, onPromise, onSuccess, onError } = options; + const { + name, + fileType, + hideFilename, + onProgress, + onPromise, + onSuccess, + onError, + describeLifecycle, + } = options; let previous: string | undefined; for (let attempt = 1; ; attempt += 1) { - const trace: UploadTrace = { startedAt: Date.now(), previous }; + const probe = startAbortProbe(); + const trace: UploadTrace = { startedAt: Date.now(), previous, aborts: probe.calls }; const abortController = new AbortController(); - const uploadPromise = mx.uploadContent(file, { - name, - type: fileType, - includeFilename: !hideFilename, - abortController, - progressHandler: (progress) => { - if (trace.firstProgressAt === undefined) trace.firstProgressAt = Date.now(); - trace.loaded = progress.loaded; - trace.total = progress.total; - onProgress?.(progress); - }, - }); - onPromise?.(uploadPromise); try { - // eslint-disable-next-line no-await-in-loop - const data = await uploadPromise; - const mxc = data.content_uri; - if (mxc) onSuccess(mxc); - else onError(new MatrixError(data)); - return; - } catch (e: any) { - const elapsed = Date.now() - trace.startedAt; - const retry = - attempt === 1 && - e?.name === 'AbortError' && - !abortController.signal.aborted && - trace.firstProgressAt === undefined && - elapsed < RETRY_WINDOW_MS; - if (!retry) { + const uploadPromise = mx.uploadContent(file, { + name, + type: fileType, + includeFilename: !hideFilename, + abortController, + progressHandler: (progress) => { + if (trace.firstProgressAt === undefined) trace.firstProgressAt = Date.now(); + trace.loaded = progress.loaded; + trace.total = progress.total; + onProgress?.(progress); + }, + }); + onPromise?.(uploadPromise); + try { // eslint-disable-next-line no-await-in-loop - onError(await uploadFailure(e, file, trace)); + const data = await uploadPromise; + const mxc = data.content_uri; + if (mxc) onSuccess(mxc); + else onError(new MatrixError(data)); return; + } catch (e: any) { + const elapsed = Date.now() - trace.startedAt; + const retry = + attempt === 1 && + e?.name === 'AbortError' && + !abortController.signal.aborted && + trace.firstProgressAt === undefined && + elapsed < RETRY_WINDOW_MS; + if (!retry) { + trace.lifecycle = describeLifecycle?.(); + // eslint-disable-next-line no-await-in-loop + onError(await uploadFailure(e, file, trace)); + return; + } + previous = `AbortError after ${elapsed}ms, no progress, ${ + trace.aborts?.length + ? `abort calls: ${trace.aborts.map((a) => `${a.api} ${a.frames}`).join('; ')}` + : 'abort calls: none' + }`; } - previous = `AbortError after ${elapsed}ms, no progress`; + } finally { + probe.stop(); } } }; diff --git a/src/app/utils/uploadAbortProbe.test.ts b/src/app/utils/uploadAbortProbe.test.ts new file mode 100644 index 00000000..557908ef --- /dev/null +++ b/src/app/utils/uploadAbortProbe.test.ts @@ -0,0 +1,171 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { + describeComponentLifecycle, + formatAbortStack, + startAbortProbe, + uploadContent, + uploadFailure, +} from './matrix'; + +const chromeStack = [ + 'Error', + ' at wrapper (https://chat.example/assets/index-abc.js:1:100)', + ' at https://chat.example/assets/index-abc.js:1:200', + ' at Object.cb (https://chat.example/assets/vendor-def.js:7:8)', + ' at https://chat.example/assets/a.js:2:3', + ' at https://chat.example/assets/b.js:4:5', + ' at https://chat.example/assets/c.js:6:7', + ' at https://chat.example/assets/d.js:8:9', + ' at https://chat.example/assets/e.js:10:11', +].join('\n'); + +describe('formatAbortStack', () => { + it('keeps at most six frames as file:line:col, skipping the probe wrapper and the error line', () => { + const out = formatAbortStack(chromeStack); + const frames = out.split(' < '); + expect(frames).toHaveLength(6); + expect(frames[0]).toBe('index-abc.js:1:200'); + expect(out).not.toContain('https://'); + expect(out).not.toContain('wrapper'); + }); + + it('copes with an empty stack', () => { + expect(formatAbortStack('')).toBe('no stack'); + }); +}); + +describe('abort probe', () => { + const original = AbortController.prototype.abort; + + afterEach(() => { + vi.unstubAllGlobals(); + AbortController.prototype.abort = original; + }); + + it('records an AbortController.abort made while it runs, still aborts, and restores afterwards', () => { + const probe = startAbortProbe(); + expect(AbortController.prototype.abort).not.toBe(original); + const c = new AbortController(); + c.abort(); + expect(c.signal.aborted).toBe(true); + probe.stop(); + expect(AbortController.prototype.abort).toBe(original); + expect(probe.calls).toHaveLength(1); + expect(probe.calls[0].api).toBe('AbortController.abort'); + }); + + it('records XMLHttpRequest.prototype.abort and restores it', () => { + const xhrAbort = vi.fn(); + class FakeXhr { + // eslint-disable-next-line class-methods-use-this + abort() { + xhrAbort(); + } + } + vi.stubGlobal('XMLHttpRequest', FakeXhr); + const probe = startAbortProbe(); + new FakeXhr().abort(); + probe.stop(); + expect(xhrAbort).toHaveBeenCalledTimes(1); + expect(probe.calls.map((c) => c.api)).toContain('XHR.abort'); + expect(FakeXhr.prototype.abort.toString()).not.toContain('collectors'); + const after = new FakeXhr(); + after.abort(); + expect(xhrAbort).toHaveBeenCalledTimes(2); + }); + + it('records nothing after stop', () => { + const probe = startAbortProbe(); + probe.stop(); + new AbortController().abort(); + expect(probe.calls).toHaveLength(0); + }); + + it('keeps the wrapper until the last overlapping probe stops, then restores the original', () => { + const a = startAbortProbe(); + const b = startAbortProbe(); + a.stop(); + new AbortController().abort(); + expect(b.calls).toHaveLength(1); + expect(a.calls).toHaveLength(0); + b.stop(); + expect(AbortController.prototype.abort).toBe(original); + }); + + it('restores the original when the upload attempt throws', async () => { + const mx = { uploadContent: () => Promise.reject(new Error('boom')) } as any; + await uploadContent(mx, new Blob(['x']), { onSuccess: vi.fn(), onError: vi.fn() }); + expect(AbortController.prototype.abort).toBe(original); + }); + + it('restores the original when uploadContent itself throws synchronously', async () => { + const mx = { + uploadContent: () => { + throw new Error('sync'); + }, + } as any; + await expect( + uploadContent(mx, new Blob(['x']), { onSuccess: vi.fn(), onError: vi.fn() }) + ).rejects.toThrow('sync'); + expect(AbortController.prototype.abort).toBe(original); + }); +}); + +describe('failure text', () => { + const abort = () => new DOMException('', 'AbortError'); + + it('lists abort calls seen during the attempt', async () => { + const mx = { + uploadContent: () => { + new AbortController().abort(); + return Promise.reject(abort()); + }, + } as any; + const onError = vi.fn(); + await uploadContent(mx, new Blob(['x']), { onSuccess: vi.fn(), onError }); + // retried once: the final message carries the second attempt and attempt 1's summary + const msg = onError.mock.calls[0][0].message; + expect(msg).toContain('abort calls: AbortController.abort'); + expect(msg).toContain('retried (attempt 1: AbortError'); + }); + + it('says none when nothing in the page called abort', async () => { + const err = await uploadFailure(abort(), new Blob(['x']), { + startedAt: Date.now(), + aborts: [], + }); + expect(err.message).toContain('abort calls: none'); + }); + + it('reports the component lifecycle when given', async () => { + const mx = { uploadContent: () => Promise.reject(new Error('Timeout')) } as any; + const onError = vi.fn(); + await uploadContent(mx, new Blob(['x']), { + onSuccess: vi.fn(), + onError, + describeLifecycle: () => 'component remounted 0x, renders 3 (2 within 300ms)', + }); + expect(onError.mock.calls[0][0].message).toContain('renders 3 (2 within 300ms)'); + }); +}); + +describe('describeComponentLifecycle', () => { + it('counts renders after attach and those within 300ms, and reports an unmount', () => { + const text = describeComponentLifecycle({ + attachedAt: 1000, + rendersAtAttach: 1, + renders: [990, 1100, 1250, 1400], + unmountedAt: 1500, + }); + expect(text).toBe('component: 3 renders since attach (2 within 300ms), unmounted +500ms'); + }); + + it('says not unmounted when it was not', () => { + const text = describeComponentLifecycle({ + attachedAt: 1000, + rendersAtAttach: 1, + renders: [990], + }); + expect(text).toBe('component: 0 renders since attach (0 within 300ms), not unmounted'); + }); +}); -- 2.47.3 From 25765c44c5ed6577e7ada36d52201de0d87ebacc Mon Sep 17 00:00:00 2001 From: Void Date: Sun, 8 Nov 2026 18:47:27 +0000 Subject: [PATCH 5/5] Upload each attempt from an in-memory copy; failure line names the XHR's end state - before every attempt the picked file is read into memory (ArrayBuffer) and that copy is what the SDK uploads; a file the browser cannot read fails there as "file unreadable before upload" - while an attempt runs, XMLHttpRequest open/send are wrapped (reference-counted with the abort probe, restored in finally, no behaviour change) so the failure line carries each upload XHR's method, path (query dropped), readyState, status, the events it fired with timings, and our signal.aborted - the abort-call stacks stay as a minor extra Co-Authored-By: Claude Sonnet 5.5 --- src/app/utils/matrix.ts | 127 +++++++++++++++++++-- src/app/utils/uploadXhrProbe.test.ts | 163 +++++++++++++++++++++++++++ 2 files changed, 278 insertions(+), 12 deletions(-) create mode 100644 src/app/utils/uploadXhrProbe.test.ts diff --git a/src/app/utils/matrix.ts b/src/app/utils/matrix.ts index a3257f26..f0ded881 100644 --- a/src/app/utils/matrix.ts +++ b/src/app/utils/matrix.ts @@ -171,6 +171,9 @@ export type UploadTrace = { aborts?: AbortCall[]; /** What the owning component did since the upload attached. */ lifecycle?: string; + /** Upload XHR events and end state, and whether our own signal was aborted. */ + xhr?: string; + signalAborted?: boolean; }; const MAX_FRAMES = 6; @@ -212,15 +215,29 @@ export const describeComponentLifecycle = (life: ComponentLifecycle): string => return `component: ${since.length} renders since attach (${quick} within 300ms), ${unmount}`; }; -const collectors = new Set(); +export type XhrRecord = { + xhr: XMLHttpRequest; + method: string; + path: string; + startedAt: number; + events: string[]; +}; + +type Probe = { calls: AbortCall[]; xhrs: XhrRecord[] }; + +const probes = new Set(); let restoreProbe: (() => void) | undefined; +const xhrTargets = new WeakMap(); + +const XHR_EVENTS = ['loadstart', 'error', 'abort', 'timeout', 'load', 'loadend']; +const MAX_XHR_EVENTS = 12; /* eslint-disable no-param-reassign */ const patchAbort = (proto: { abort: (...a: any[]) => any }, api: string) => { const original = proto.abort; const wrapper = function abortProbe(this: unknown, ...args: any[]) { const frames = formatAbortStack(new Error().stack ?? ''); - collectors.forEach((calls) => { + probes.forEach(({ calls }) => { if (calls.length < MAX_ABORT_CALLS) calls.push({ api, frames }); }); return original.apply(this, args); @@ -230,30 +247,87 @@ const patchAbort = (proto: { abort: (...a: any[]) => any }, api: string) => { if (proto.abort === wrapper) proto.abort = original; }; }; + +// Upload XHRs only; the path is kept and the query (it holds the access token) is dropped. +const patchXhr = (proto: XMLHttpRequest) => { + const { open, send } = proto; + const openWrapper = function openProbe(this: XMLHttpRequest, ...args: any[]) { + try { + const url = new URL(String(args[1]), 'http://x'); + if (url.pathname.includes('/upload')) { + xhrTargets.set(this, { method: String(args[0]).toUpperCase(), path: url.pathname }); + } + } catch { + // not a URL we can read; the request is not recorded + } + return (open as any).apply(this, args); + }; + const sendWrapper = function sendProbe(this: XMLHttpRequest, ...args: any[]) { + const target = xhrTargets.get(this); + if (target && probes.size > 0) { + const startedAt = Date.now(); + const events: string[] = []; + const note = (name: string) => () => { + if (events.length < MAX_XHR_EVENTS) events.push(`${name} +${Date.now() - startedAt}ms`); + }; + XHR_EVENTS.forEach((ev) => this.addEventListener(ev, note(ev))); + XHR_EVENTS.forEach((ev) => this.upload?.addEventListener(ev, note(`upload.${ev}`))); + this.upload?.addEventListener( + 'progress', + (() => { + let first = true; + return () => { + if (first) note('upload.progress')(); + first = false; + }; + })() + ); + const record: XhrRecord = { xhr: this, ...target, startedAt, events }; + probes.forEach((probe) => probe.xhrs.push(record)); + } + return (send as any).apply(this, args); + }; + (proto as any).open = openWrapper; + (proto as any).send = sendWrapper; + return () => { + if ((proto as any).open === openWrapper) (proto as any).open = open; + if ((proto as any).send === sendWrapper) (proto as any).send = send; + }; +}; /* eslint-enable no-param-reassign */ +const describeXhr = (r: XhrRecord): string => + `${r.method} ${r.path} readyState ${r.xhr.readyState} status ${ + r.xhr.status + } events [${r.events.join(', ')}]`; + /** - * Records every XMLHttpRequest.abort and AbortController.abort the page makes until stopped. The - * prototypes are wrapped while at least one probe runs and restored when the last one stops. + * Records, until stopped, every XMLHttpRequest.abort and AbortController.abort the page makes and + * the lifecycle events and end state of upload XHRs. The prototypes are wrapped while at least one + * probe runs and restored when the last one stops; the wrappers change no behaviour. */ export const startAbortProbe = () => { - const calls: AbortCall[] = []; - if (collectors.size === 0) { + const probe: Probe = { calls: [], xhrs: [] }; + if (probes.size === 0) { const restores: (() => void)[] = []; if (typeof XMLHttpRequest !== 'undefined') { restores.push(patchAbort(XMLHttpRequest.prototype, 'XHR.abort')); + restores.push(patchXhr(XMLHttpRequest.prototype)); } if (typeof AbortController !== 'undefined') { restores.push(patchAbort(AbortController.prototype, 'AbortController.abort')); } restoreProbe = () => restores.forEach((r) => r()); } - collectors.add(calls); + probes.add(probe); return { - calls, + calls: probe.calls, + xhrs: probe.xhrs, + describeXhrs: () => + probe.xhrs.length === 0 ? 'xhr: none seen' : `xhr: ${probe.xhrs.map(describeXhr).join('; ')}`, stop: () => { - collectors.delete(calls); - if (collectors.size === 0) { + probes.delete(probe); + if (probes.size === 0) { restoreProbe?.(); restoreProbe = undefined; } @@ -312,6 +386,8 @@ export const uploadFailure = async ( : `abort calls: ${trace.aborts.map((a) => `${a.api} ${a.frames}`).join('; ')}` ); } + if (trace.xhr) parts.push(trace.xhr); + if (trace.signalAborted !== undefined) parts.push(`signal.aborted ${trace.signalAborted}`); if (trace.lifecycle) parts.push(trace.lifecycle); if (trace.previous) parts.push(`retried (attempt 1: ${trace.previous})`); } @@ -326,6 +402,13 @@ export const uploadFailure = async ( // here has shown that is the cause; the failure text records both attempts so it can be checked. const RETRY_WINDOW_MS = 3000; +const copyInMemory = async (file: TUploadContent): Promise => { + const bytes = await file.arrayBuffer(); + return typeof File !== 'undefined' && file instanceof File + ? new File([bytes], file.name, { type: file.type, lastModified: file.lastModified }) + : new Blob([bytes], { type: file.type }); +}; + export const uploadContent = async ( mx: MatrixClient, file: TUploadContent, @@ -344,11 +427,29 @@ export const uploadContent = async ( let previous: string | undefined; for (let attempt = 1; ; attempt += 1) { + // The browser may hold a picked file as a handle it cannot always re-read (e.g. content://), so + // each attempt uploads a copy held in memory; a file that cannot be read fails here, named. + let body: TUploadContent; + try { + // eslint-disable-next-line no-await-in-loop + body = await copyInMemory(file); + } catch (readErr: any) { + const errcode = readErr?.name || 'Error'; + onError( + new MatrixError({ + error: `Upload failed: file unreadable before upload (${errcode}) (${file.size} bytes, ${ + file.type || 'unknown type' + })`, + errcode, + }) + ); + return; + } const probe = startAbortProbe(); const trace: UploadTrace = { startedAt: Date.now(), previous, aborts: probe.calls }; const abortController = new AbortController(); try { - const uploadPromise = mx.uploadContent(file, { + const uploadPromise = mx.uploadContent(body, { name, type: fileType, includeFilename: !hideFilename, @@ -370,6 +471,8 @@ export const uploadContent = async ( return; } catch (e: any) { const elapsed = Date.now() - trace.startedAt; + trace.xhr = probe.describeXhrs(); + trace.signalAborted = abortController.signal.aborted; const retry = attempt === 1 && e?.name === 'AbortError' && @@ -386,7 +489,7 @@ export const uploadContent = async ( trace.aborts?.length ? `abort calls: ${trace.aborts.map((a) => `${a.api} ${a.frames}`).join('; ')}` : 'abort calls: none' - }`; + }, ${trace.xhr}`; } } finally { probe.stop(); diff --git a/src/app/utils/uploadXhrProbe.test.ts b/src/app/utils/uploadXhrProbe.test.ts new file mode 100644 index 00000000..e72afa3d --- /dev/null +++ b/src/app/utils/uploadXhrProbe.test.ts @@ -0,0 +1,163 @@ +/* eslint-disable class-methods-use-this, @typescript-eslint/no-empty-function, @typescript-eslint/no-unused-vars, no-unused-vars */ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { startAbortProbe, uploadContent } from './matrix'; + +class FakeXhr extends EventTarget { + upload = new EventTarget(); + + readyState = 0; + + status = 0; + + open(_method: string, _url: string) { + this.readyState = 1; + } + + send() { + this.readyState = 2; + } + + abort() {} +} + +const origOpen = FakeXhr.prototype.open; +const origSend = FakeXhr.prototype.send; +const uploadUrl = + 'https://matrix.example/_matrix/media/v3/upload?filename=a.jpg&access_token=SECRET'; + +describe('XHR probe', () => { + afterEach(() => { + vi.unstubAllGlobals(); + FakeXhr.prototype.open = origOpen; + FakeXhr.prototype.send = origSend; + }); + + it('records which events an upload XHR fired and how it ended, without the query string', () => { + vi.stubGlobal('XMLHttpRequest', FakeXhr); + const probe = startAbortProbe(); + const x = new FakeXhr(); + x.open('POST', uploadUrl); + x.send(); + x.upload.dispatchEvent(new Event('loadstart')); + x.upload.dispatchEvent(new Event('error')); + x.dispatchEvent(new Event('error')); + x.readyState = 4; + x.status = 0; + probe.stop(); + expect(probe.xhrs).toHaveLength(1); + const text = probe.describeXhrs(); + expect(text).toContain('POST /_matrix/media/v3/upload'); + expect(text).toContain('readyState 4 status 0'); + expect(text).toContain('upload.loadstart'); + expect(text).toContain('upload.error'); + expect(text).toContain('error'); + expect(text).not.toContain('SECRET'); + expect(text).not.toContain('access_token'); + }); + + it('ignores XHRs that are not uploads', () => { + vi.stubGlobal('XMLHttpRequest', FakeXhr); + const probe = startAbortProbe(); + const x = new FakeXhr(); + x.open('GET', 'https://matrix.example/_matrix/client/v3/sync'); + x.send(); + probe.stop(); + expect(probe.xhrs).toHaveLength(0); + expect(probe.describeXhrs()).toBe('xhr: none seen'); + }); + + it('still calls the real open and send and restores both afterwards', () => { + const open = vi.fn(); + const send = vi.fn(); + FakeXhr.prototype.open = open; + FakeXhr.prototype.send = send; + vi.stubGlobal('XMLHttpRequest', FakeXhr); + const probe = startAbortProbe(); + const x = new FakeXhr(); + x.open('POST', uploadUrl); + x.send(); + expect(open).toHaveBeenCalledWith('POST', uploadUrl); + expect(send).toHaveBeenCalledTimes(1); + probe.stop(); + expect(FakeXhr.prototype.open).toBe(open); + expect(FakeXhr.prototype.send).toBe(send); + }); + + it('records nothing for requests made after stop', () => { + vi.stubGlobal('XMLHttpRequest', FakeXhr); + const probe = startAbortProbe(); + probe.stop(); + const x = new FakeXhr(); + x.open('POST', uploadUrl); + x.send(); + expect(probe.xhrs).toHaveLength(0); + }); +}); + +describe('uploadContent in-memory copy and failure line', () => { + const abort = () => new DOMException('', 'AbortError'); + + it('uploads an in-memory copy with the same bytes, name and type, not the original', async () => { + const file = new File(['abcdef'], 'a.jpg', { type: 'image/jpeg' }); + let sent: any; + const mx = { + uploadContent: vi.fn(async (f: any) => { + sent = f; + return { content_uri: 'mxc://a/b' }; + }), + } as any; + await uploadContent(mx, file, { onSuccess: vi.fn(), onError: vi.fn() }); + expect(sent).not.toBe(file); + expect(sent.type).toBe('image/jpeg'); + expect(sent.name).toBe('a.jpg'); + expect(await sent.text()).toBe('abcdef'); + }); + + it('copies again before the retry', async () => { + const seen: any[] = []; + const mx = { + uploadContent: vi.fn((f: any) => { + seen.push(f); + return seen.length === 1 + ? Promise.reject(abort()) + : Promise.resolve({ content_uri: 'mxc://a/b' }); + }), + } as any; + await uploadContent(mx, new Blob(['xy']), { onSuccess: vi.fn(), onError: vi.fn() }); + expect(seen).toHaveLength(2); + expect(seen[0]).not.toBe(seen[1]); + }); + + it('reports an unreadable file and never starts the upload', async () => { + const bad = new Blob(['x']); + (bad as any).arrayBuffer = () => Promise.reject(new DOMException('gone', 'NotReadableError')); + const mx = { uploadContent: vi.fn() } as any; + const onError = vi.fn(); + await uploadContent(mx, bad, { onSuccess: vi.fn(), onError }); + expect(mx.uploadContent).not.toHaveBeenCalled(); + expect(onError.mock.calls[0][0].message).toContain( + 'unreadable before upload (NotReadableError)' + ); + }); + + it('puts the XHR end state and signal.aborted in the failure line', async () => { + vi.stubGlobal('XMLHttpRequest', FakeXhr); + const mx = { + uploadContent: () => { + const x = new FakeXhr(); + x.open('POST', uploadUrl); + x.send(); + x.upload.dispatchEvent(new Event('error')); + x.readyState = 4; + return Promise.reject(new Error('Timeout')); + }, + } as any; + const onError = vi.fn(); + await uploadContent(mx, new Blob(['x']), { onSuccess: vi.fn(), onError }); + const msg = onError.mock.calls[0][0].message; + expect(msg).toContain('readyState 4 status 0'); + expect(msg).toContain('upload.error'); + expect(msg).toContain('signal.aborted false'); + vi.unstubAllGlobals(); + }); +}); -- 2.47.3