fix(web): retry-after-abort starts fresh tus session; preserve File past abort
This commit is contained in:
@@ -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);
|
||||
},
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
Reference in New Issue
Block a user