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
|
<button
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
const transfers = useTransferStore.getState().transfers;
|
const transfers = useTransferStore.getState().transfers;
|
||||||
const failedIds = pending.transferIds.filter(
|
const retryIds = pending.transferIds.filter((tid) => {
|
||||||
(tid) => transfers.get(tid)?.state === 'failed',
|
const s = transfers.get(tid)?.state;
|
||||||
);
|
return s === 'failed' || s === 'aborted';
|
||||||
for (const tid of failedIds) {
|
});
|
||||||
|
// 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);
|
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"
|
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).
|
// bytes get cleaned by the storage janitor (per docs/systems/uploads.md).
|
||||||
useTransferStore.getState().remove(transferId);
|
useTransferStore.getState().remove(transferId);
|
||||||
} else {
|
} 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);
|
abortUpload(transferId);
|
||||||
|
useTransferStore.getState().remove(transferId);
|
||||||
}
|
}
|
||||||
removeStaged(channelId, transferId);
|
removeStaged(channelId, transferId);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -200,6 +200,7 @@ export const useTransferStore = create<TransferStore>()(
|
|||||||
if (!s.transfers.has(id)) return s;
|
if (!s.transfers.has(id)) return s;
|
||||||
const next = new Map(s.transfers);
|
const next = new Map(s.transfers);
|
||||||
next.delete(id);
|
next.delete(id);
|
||||||
|
liveUploadFiles.delete(id);
|
||||||
return { transfers: next };
|
return { transfers: next };
|
||||||
}),
|
}),
|
||||||
|
|
||||||
@@ -287,7 +288,7 @@ export const useTransferStore = create<TransferStore>()(
|
|||||||
void u.abort(true).catch(() => { /* server may be unreachable; that's OK */ });
|
void u.abort(true).catch(() => { /* server may be unreachable; that's OK */ });
|
||||||
liveUploads.delete(id);
|
liveUploads.delete(id);
|
||||||
}
|
}
|
||||||
liveUploadFiles.delete(id);
|
// Keep liveUploadFiles entry — needed for retry-after-abort. Cleared by remove().
|
||||||
get().setState_(id, 'aborted');
|
get().setState_(id, 'aborted');
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -304,14 +305,6 @@ export const useTransferStore = create<TransferStore>()(
|
|||||||
resumeUpload: async (id) => {
|
resumeUpload: async (id) => {
|
||||||
const t = get().get(id);
|
const t = get().get(id);
|
||||||
if (!t || t.type !== 'upload') return;
|
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;
|
const token = useAuthStore.getState().token;
|
||||||
if (!token) {
|
if (!token) {
|
||||||
@@ -320,7 +313,7 @@ export const useTransferStore = create<TransferStore>()(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Try in-memory File (same-session resume — file picker, paste, browsers
|
// 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);
|
let blob: Blob | undefined = liveUploadFiles.get(id);
|
||||||
|
|
||||||
// Fall back to persisted FileSystemFileHandle (cross-reload resume on Chrome/Edge).
|
// Fall back to persisted FileSystemFileHandle (cross-reload resume on Chrome/Edge).
|
||||||
@@ -340,31 +333,77 @@ export const useTransferStore = create<TransferStore>()(
|
|||||||
|
|
||||||
if (!blob) {
|
if (!blob) {
|
||||||
// No handle, no in-memory file — surface "re-pick to resume" to the user.
|
// 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
|
// (Cross-reload on Firefox/Safari, or after MessageInput unmount cleared
|
||||||
// its preview-URL ref will land here.)
|
// the in-memory File and the bubble survives without an FS handle.)
|
||||||
get().setState_(id, 'paused');
|
get().setState_(id, 'paused');
|
||||||
return;
|
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/',
|
endpoint: t.origin ? `${t.origin}/api/files/` : '/api/files/',
|
||||||
uploadUrl: t.tusUploadUrl,
|
|
||||||
retryDelays: [0, 1000, 3000, 5000, 10_000],
|
retryDelays: [0, 1000, 3000, 5000, 10_000],
|
||||||
chunkSize: 5 * 1024 * 1024,
|
chunkSize: 5 * 1024 * 1024,
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
...(canResume
|
||||||
|
? { uploadUrl: t.tusUploadUrl }
|
||||||
|
: { metadata: { filename: fileLike.name, filetype: fileLike.mimetype } }),
|
||||||
onProgress: (loaded: number) => {
|
onProgress: (loaded: number) => {
|
||||||
get().updateProgress(id, loaded);
|
get().updateProgress(id, loaded);
|
||||||
const cur = get().get(id);
|
const cur = get().get(id);
|
||||||
if (cur?.state !== 'active') get().setState_(id, 'active');
|
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) => {
|
onSuccess: (payload) => {
|
||||||
try {
|
try {
|
||||||
const body = payload.lastResponse?.getBody?.() ?? '';
|
const body = payload.lastResponse?.getBody?.() ?? '';
|
||||||
const att = JSON.parse(body) as Attachment;
|
const att = JSON.parse(body) as Attachment;
|
||||||
get().setAttachmentRef(id, att.id, att.filename);
|
get().setAttachmentRef(id, att.id, att.filename);
|
||||||
get().setState_(id, 'completed');
|
get().setState_(id, 'completed');
|
||||||
|
get().updateProgress(id, blob!.size);
|
||||||
} catch (e) {
|
} 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 });
|
get().setError(id, { message: msg, permanent: true });
|
||||||
} finally {
|
} finally {
|
||||||
liveUploads.delete(id);
|
liveUploads.delete(id);
|
||||||
@@ -372,12 +411,14 @@ export const useTransferStore = create<TransferStore>()(
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
onError: (err: Error) => {
|
onError: (err: Error) => {
|
||||||
const msg = err.message ?? 'Resume error';
|
const msg = err.message ?? 'Upload error';
|
||||||
const permanent = /\b4\d\d\b/.test(msg);
|
const permanent = /\b4\d\d\b/.test(msg);
|
||||||
get().setError(id, { message: msg, permanent });
|
get().setError(id, { message: msg, permanent });
|
||||||
liveUploads.delete(id);
|
liveUploads.delete(id);
|
||||||
},
|
},
|
||||||
});
|
};
|
||||||
|
|
||||||
|
const upload = new Upload(blob as File, tusOpts);
|
||||||
liveUploads.set(id, upload);
|
liveUploads.set(id, upload);
|
||||||
upload.start();
|
upload.start();
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -123,7 +123,7 @@ describe('transferStore.resumeUpload', () => {
|
|||||||
abortMock.mockClear();
|
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({
|
const id = useTransferStore.getState().createTransfer({
|
||||||
type: 'upload',
|
type: 'upload',
|
||||||
file: { name: 'a.png', size: 100, mimetype: 'image/png' },
|
file: { name: 'a.png', size: 100, mimetype: 'image/png' },
|
||||||
@@ -132,11 +132,12 @@ describe('transferStore.resumeUpload', () => {
|
|||||||
useTransferStore.getState().setState_(id, 'paused');
|
useTransferStore.getState().setState_(id, 'paused');
|
||||||
await useTransferStore.getState().resumeUpload(id);
|
await useTransferStore.getState().resumeUpload(id);
|
||||||
const t = useTransferStore.getState().get(id)!;
|
const t = useTransferStore.getState().get(id)!;
|
||||||
expect(t.state).toBe('failed');
|
// No URL + no in-memory file + no handle → can neither resume nor restart;
|
||||||
expect(t.error?.message).toMatch(/no tus url/i);
|
// 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({
|
const id = useTransferStore.getState().createTransfer({
|
||||||
type: 'upload',
|
type: 'upload',
|
||||||
file: { name: 'a.png', size: 100, mimetype: 'image/png' },
|
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().setTusUrl(id, '/api/files/expired', Date.now() - 1000);
|
||||||
useTransferStore.getState().setState_(id, 'paused');
|
useTransferStore.getState().setState_(id, 'paused');
|
||||||
await useTransferStore.getState().resumeUpload(id);
|
await useTransferStore.getState().resumeUpload(id);
|
||||||
expect(useTransferStore.getState().get(id)!.state).toBe('failed');
|
// Expired URL with no blob to restart → stay paused, user re-picks.
|
||||||
expect(useTransferStore.getState().get(id)!.error?.message).toMatch(/expired/i);
|
expect(useTransferStore.getState().get(id)!.state).toBe('paused');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('stays paused when no FS handle is available (re-pick required)', async () => {
|
it('stays paused when no FS handle is available (re-pick required)', async () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user