Upload failures keep name, size and file readability #34

Merged
void merged 5 commits from void/upload-error-diagnostics into dev 2026-11-09 10:59:53 +00:00
6 changed files with 920 additions and 30 deletions

View file

@ -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<ComponentLifecycle>({ 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<UploadResponse>) => 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<UploadResponse>) => 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) {

View file

@ -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<Probe>();
let restoreProbe: (() => void) | undefined;
const xhrTargets = new WeakMap<object, { method: string; path: string }>();
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<MatrixError> => {
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<TUploadContent> => {
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();
}
}
};

View file

@ -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');
});
});

View file

@ -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');
});
});

View file

@ -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<any>;
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);
});
});

View file

@ -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();
});
});