fix: Bug - Sending files between users doesn't really work

Stream oversized generic attachments to disk instead of silently dropping chunks, avoid loading completed file downloads into renderer memory, and surface a clear error when the browser client cannot receive a file.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-13 21:50:21 +02:00
parent 924d4bbb1d
commit 95259e8943
7 changed files with 187 additions and 9 deletions

View File

@@ -364,6 +364,23 @@ describe('AttachmentTransferService', () => {
return attachment;
}
function registerIncomingGenericFile(size: number): Attachment {
const attachment: Attachment = {
id: FILE_ID,
messageId: MESSAGE_ID,
filename: 'archive.zip',
size,
mime: 'application/zip',
isImage: false,
uploaderPeerId: PEER_ID,
available: false,
receivedBytes: 0
};
runtimeStore.setAttachmentsForMessage(MESSAGE_ID, [attachment]);
return attachment;
}
it('streams playable media to disk when the store supports streaming', async () => {
attachmentStorage.canStreamToDisk.mockReturnValue(true);
@@ -409,4 +426,57 @@ describe('AttachmentTransferService', () => {
expect(service.hasPendingRequest(MESSAGE_ID, FILE_ID)).toBe(true);
});
it('streams oversized generic files to disk when the store supports streaming', async () => {
attachmentStorage.canStreamToDisk.mockReturnValue(true);
attachmentStorage.canPersistSize.mockImplementation((bytes: number) => bytes <= 256 * 1024 * 1024);
const service = createService();
const attachment = registerIncomingGenericFile(12 * 1024 * 1024);
service.handleFileChunk(chunkPayload(0, 1, [
1,
2,
3
]));
await vi.waitFor(() => expect(attachment.available).toBe(true));
expect(attachmentStorage.createWritableFile).toHaveBeenCalled();
expect(attachmentStorage.appendBase64).toHaveBeenCalled();
expect(persistence.ensureInlineDisplayObjectUrl).not.toHaveBeenCalled();
expect(persistence.saveFileToDisk).not.toHaveBeenCalled();
});
it('rejects oversized browser downloads before requesting peers', async () => {
attachmentStorage.canStreamToDisk.mockReturnValue(false);
attachmentStorage.canPersistSize.mockImplementation((bytes: number) => bytes <= 50 * 1024 * 1024);
const service = createService();
const attachment = registerIncomingGenericFile(200 * 1024 * 1024);
await service.requestFromAnyPeer(MESSAGE_ID, attachment);
expect(attachment.requestError).toBe('attachment.errors.fileTooLarge');
expect(webrtc.sendToPeer).not.toHaveBeenCalled();
});
it('assembles browser-sized generic files in memory when streaming is unavailable', async () => {
attachmentStorage.canStreamToDisk.mockReturnValue(false);
attachmentStorage.canPersistSize.mockImplementation((bytes: number) => bytes <= 50 * 1024 * 1024);
const service = createService();
const attachment = registerIncomingGenericFile(3);
service.handleFileChunk(chunkPayload(0, 1, [
1,
2,
3
]));
await vi.waitFor(() => expect(attachment.available).toBe(true));
expect(attachmentStorage.appendBase64).not.toHaveBeenCalled();
expect(persistence.saveFileToDisk).toHaveBeenCalledTimes(1);
});
});

View File

@@ -9,13 +9,20 @@ import { AttachmentStorageService } from '../../infrastructure/services/attachme
import { MAX_AUTO_SAVE_SIZE_BYTES } from '../../domain/constants/attachment.constants';
import { isImageAttachment, resolvePublishAttachmentIsImage } from '../../domain/logic/attachment-image.rules';
import { isSharingFromThisDevice } from '../../domain/logic/attachment-sharing.rules';
import { shouldCopyUploaderMediaToAppData, shouldPersistDownloadedAttachment } from '../../domain/logic/attachment.logic';
import {
canReceiveAttachment,
isAttachmentMedia,
shouldCopyUploaderMediaToAppData,
shouldPersistDownloadedAttachment,
shouldStreamAttachmentReceiveToDisk
} from '../../domain/logic/attachment.logic';
import type { Attachment, AttachmentMeta } from '../../domain/models/attachment.model';
import {
ATTACHMENT_TRANSFER_EWMA_CURRENT_WEIGHT,
ATTACHMENT_TRANSFER_EWMA_PREVIOUS_WEIGHT,
DEFAULT_ATTACHMENT_MIME_TYPE,
ATTACHMENT_DOWNLOAD_FAILED_KEY,
ATTACHMENT_FILE_TOO_LARGE_KEY,
ATTACHMENT_CHUNKS_OUT_OF_ORDER_KEY,
ATTACHMENT_OPEN_DOWNLOAD_FAILED_KEY,
ATTACHMENT_PREPARE_DOWNLOAD_FAILED_KEY,
@@ -188,6 +195,13 @@ export class AttachmentTransferService {
return;
}
if (!canReceiveAttachment(attachment, this.receiveCapabilities())) {
this.runtimeStore.deletePendingRequest(requestKey);
attachment.requestError = this.appI18n.instant(ATTACHMENT_FILE_TOO_LARGE_KEY);
this.runtimeStore.touch();
return;
}
if (clearedRequestError)
this.runtimeStore.touch();
@@ -344,7 +358,9 @@ export class AttachmentTransferService {
return;
}
if (!this.shouldReceiveToDisk(attachment) && attachment.size > MAX_AUTO_SAVE_SIZE_BYTES) {
if (!canReceiveAttachment(attachment, this.receiveCapabilities())) {
attachment.requestError = this.appI18n.instant(ATTACHMENT_FILE_TOO_LARGE_KEY);
this.runtimeStore.touch();
return;
}
@@ -784,10 +800,14 @@ export class AttachmentTransferService {
}
private shouldReceiveToDisk(attachment: Attachment): boolean {
return this.isPlayableMedia(attachment) &&
!attachment.filePath &&
this.attachmentStorage.canStreamToDisk() &&
this.attachmentStorage.canPersistSize(attachment.size);
return shouldStreamAttachmentReceiveToDisk(attachment, this.receiveCapabilities());
}
private receiveCapabilities() {
return {
canStreamToDisk: this.attachmentStorage.canStreamToDisk(),
canPersistSize: (bytes: number) => this.attachmentStorage.canPersistSize(bytes)
};
}
private enqueueDiskFileChunk(
@@ -851,6 +871,14 @@ export class AttachmentTransferService {
attachment.savedPath = assembly.path;
if (!isAttachmentMedia(attachment)) {
attachment.available = true;
this.diskReceiveAssemblies.delete(assemblyKey);
this.runtimeStore.touch();
void this.persistence.persistAttachmentMeta(attachment);
return;
}
const restoredForDisplay = await this.persistence.ensureInlineDisplayObjectUrl(attachment);
if (!restoredForDisplay) {

View File

@@ -22,3 +22,4 @@ export const ATTACHMENT_CHUNKS_OUT_OF_ORDER_KEY = 'attachment.errors.chunksOutOf
export const ATTACHMENT_WRITE_DOWNLOAD_FAILED_KEY = 'attachment.errors.writeDownloadFailed';
export const ATTACHMENT_OPEN_DOWNLOAD_FAILED_KEY = 'attachment.errors.openDownloadFailed';
export const ATTACHMENT_DOWNLOAD_FAILED_KEY = 'attachment.errors.downloadFailed';
export const ATTACHMENT_FILE_TOO_LARGE_KEY = 'attachment.errors.fileTooLarge';

View File

@@ -1,7 +1,9 @@
import {
getWatchedAttachmentRoomIdFromUrl,
isDirectMessageAttachmentRoomId,
shouldCopyUploaderMediaToAppData
shouldCopyUploaderMediaToAppData,
shouldStreamAttachmentReceiveToDisk,
canReceiveAttachment
} from './attachment.logic';
describe('attachment logic', () => {
@@ -44,4 +46,36 @@ describe('attachment logic', () => {
mime: 'video/mp4'
}, undefined, true)).toBe(false);
});
it('streams oversized generic files to disk when the store supports it', () => {
const capabilities = {
canStreamToDisk: true,
canPersistSize: (bytes: number) => bytes <= 256 * 1024 * 1024
};
expect(shouldStreamAttachmentReceiveToDisk({
size: 200 * 1024 * 1024,
mime: 'application/zip',
filePath: undefined
}, capabilities)).toBe(true);
});
it('receives browser-sized files in memory when disk streaming is unavailable', () => {
const browserCapabilities = {
canStreamToDisk: false,
canPersistSize: (bytes: number) => bytes <= 50 * 1024 * 1024
};
expect(canReceiveAttachment({
size: 20 * 1024 * 1024,
mime: 'application/zip',
filePath: undefined
}, browserCapabilities)).toBe(true);
expect(canReceiveAttachment({
size: 200 * 1024 * 1024,
mime: 'application/zip',
filePath: undefined
}, browserCapabilities)).toBe(false);
});
});

View File

@@ -50,6 +50,49 @@ export function isDirectMessageAttachmentRoomId(roomId: string | null | undefine
return !!roomId && roomId.startsWith(DIRECT_MESSAGE_ATTACHMENT_STORAGE_PREFIX);
}
export interface AttachmentReceiveCapabilities {
canStreamToDisk: boolean;
canPersistSize: (bytes: number) => boolean;
}
export function shouldStreamAttachmentReceiveToDisk(
attachment: Pick<Attachment, 'size' | 'mime' | 'filePath'>,
capabilities: AttachmentReceiveCapabilities
): boolean {
if (attachment.filePath?.trim()) {
return false;
}
if (!capabilities.canStreamToDisk || !capabilities.canPersistSize(attachment.size)) {
return false;
}
if (attachment.size > MAX_AUTO_SAVE_SIZE_BYTES) {
return true;
}
return isAttachmentMedia(attachment);
}
export function canReceiveAttachmentInMemory(
attachment: Pick<Attachment, 'size'>,
capabilities: AttachmentReceiveCapabilities
): boolean {
if (attachment.size <= MAX_AUTO_SAVE_SIZE_BYTES) {
return true;
}
return !capabilities.canStreamToDisk && capabilities.canPersistSize(attachment.size);
}
export function canReceiveAttachment(
attachment: Pick<Attachment, 'size' | 'mime' | 'filePath'>,
capabilities: AttachmentReceiveCapabilities
): boolean {
return shouldStreamAttachmentReceiveToDisk(attachment, capabilities)
|| canReceiveAttachmentInMemory(attachment, capabilities);
}
function decodeUrlSegment(value: string): string {
try {
return decodeURIComponent(value);