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 2859dbc3..f0ded881 100644 --- a/src/app/utils/matrix.ts +++ b/src/app/utils/matrix.ts @@ -143,6 +143,270 @@ 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(); +export const noteUserActivity = () => { + lastUserActivity = Date.now(); +}; +if (typeof window !== 'undefined') { + ['pointerdown', 'keydown', 'touchstart'].forEach((type) => + window.addEventListener(type, noteUserActivity, { capture: true, passive: true }) + ); +} + +export type AbortCall = { api: string; frames: string }; + +/** What the progress handler saw of one upload, for the failure text. */ +export type UploadTrace = { + startedAt: number; + firstProgressAt?: number; + loaded?: number; + 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; + /** Upload XHR events and end state, and whether our own signal was aborted. */ + xhr?: string; + signalAborted?: boolean; +}; + +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}`; +}; + +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 ?? ''); + probes.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; + }; +}; + +// 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, 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 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()); + } + probes.add(probe); + return { + calls: probe.calls, + xhrs: probe.xhrs, + describeXhrs: () => + probe.xhrs.length === 0 ? 'xhr: none seen' : `xhr: ${probe.xhrs.map(describeXhr).join('; ')}`, + stop: () => { + probes.delete(probe); + if (probes.size === 0) { + restoreProbe?.(); + restoreProbe = undefined; + } + }, + }; +}; + +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 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, + 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; + try { + await file.arrayBuffer(); + readable = 'file readable'; + } catch (readErr: any) { + readable = `file unreadable (${readErr?.name ?? 'unknown'})`; + } + const parts = [`${file.size} bytes`, file.type || 'unknown type', readable, describeNetwork()]; + parts.push(`idle ${now - lastUserActivity}ms`); + if (trace) { + parts.push( + trace.firstProgressAt === undefined + ? 'no progress' + : `first progress ${trace.firstProgressAt - trace.startedAt}ms ${trace.loaded ?? 0}/${ + trace.total ?? '?' + }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.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})`); + } + const error = `Upload failed: ${message ? `${name}: ${message}` : name} (${parts.join(', ')})`; + const err = new MatrixError({ error, errcode: name }); + (err as any).cause = e; + 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; + +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 ( @@ -150,24 +414,86 @@ export const uploadContent = async ( file: TUploadContent, options: ContentUploadOptions ) => { - const { name, fileType, hideFilename, onProgress, onPromise, onSuccess, onError } = options; - - const uploadPromise = mx.uploadContent(file, { + const { name, - type: fileType, - includeFilename: !hideFilename, - progressHandler: onProgress, - }); - onPromise?.(uploadPromise); - try { - const data = await uploadPromise; - const mxc = data.content_uri; - 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 })); + fileType, + hideFilename, + onProgress, + onPromise, + onSuccess, + onError, + describeLifecycle, + } = options; + + 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(body, { + 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; + trace.xhr = probe.describeXhrs(); + trace.signalAborted = abortController.signal.aborted; + 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' + }, ${trace.xhr}`; + } + } 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'); + }); +}); diff --git a/src/app/utils/uploadFailure.test.ts b/src/app/utils/uploadFailure.test.ts new file mode 100644 index 00000000..ec1e8115 --- /dev/null +++ b/src/app/utils/uploadFailure.test.ts @@ -0,0 +1,100 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { noteUserActivity, 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'); + }); +}); + +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'); + }); +}); 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); + }); +}); 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(); + }); +});