fix: Bug - Sending files and attachment issues
Hydrate playable media after disk receive, relay file-announce to sibling devices via account_sync, bind DM attachments to pre-allocated message ids, and improve gallery retry/cancel UX with bounded parallel auto-downloads. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+22
-10
@@ -16,6 +16,7 @@ import {
|
||||
isDirectMessageAttachmentRoomId,
|
||||
shouldAutoRequestWhenWatched
|
||||
} from '../../domain/logic/attachment.logic';
|
||||
import { ATTACHMENT_AUTO_DOWNLOAD_CONCURRENCY, runTasksWithBoundedConcurrency } from '../../domain/logic/attachment-autodownload-concurrency.rules';
|
||||
import type { Attachment, AttachmentMeta } from '../../domain/models/attachment.model';
|
||||
import type {
|
||||
FileAnnouncePayload,
|
||||
@@ -153,6 +154,10 @@ export class AttachmentManagerService {
|
||||
return this.transfer.requestImageFromAnyPeer(messageId, attachment);
|
||||
}
|
||||
|
||||
hasPendingRequest(messageId: string, attachmentId: string): boolean {
|
||||
return this.transfer.hasPendingRequest(messageId, attachmentId);
|
||||
}
|
||||
|
||||
async tryRestoreAttachmentFromLocal(attachment: Attachment): Promise<boolean> {
|
||||
const restored = await this.persistence.tryRestoreAttachmentFromLocal(attachment);
|
||||
|
||||
@@ -316,33 +321,40 @@ export class AttachmentManagerService {
|
||||
|
||||
await this.restoreLocalAttachmentsForRoom(roomId);
|
||||
|
||||
if (isDirectMessageAttachmentRoomId(roomId)) {
|
||||
await this.requestAutoDownloadsForRuntimeRoom(roomId);
|
||||
return;
|
||||
}
|
||||
let messageIds: string[];
|
||||
|
||||
if (this.database.isReady()) {
|
||||
if (isDirectMessageAttachmentRoomId(roomId)) {
|
||||
messageIds = await this.collectMessageIdsForAttachmentsInRoom(roomId);
|
||||
} else if (this.database.isReady()) {
|
||||
const messages = await this.database.getMessages(roomId, 500, 0);
|
||||
|
||||
for (const message of messages) {
|
||||
this.runtimeStore.rememberMessageRoom(message.id, message.roomId);
|
||||
await this.requestAutoDownloadsForMessage(message.id);
|
||||
}
|
||||
|
||||
return;
|
||||
messageIds = messages.map((message) => message.id);
|
||||
} else {
|
||||
messageIds = await this.collectMessageIdsForAttachmentsInRoom(roomId);
|
||||
}
|
||||
|
||||
await this.requestAutoDownloadsForRuntimeRoom(roomId);
|
||||
await runTasksWithBoundedConcurrency(
|
||||
messageIds.map((messageId) => () => this.requestAutoDownloadsForMessage(messageId)),
|
||||
ATTACHMENT_AUTO_DOWNLOAD_CONCURRENCY
|
||||
);
|
||||
}
|
||||
|
||||
private async requestAutoDownloadsForRuntimeRoom(roomId: string): Promise<void> {
|
||||
private async collectMessageIdsForAttachmentsInRoom(roomId: string): Promise<string[]> {
|
||||
const messageIds: string[] = [];
|
||||
|
||||
for (const [messageId] of this.runtimeStore.getAttachmentEntries()) {
|
||||
const attachmentRoomId = await this.persistence.resolveMessageRoomId(messageId);
|
||||
|
||||
if (attachmentRoomId === roomId) {
|
||||
await this.requestAutoDownloadsForMessage(messageId);
|
||||
messageIds.push(messageId);
|
||||
}
|
||||
}
|
||||
|
||||
return messageIds;
|
||||
}
|
||||
|
||||
private async requestAutoDownloadsForMessage(messageId: string, attachmentId?: string): Promise<void> {
|
||||
|
||||
+84
-2
@@ -414,6 +414,7 @@ describe('AttachmentTransferService', () => {
|
||||
fileId: FILE_ID,
|
||||
index: 0
|
||||
});
|
||||
|
||||
expect(persistence.saveFileToDisk).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -480,6 +481,7 @@ describe('AttachmentTransferService', () => {
|
||||
fileId: FILE_ID,
|
||||
index: 0
|
||||
});
|
||||
|
||||
expect(persistence.ensureInlineDisplayObjectUrl).not.toHaveBeenCalled();
|
||||
expect(persistence.saveFileToDisk).not.toHaveBeenCalled();
|
||||
expect(attachment.objectUrl).toBeUndefined();
|
||||
@@ -510,15 +512,16 @@ describe('AttachmentTransferService', () => {
|
||||
fileId: FILE_ID,
|
||||
index: 0
|
||||
});
|
||||
|
||||
expect(persistence.saveFileToDisk).not.toHaveBeenCalled();
|
||||
expect(runtimeStore.getChunkBuffer(`${MESSAGE_ID}:${FILE_ID}`)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('does not hydrate media blobs after a disk-streamed download completes', async () => {
|
||||
it('does not hydrate image blobs after a disk-streamed download completes', async () => {
|
||||
attachmentStorage.canStreamToDisk.mockReturnValue(true);
|
||||
|
||||
const service = createService();
|
||||
const attachment = registerIncomingVideo(3);
|
||||
const attachment = registerIncomingAttachment(3);
|
||||
|
||||
service.handleFileChunk(chunkPayload(0, 1, [
|
||||
1,
|
||||
@@ -530,9 +533,55 @@ describe('AttachmentTransferService', () => {
|
||||
|
||||
expect(attachment.savedPath).toBeTruthy();
|
||||
expect(attachment.objectUrl).toBeUndefined();
|
||||
expect(attachmentStorage.getFileUrl).not.toHaveBeenCalled();
|
||||
expect(persistence.ensureInlineDisplayObjectUrl).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('hydrates playable media with a native file url after disk-streamed download completes', async () => {
|
||||
attachmentStorage.canStreamToDisk.mockReturnValue(true);
|
||||
attachmentStorage.getFileUrl.mockResolvedValue('file:///appdata/server/room/files/clip.mp4');
|
||||
|
||||
const service = createService();
|
||||
const attachment = registerIncomingVideo(3);
|
||||
|
||||
service.handleFileChunk(chunkPayload(0, 1, [
|
||||
1,
|
||||
2,
|
||||
3
|
||||
]));
|
||||
|
||||
await vi.waitFor(() => expect(attachment.objectUrl).toBe('file:///appdata/server/room/files/clip.mp4'));
|
||||
|
||||
expect(attachment.available).toBe(true);
|
||||
expect(attachment.savedPath).toBeTruthy();
|
||||
expect(attachmentStorage.getFileUrl).toHaveBeenCalledWith(attachment.savedPath);
|
||||
expect(persistence.ensureInlineDisplayObjectUrl).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('falls back to inline blob hydration for playable media when no native file url exists', async () => {
|
||||
attachmentStorage.canStreamToDisk.mockReturnValue(true);
|
||||
attachmentStorage.getFileUrl.mockResolvedValue(null);
|
||||
persistence.ensureInlineDisplayObjectUrl.mockImplementation(async (entry) => {
|
||||
entry.objectUrl = 'blob:http://localhost/clip';
|
||||
entry.available = true;
|
||||
return true;
|
||||
});
|
||||
|
||||
const service = createService();
|
||||
const attachment = registerIncomingVideo(3);
|
||||
|
||||
service.handleFileChunk(chunkPayload(0, 1, [
|
||||
1,
|
||||
2,
|
||||
3
|
||||
]));
|
||||
|
||||
await vi.waitFor(() => expect(persistence.ensureInlineDisplayObjectUrl).toHaveBeenCalled());
|
||||
|
||||
expect(attachment.objectUrl).toBe('blob:http://localhost/clip');
|
||||
expect(attachment.available).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects oversized browser downloads before requesting peers', async () => {
|
||||
attachmentStorage.canStreamToDisk.mockReturnValue(false);
|
||||
attachmentStorage.canPersistSize.mockImplementation((bytes: number) => bytes <= 50 * 1024 * 1024);
|
||||
@@ -727,4 +776,37 @@ describe('AttachmentTransferService', () => {
|
||||
expect(attachment.available).toBe(true);
|
||||
expect(attachment.savedPath).toBe('/appdata/server/room/files/setup.exe');
|
||||
});
|
||||
|
||||
it('sends file-cancel to pending request peers instead of only the uploader', async () => {
|
||||
const mirrorPeer = 'mirror-peer';
|
||||
|
||||
webrtc.getConnectedPeers.mockReturnValue([mirrorPeer]);
|
||||
webrtc.sendToPeer.mockClear();
|
||||
|
||||
const service = createService();
|
||||
const attachment = registerIncomingAttachment(3_000);
|
||||
|
||||
attachment.uploaderPeerId = 'uploader-peer';
|
||||
runtimeStore.addAnnouncedHost(`${MESSAGE_ID}:${FILE_ID}`, mirrorPeer);
|
||||
runtimeStore.addAnnouncedHost(`${MESSAGE_ID}:${FILE_ID}`, attachment.uploaderPeerId);
|
||||
|
||||
await service.requestFromAnyPeer(MESSAGE_ID, attachment);
|
||||
|
||||
expect(webrtc.sendToPeer).toHaveBeenCalledWith(mirrorPeer, expect.objectContaining({
|
||||
type: 'file-request'
|
||||
}));
|
||||
|
||||
attachment.receivedBytes = 512;
|
||||
service.cancelRequest(MESSAGE_ID, attachment);
|
||||
|
||||
expect(webrtc.sendToPeer).toHaveBeenCalledWith(mirrorPeer, {
|
||||
type: 'file-cancel',
|
||||
messageId: MESSAGE_ID,
|
||||
fileId: FILE_ID
|
||||
});
|
||||
|
||||
expect(attachment.receivedBytes).toBe(0);
|
||||
expect(attachment.available).toBe(false);
|
||||
expect(service.hasPendingRequest(MESSAGE_ID, FILE_ID)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
+40
-6
@@ -229,6 +229,10 @@ export class AttachmentTransferService {
|
||||
}
|
||||
|
||||
requestImageFromAnyPeer(messageId: string, attachment: Attachment): Promise<void> {
|
||||
if ((attachment.receivedBytes ?? 0) > 0 || this.hasPendingRequest(messageId, attachment.id)) {
|
||||
this.cancelRequest(messageId, attachment);
|
||||
}
|
||||
|
||||
return this.requestFromAnyPeer(messageId, attachment);
|
||||
}
|
||||
|
||||
@@ -456,22 +460,22 @@ export class AttachmentTransferService {
|
||||
}
|
||||
|
||||
cancelRequest(messageId: string, attachment: Attachment): void {
|
||||
const targetPeerId = attachment.uploaderPeerId;
|
||||
|
||||
if (!targetPeerId)
|
||||
return;
|
||||
|
||||
try {
|
||||
const requestKey = this.buildRequestKey(messageId, attachment.id);
|
||||
const assemblyKey = `${messageId}:${attachment.id}`;
|
||||
const pendingPeers = this.runtimeStore.getPendingRequestPeers(requestKey);
|
||||
|
||||
this.runtimeStore.deleteChunkBuffer(assemblyKey);
|
||||
this.runtimeStore.deleteChunkCount(assemblyKey);
|
||||
this.runtimeStore.deletePendingRequest(requestKey);
|
||||
void this.deleteDiskReceiveAssembly(assemblyKey);
|
||||
this.chunkAcks.cancelPendingForFile(messageId, attachment.id);
|
||||
|
||||
attachment.receivedBytes = 0;
|
||||
attachment.speedBps = 0;
|
||||
attachment.startedAtMs = undefined;
|
||||
attachment.lastUpdateMs = undefined;
|
||||
attachment.requestError = undefined;
|
||||
|
||||
if (attachment.objectUrl) {
|
||||
try {
|
||||
@@ -489,8 +493,21 @@ export class AttachmentTransferService {
|
||||
messageId,
|
||||
fileId: attachment.id
|
||||
};
|
||||
const peersToNotify = new Set<string>();
|
||||
|
||||
this.webrtc.sendToPeer(targetPeerId, fileCancelEvent);
|
||||
if (pendingPeers) {
|
||||
for (const peerId of pendingPeers) {
|
||||
peersToNotify.add(peerId);
|
||||
}
|
||||
}
|
||||
|
||||
if (attachment.uploaderPeerId) {
|
||||
peersToNotify.add(attachment.uploaderPeerId);
|
||||
}
|
||||
|
||||
for (const peerId of peersToNotify) {
|
||||
this.webrtc.sendToPeer(peerId, fileCancelEvent);
|
||||
}
|
||||
} catch { /* best-effort */ }
|
||||
}
|
||||
|
||||
@@ -997,6 +1014,23 @@ export class AttachmentTransferService {
|
||||
this.runtimeStore.touch();
|
||||
void this.persistence.persistAttachmentMeta(attachment);
|
||||
void this.announceLocalHost(attachment);
|
||||
void this.hydratePlayableMediaAfterDiskReceive(attachment);
|
||||
}
|
||||
|
||||
private async hydratePlayableMediaAfterDiskReceive(attachment: Attachment): Promise<void> {
|
||||
if (!this.isPlayableMedia(attachment) || !attachment.savedPath) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nativeUrl = await this.attachmentStorage.getFileUrl(attachment.savedPath);
|
||||
|
||||
if (nativeUrl) {
|
||||
attachment.objectUrl = nativeUrl;
|
||||
this.runtimeStore.touch();
|
||||
return;
|
||||
}
|
||||
|
||||
await this.persistence.ensureInlineDisplayObjectUrl(attachment);
|
||||
}
|
||||
|
||||
private async getOrCreateDiskReceiveAssembly(
|
||||
|
||||
Reference in New Issue
Block a user