From 4e5a44017662350ff5c200ccd6e5e0bdd89a2870 Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Sat, 2 May 2026 18:22:45 +0200 Subject: [PATCH] fix(web): server-side DELETE for failed/discarded uploads (no janitor wait) --- packages/web/src/components/chat/Message.tsx | 11 ++++++++--- .../web/src/components/chat/MessageInput.tsx | 16 +++++++++------- packages/web/src/stores/transferStore.ts | 19 ++++++++++++++++++- 3 files changed, 35 insertions(+), 11 deletions(-) diff --git a/packages/web/src/components/chat/Message.tsx b/packages/web/src/components/chat/Message.tsx index fa0610d2..6c046bc2 100644 --- a/packages/web/src/components/chat/Message.tsx +++ b/packages/web/src/components/chat/Message.tsx @@ -542,11 +542,16 @@ export function Message({ message, isCompact, isFirstInGroup, previousMessageId const transfers = useTransferStore.getState().transfers; for (const tid of pending.transferIds) { const t = transfers.get(tid); - if (t && (t.state === 'active' || t.state === 'paused' || t.state === 'queued')) { + if (!t) continue; + // Route anything with potential server-side state (active/paused/ + // queued/failed) through abortUpload — it sends DELETE so the + // .tus session doesn't sit on disk for the janitor to sweep. + // 'aborted' already cleaned itself; 'completed' has a finalized + // attachment row that the unlinked-attachment janitor handles (1h grace). + if (t.state !== 'aborted' && t.state !== 'completed') { useTransferStore.getState().abortUpload(tid); - } else { - useTransferStore.getState().remove(tid); } + useTransferStore.getState().remove(tid); } if (channelKey) { usePendingMessageStore.getState().removeByClientId(channelKey, pending.clientId); diff --git a/packages/web/src/components/chat/MessageInput.tsx b/packages/web/src/components/chat/MessageInput.tsx index a56effcb..91abcc97 100644 --- a/packages/web/src/components/chat/MessageInput.tsx +++ b/packages/web/src/components/chat/MessageInput.tsx @@ -253,15 +253,17 @@ export function MessageInput({ channelId, channelName }: MessageInputProps) { const t = useTransferStore.getState().transfers.get(transferId); if (t?.state === 'completed') { - // User is discarding a fully-uploaded attachment. Drop it entirely so - // no orphan 'aborted'-with-attachmentId record persists. Server-side - // bytes get cleaned by the storage janitor (per docs/systems/uploads.md). + // Fully-uploaded attachment with a finalized DB row. Server-side bytes + // get cleaned by the unlinked-attachment janitor (1h grace). + useTransferStore.getState().remove(transferId); + } else if (t && t.state !== 'aborted') { + // active/paused/queued/failed: abortUpload tears down any live tus + // instance AND sends DELETE for orphaned server-side .tus sessions. + // Then drop the transfer + free the retained File reference. + abortUpload(transferId); useTransferStore.getState().remove(transferId); } else { - // 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); + // Already 'aborted' (server already cleaned); just drop the record. useTransferStore.getState().remove(transferId); } removeStaged(channelId, transferId); diff --git a/packages/web/src/stores/transferStore.ts b/packages/web/src/stores/transferStore.ts index b1d08168..7af9e51e 100644 --- a/packages/web/src/stores/transferStore.ts +++ b/packages/web/src/stores/transferStore.ts @@ -281,12 +281,29 @@ export const useTransferStore = create()( }, abortUpload: (id) => { + const t = get().get(id); const u = liveUploads.get(id); if (u) { // tus-js-client v4: abort(true) deletes server-side state via DELETE. - // We pass true so the user actually frees the slot, not just "pause". void u.abort(true).catch(() => { /* server may be unreachable; that's OK */ }); liveUploads.delete(id); + } else if (t?.tusUploadUrl && !t.attachmentId) { + // No live instance, but server-side .tus state still exists (e.g., this + // transfer failed mid-flight or was paused with a stored URL). Send DELETE + // directly so the partial bytes don't sit on disk until the janitor sweeps. + const token = useAuthStore.getState().token; + if (token) { + const fullUrl = t.tusUploadUrl.startsWith('http') + ? t.tusUploadUrl + : `${t.origin ?? ''}${t.tusUploadUrl}`; + void fetch(fullUrl, { + method: 'DELETE', + headers: { + Authorization: `Bearer ${token}`, + 'Tus-Resumable': '1.0.0', + }, + }).catch(() => { /* server unreachable — janitor will eventually clean */ }); + } } // Keep liveUploadFiles entry — needed for retry-after-abort. Cleared by remove(). get().setState_(id, 'aborted');