fix(web): retry-after-abort starts fresh tus session; preserve File past abort

This commit is contained in:
Jannis Braun
2026-05-02 18:18:07 +02:00
parent 61dea725a3
commit dd91f5f349
4 changed files with 77 additions and 29 deletions
+8 -5
View File
@@ -522,13 +522,16 @@ export function Message({ message, isCompact, isFirstInGroup, previousMessageId
<button
onClick={async () => {
const transfers = useTransferStore.getState().transfers;
const failedIds = pending.transferIds.filter(
(tid) => transfers.get(tid)?.state === 'failed',
);
for (const tid of failedIds) {
const retryIds = pending.transferIds.filter((tid) => {
const s = transfers.get(tid)?.state;
return s === 'failed' || s === 'aborted';
});
// Flip the bubble back to 'sending' first so the orchestrator
// re-evaluates after the resumed transfers complete.
usePendingMessageStore.getState().markSending(pending.clientId);
for (const tid of retryIds) {
await useTransferStore.getState().resumeUpload(tid);
}
usePendingMessageStore.getState().markSending(pending.clientId);
}}
className="px-2 py-0.5 rounded-md text-[11.5px] font-medium text-accent-mint bg-accent-mint/10 hover:bg-accent-mint/20 transition-colors"
>
@@ -258,8 +258,11 @@ export function MessageInput({ channelId, channelName }: MessageInputProps) {
// bytes get cleaned by the storage janitor (per docs/systems/uploads.md).
useTransferStore.getState().remove(transferId);
} else {
// abortUpload sets state='aborted' and tears down the live tus instance.
// Tear down the live tus instance, then drop the transfer + free the
// retained File reference. (abortUpload alone leaves the record in the
// store for retry-after-abort; here the user is fully discarding.)
abortUpload(transferId);
useTransferStore.getState().remove(transferId);
}
removeStaged(channelId, transferId);
},
+58 -17
View File
@@ -200,6 +200,7 @@ export const useTransferStore = create<TransferStore>()(
if (!s.transfers.has(id)) return s;
const next = new Map(s.transfers);
next.delete(id);
liveUploadFiles.delete(id);
return { transfers: next };
}),
@@ -287,7 +288,7 @@ export const useTransferStore = create<TransferStore>()(
void u.abort(true).catch(() => { /* server may be unreachable; that's OK */ });
liveUploads.delete(id);
}
liveUploadFiles.delete(id);
// Keep liveUploadFiles entry — needed for retry-after-abort. Cleared by remove().
get().setState_(id, 'aborted');
},
@@ -304,14 +305,6 @@ export const useTransferStore = create<TransferStore>()(
resumeUpload: async (id) => {
const t = get().get(id);
if (!t || t.type !== 'upload') return;
if (!t.tusUploadUrl) {
get().setError(id, { message: 'No tus URL — cannot resume', permanent: true });
return;
}
if (Date.now() > (t.tusExpiresAt ?? 0)) {
get().setError(id, { message: 'Upload expired', permanent: true });
return;
}
const token = useAuthStore.getState().token;
if (!token) {
@@ -320,7 +313,7 @@ export const useTransferStore = create<TransferStore>()(
}
// Try in-memory File (same-session resume — file picker, paste, browsers
// without getAsFileSystemHandle).
// without getAsFileSystemHandle, or retry after abort).
let blob: Blob | undefined = liveUploadFiles.get(id);
// Fall back to persisted FileSystemFileHandle (cross-reload resume on Chrome/Edge).
@@ -340,31 +333,77 @@ export const useTransferStore = create<TransferStore>()(
if (!blob) {
// No handle, no in-memory file — surface "re-pick to resume" to the user.
// (Cross-reload on Firefox/Safari or after MessageInput unmount that cleared
// its preview-URL ref will land here.)
// (Cross-reload on Firefox/Safari, or after MessageInput unmount cleared
// the in-memory File and the bubble survives without an FS handle.)
get().setState_(id, 'paused');
return;
}
const upload = new Upload(blob as File, {
// Resume the existing tus session if we have a valid, non-expired URL and
// we weren't aborted. Otherwise start a fresh session reusing this transferId
// (retry-after-abort, retry-after-failure, retry-after-expiry).
const canResume =
!!t.tusUploadUrl &&
t.state !== 'aborted' &&
Date.now() <= (t.tusExpiresAt ?? 0);
if (!canResume) {
// Reset transient state so a fresh tus session starts cleanly.
set((s) => {
const cur = s.transfers.get(id);
if (!cur) return s;
const next = new Map(s.transfers);
next.set(id, {
...cur,
tusUploadUrl: undefined,
tusExpiresAt: undefined,
attachmentId: undefined,
attachmentFilename: undefined,
error: undefined,
progress: { loaded: 0, total: blob!.size },
});
return { transfers: next };
});
}
get().setState_(id, 'queued');
const fileLike = blob instanceof File
? { name: blob.name, mimetype: blob.type || 'application/octet-stream' }
: { name: t.file.name, mimetype: blob.type || t.file.mimetype };
const tusOpts: UploadOptions = {
endpoint: t.origin ? `${t.origin}/api/files/` : '/api/files/',
uploadUrl: t.tusUploadUrl,
retryDelays: [0, 1000, 3000, 5000, 10_000],
chunkSize: 5 * 1024 * 1024,
headers: { Authorization: `Bearer ${token}` },
...(canResume
? { uploadUrl: t.tusUploadUrl }
: { metadata: { filename: fileLike.name, filetype: fileLike.mimetype } }),
onProgress: (loaded: number) => {
get().updateProgress(id, loaded);
const cur = get().get(id);
if (cur?.state !== 'active') get().setState_(id, 'active');
},
onAfterResponse: (_req, res) => {
// Capture the new Location for fresh sessions; resume reuses the existing one.
if (canResume) return;
const location = res.getHeader('Location');
const expires = res.getHeader('Upload-Expires');
if (location && !get().get(id)?.tusUploadUrl) {
const expiresMs = expires ? new Date(expires).getTime() : Date.now() + 24 * 60 * 60 * 1000;
get().setTusUrl(id, location, expiresMs);
}
},
onSuccess: (payload) => {
try {
const body = payload.lastResponse?.getBody?.() ?? '';
const att = JSON.parse(body) as Attachment;
get().setAttachmentRef(id, att.id, att.filename);
get().setState_(id, 'completed');
get().updateProgress(id, blob!.size);
} catch (e) {
const msg = e instanceof Error ? e.message : 'Resume completed but parse failed';
const msg = e instanceof Error ? e.message : 'Upload completed but parse failed';
get().setError(id, { message: msg, permanent: true });
} finally {
liveUploads.delete(id);
@@ -372,12 +411,14 @@ export const useTransferStore = create<TransferStore>()(
}
},
onError: (err: Error) => {
const msg = err.message ?? 'Resume error';
const msg = err.message ?? 'Upload error';
const permanent = /\b4\d\d\b/.test(msg);
get().setError(id, { message: msg, permanent });
liveUploads.delete(id);
},
});
};
const upload = new Upload(blob as File, tusOpts);
liveUploads.set(id, upload);
upload.start();
},
@@ -123,7 +123,7 @@ describe('transferStore.resumeUpload', () => {
abortMock.mockClear();
});
it('returns early when transfer has no tusUploadUrl', async () => {
it('stays paused when transfer has no tusUploadUrl and no available blob', async () => {
const id = useTransferStore.getState().createTransfer({
type: 'upload',
file: { name: 'a.png', size: 100, mimetype: 'image/png' },
@@ -132,11 +132,12 @@ describe('transferStore.resumeUpload', () => {
useTransferStore.getState().setState_(id, 'paused');
await useTransferStore.getState().resumeUpload(id);
const t = useTransferStore.getState().get(id)!;
expect(t.state).toBe('failed');
expect(t.error?.message).toMatch(/no tus url/i);
// No URL + no in-memory file + no handle → can neither resume nor restart;
// surface 'paused' to prompt the user to re-pick.
expect(t.state).toBe('paused');
});
it('returns failed when tus URL has expired', async () => {
it('stays paused when tus URL has expired and no available blob', async () => {
const id = useTransferStore.getState().createTransfer({
type: 'upload',
file: { name: 'a.png', size: 100, mimetype: 'image/png' },
@@ -145,8 +146,8 @@ describe('transferStore.resumeUpload', () => {
useTransferStore.getState().setTusUrl(id, '/api/files/expired', Date.now() - 1000);
useTransferStore.getState().setState_(id, 'paused');
await useTransferStore.getState().resumeUpload(id);
expect(useTransferStore.getState().get(id)!.state).toBe('failed');
expect(useTransferStore.getState().get(id)!.error?.message).toMatch(/expired/i);
// Expired URL with no blob to restart → stay paused, user re-picks.
expect(useTransferStore.getState().get(id)!.state).toBe('paused');
});
it('stays paused when no FS handle is available (re-pick required)', async () => {