fix: Bug - Attachments gets syncronized corrupt
This commit is contained in:
@@ -101,6 +101,14 @@ sequenceDiagram
|
||||
Note over R: shouldPersistDownloadedAttachment? Save to disk
|
||||
```
|
||||
|
||||
### Transfer integrity invariants
|
||||
|
||||
Concurrent triggers (file-announce, message sync, peer connect) can race to request the same file, and a sender can receive duplicate `file-request`s for the same attachment. The transfer service enforces these invariants so duplicate streams can never corrupt a download (regression: receivers used to finalize after only the first chunks):
|
||||
|
||||
- **Requester:** `requestFromAnyPeer` marks the request pending *synchronously* before any async work, so the manager's `hasPendingRequest` gate closes the double-request race window.
|
||||
- **Sender:** `handleFileRequest` / `fulfillRequestWithFile` track active outbound streams per `(messageId, fileId, peerId)` and ignore duplicate requests while a stream is in flight. A fresh `file-request` clears any earlier `file-cancel` marker from that peer.
|
||||
- **Receiver:** chunk buffers are dense (`Array.from({ length: total })`, never sparse `new Array(total)`); a chunk index that is already buffered is ignored entirely and never counts toward `receivedBytes`; a transfer finalizes only when *every* chunk index is present — byte counters are never a substitute for chunk completeness. Assembly state is released only after the attachment is marked `available`, and chunks arriving for an already-available attachment are dropped.
|
||||
|
||||
### Failure handling
|
||||
|
||||
If the sender cannot find the file, it replies with `file-not-found`. The transfer service then tries the next connected peer that has announced the same attachment. Either side can send `file-cancel` to abort a transfer in progress.
|
||||
|
||||
@@ -10,7 +10,7 @@ export class AttachmentRuntimeStore {
|
||||
private originalFiles = new Map<string, File>();
|
||||
private cancelledTransfers = new Set<string>();
|
||||
private pendingRequests = new Map<string, Set<string>>();
|
||||
private chunkBuffers = new Map<string, ArrayBuffer[]>();
|
||||
private chunkBuffers = new Map<string, (ArrayBuffer | undefined)[]>();
|
||||
private chunkCounts = new Map<string, number>();
|
||||
|
||||
touch(): void {
|
||||
@@ -84,6 +84,10 @@ export class AttachmentRuntimeStore {
|
||||
return this.cancelledTransfers.has(key);
|
||||
}
|
||||
|
||||
deleteCancelledTransfer(key: string): void {
|
||||
this.cancelledTransfers.delete(key);
|
||||
}
|
||||
|
||||
setPendingRequestPeers(key: string, peers: Set<string>): void {
|
||||
this.pendingRequests.set(key, peers);
|
||||
}
|
||||
@@ -100,11 +104,11 @@ export class AttachmentRuntimeStore {
|
||||
this.pendingRequests.delete(key);
|
||||
}
|
||||
|
||||
setChunkBuffer(key: string, buffer: ArrayBuffer[]): void {
|
||||
setChunkBuffer(key: string, buffer: (ArrayBuffer | undefined)[]): void {
|
||||
this.chunkBuffers.set(key, buffer);
|
||||
}
|
||||
|
||||
getChunkBuffer(key: string): ArrayBuffer[] | undefined {
|
||||
getChunkBuffer(key: string): (ArrayBuffer | undefined)[] | undefined {
|
||||
return this.chunkBuffers.get(key);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,354 @@
|
||||
import '@angular/compiler';
|
||||
import {
|
||||
beforeEach,
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
vi
|
||||
} from 'vitest';
|
||||
import { Injector, runInInjectionContext } from '@angular/core';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { of } from 'rxjs';
|
||||
|
||||
import { RealtimeSessionFacade } from '../../../../core/realtime';
|
||||
import { AppI18nService } from '../../../../core/i18n';
|
||||
import { arrayBufferToBase64 } from '../../../../shared-kernel';
|
||||
import { decodeBase64 } from '../../../../shared-kernel/p2p-transfer.utils';
|
||||
import { AttachmentStorageService } from '../../infrastructure/services/attachment-storage.service';
|
||||
import type { Attachment } from '../../domain/models/attachment.model';
|
||||
import type { FileChunkPayload } from '../../domain/models/attachment-transfer.model';
|
||||
import { AttachmentPersistenceService } from './attachment-persistence.service';
|
||||
import { AttachmentRuntimeStore } from './attachment-runtime.store';
|
||||
import { AttachmentTransferService } from './attachment-transfer.service';
|
||||
import { AttachmentTransferTransportService } from './attachment-transfer-transport.service';
|
||||
|
||||
const MESSAGE_ID = 'msg-1';
|
||||
const FILE_ID = 'file-1';
|
||||
const PEER_ID = 'peer-1';
|
||||
|
||||
function encodeChunk(bytes: number[]): string {
|
||||
return arrayBufferToBase64(new Uint8Array(bytes).buffer);
|
||||
}
|
||||
|
||||
describe('AttachmentTransferService', () => {
|
||||
let runtimeStore: AttachmentRuntimeStore;
|
||||
let persistence: {
|
||||
whenReady: ReturnType<typeof vi.fn>;
|
||||
persistAttachmentMeta: ReturnType<typeof vi.fn>;
|
||||
tryRestoreAttachmentFromLocal: ReturnType<typeof vi.fn>;
|
||||
saveFileToDisk: ReturnType<typeof vi.fn>;
|
||||
persistUploadCopyFromSourcePath: ReturnType<typeof vi.fn>;
|
||||
resolveCurrentRoomName: ReturnType<typeof vi.fn>;
|
||||
resolveStorageContainerName: ReturnType<typeof vi.fn>;
|
||||
ensureInlineDisplayObjectUrl: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
let attachmentStorage: {
|
||||
canWriteFiles: ReturnType<typeof vi.fn>;
|
||||
canCopyFiles: ReturnType<typeof vi.fn>;
|
||||
canReadFileChunks: ReturnType<typeof vi.fn>;
|
||||
getFileUrl: ReturnType<typeof vi.fn>;
|
||||
resolveExistingPath: ReturnType<typeof vi.fn>;
|
||||
resolveLegacyImagePath: ReturnType<typeof vi.fn>;
|
||||
appendBase64: ReturnType<typeof vi.fn>;
|
||||
createWritableFile: ReturnType<typeof vi.fn>;
|
||||
deleteFile: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
let transport: {
|
||||
decodeBase64: ReturnType<typeof vi.fn>;
|
||||
streamFileToPeer: ReturnType<typeof vi.fn>;
|
||||
streamFileFromDiskToPeer: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
let webrtc: {
|
||||
getConnectedPeers: ReturnType<typeof vi.fn>;
|
||||
broadcastMessage: ReturnType<typeof vi.fn>;
|
||||
sendToPeer: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
persistence = {
|
||||
whenReady: vi.fn(async () => undefined),
|
||||
persistAttachmentMeta: vi.fn(async () => undefined),
|
||||
tryRestoreAttachmentFromLocal: vi.fn(async () => false),
|
||||
saveFileToDisk: vi.fn(async () => null),
|
||||
persistUploadCopyFromSourcePath: vi.fn(async () => null),
|
||||
resolveCurrentRoomName: vi.fn(async () => null),
|
||||
resolveStorageContainerName: vi.fn(async () => 'room'),
|
||||
ensureInlineDisplayObjectUrl: vi.fn(async () => true)
|
||||
};
|
||||
|
||||
attachmentStorage = {
|
||||
canWriteFiles: vi.fn(() => false),
|
||||
canCopyFiles: vi.fn(() => false),
|
||||
canReadFileChunks: vi.fn(() => false),
|
||||
getFileUrl: vi.fn(async () => null),
|
||||
resolveExistingPath: vi.fn(async () => null),
|
||||
resolveLegacyImagePath: vi.fn(async () => null),
|
||||
appendBase64: vi.fn(async () => true),
|
||||
createWritableFile: vi.fn(async () => '/appdata/server/room/files/file-1'),
|
||||
deleteFile: vi.fn(async () => true)
|
||||
};
|
||||
|
||||
transport = {
|
||||
decodeBase64: vi.fn((base64: string) => decodeBase64(base64)),
|
||||
streamFileToPeer: vi.fn(async () => undefined),
|
||||
streamFileFromDiskToPeer: vi.fn(async () => undefined)
|
||||
};
|
||||
|
||||
webrtc = {
|
||||
getConnectedPeers: vi.fn(() => [PEER_ID]),
|
||||
broadcastMessage: vi.fn(),
|
||||
sendToPeer: vi.fn()
|
||||
};
|
||||
});
|
||||
|
||||
function createService(): AttachmentTransferService {
|
||||
const injector = Injector.create({
|
||||
providers: [
|
||||
AttachmentTransferService,
|
||||
AttachmentRuntimeStore,
|
||||
{ provide: Store, useValue: { select: () => of(null) } },
|
||||
{ provide: RealtimeSessionFacade, useValue: webrtc },
|
||||
{ provide: AppI18nService, useValue: { instant: (key: string) => key } },
|
||||
{ provide: AttachmentStorageService, useValue: attachmentStorage },
|
||||
{ provide: AttachmentPersistenceService, useValue: persistence },
|
||||
{ provide: AttachmentTransferTransportService, useValue: transport }
|
||||
]
|
||||
});
|
||||
const service = runInInjectionContext(injector, () => injector.get(AttachmentTransferService));
|
||||
|
||||
runtimeStore = injector.get(AttachmentRuntimeStore);
|
||||
return service;
|
||||
}
|
||||
|
||||
function registerIncomingAttachment(size: number): Attachment {
|
||||
const attachment: Attachment = {
|
||||
id: FILE_ID,
|
||||
messageId: MESSAGE_ID,
|
||||
filename: 'photo.png',
|
||||
size,
|
||||
mime: 'image/png',
|
||||
isImage: true,
|
||||
uploaderPeerId: PEER_ID,
|
||||
available: false,
|
||||
receivedBytes: 0
|
||||
};
|
||||
|
||||
runtimeStore.setAttachmentsForMessage(MESSAGE_ID, [attachment]);
|
||||
return attachment;
|
||||
}
|
||||
|
||||
function chunkPayload(index: number, total: number, bytes: number[]): FileChunkPayload {
|
||||
return {
|
||||
messageId: MESSAGE_ID,
|
||||
fileId: FILE_ID,
|
||||
fromPeerId: PEER_ID,
|
||||
index,
|
||||
total,
|
||||
data: encodeChunk(bytes)
|
||||
};
|
||||
}
|
||||
|
||||
async function readSavedBlobBytes(): Promise<number[]> {
|
||||
const lastCall = persistence.saveFileToDisk.mock.calls.at(-1);
|
||||
const blob = lastCall?.[1] as Blob;
|
||||
const buffer = await blob.arrayBuffer();
|
||||
|
||||
return [...new Uint8Array(buffer)];
|
||||
}
|
||||
|
||||
it('assembles a multi-chunk in-memory transfer intact when each chunk arrives once', async () => {
|
||||
const service = createService();
|
||||
const attachment = registerIncomingAttachment(9);
|
||||
|
||||
service.handleFileChunk(chunkPayload(0, 3, [
|
||||
1,
|
||||
2,
|
||||
3
|
||||
]));
|
||||
|
||||
service.handleFileChunk(chunkPayload(1, 3, [
|
||||
4,
|
||||
5,
|
||||
6
|
||||
]));
|
||||
|
||||
service.handleFileChunk(chunkPayload(2, 3, [
|
||||
7,
|
||||
8,
|
||||
9
|
||||
]));
|
||||
|
||||
await vi.waitFor(() => expect(attachment.available).toBe(true));
|
||||
|
||||
expect(persistence.saveFileToDisk).toHaveBeenCalledTimes(1);
|
||||
await expect(readSavedBlobBytes()).resolves.toEqual([
|
||||
1,
|
||||
2,
|
||||
3,
|
||||
4,
|
||||
5,
|
||||
6,
|
||||
7,
|
||||
8,
|
||||
9
|
||||
]);
|
||||
|
||||
expect(attachment.receivedBytes).toBe(9);
|
||||
});
|
||||
|
||||
it('ignores duplicate chunk deliveries from concurrent streams and assembles the full file', async () => {
|
||||
const service = createService();
|
||||
const attachment = registerIncomingAttachment(9);
|
||||
|
||||
// Two interleaved streams of the same file (duplicate file-request race).
|
||||
service.handleFileChunk(chunkPayload(0, 3, [
|
||||
1,
|
||||
2,
|
||||
3
|
||||
]));
|
||||
|
||||
service.handleFileChunk(chunkPayload(0, 3, [
|
||||
1,
|
||||
2,
|
||||
3
|
||||
]));
|
||||
|
||||
service.handleFileChunk(chunkPayload(1, 3, [
|
||||
4,
|
||||
5,
|
||||
6
|
||||
]));
|
||||
|
||||
service.handleFileChunk(chunkPayload(1, 3, [
|
||||
4,
|
||||
5,
|
||||
6
|
||||
]));
|
||||
|
||||
service.handleFileChunk(chunkPayload(2, 3, [
|
||||
7,
|
||||
8,
|
||||
9
|
||||
]));
|
||||
|
||||
service.handleFileChunk(chunkPayload(2, 3, [
|
||||
7,
|
||||
8,
|
||||
9
|
||||
]));
|
||||
|
||||
await vi.waitFor(() => expect(attachment.available).toBe(true));
|
||||
|
||||
expect(persistence.saveFileToDisk).toHaveBeenCalledTimes(1);
|
||||
await expect(readSavedBlobBytes()).resolves.toEqual([
|
||||
1,
|
||||
2,
|
||||
3,
|
||||
4,
|
||||
5,
|
||||
6,
|
||||
7,
|
||||
8,
|
||||
9
|
||||
]);
|
||||
|
||||
expect(attachment.receivedBytes).toBe(9);
|
||||
});
|
||||
|
||||
it('does not finalize a transfer while chunks are still missing', () => {
|
||||
const service = createService();
|
||||
const attachment = registerIncomingAttachment(9);
|
||||
|
||||
// Duplicates inflate naive byte accounting past the file size while
|
||||
// chunk 2 has not arrived yet - the transfer must stay incomplete.
|
||||
service.handleFileChunk(chunkPayload(0, 3, [
|
||||
1,
|
||||
2,
|
||||
3
|
||||
]));
|
||||
|
||||
service.handleFileChunk(chunkPayload(0, 3, [
|
||||
1,
|
||||
2,
|
||||
3
|
||||
]));
|
||||
|
||||
service.handleFileChunk(chunkPayload(1, 3, [
|
||||
4,
|
||||
5,
|
||||
6
|
||||
]));
|
||||
|
||||
service.handleFileChunk(chunkPayload(1, 3, [
|
||||
4,
|
||||
5,
|
||||
6
|
||||
]));
|
||||
|
||||
expect(attachment.available).toBe(false);
|
||||
expect(persistence.saveFileToDisk).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('streams a requested file only once while the same request is already in flight', async () => {
|
||||
const service = createService();
|
||||
|
||||
registerIncomingAttachment(9);
|
||||
runtimeStore.setOriginalFile(`${MESSAGE_ID}:${FILE_ID}`, new File([new Uint8Array(9)], 'photo.png', { type: 'image/png' }));
|
||||
|
||||
let releaseStream: () => void = () => undefined;
|
||||
|
||||
transport.streamFileToPeer.mockImplementation(() => new Promise<void>((resolve) => {
|
||||
releaseStream = resolve;
|
||||
}));
|
||||
|
||||
const firstRequest = service.handleFileRequest({
|
||||
messageId: MESSAGE_ID,
|
||||
fileId: FILE_ID,
|
||||
fromPeerId: PEER_ID
|
||||
});
|
||||
const duplicateRequest = service.handleFileRequest({
|
||||
messageId: MESSAGE_ID,
|
||||
fileId: FILE_ID,
|
||||
fromPeerId: PEER_ID
|
||||
});
|
||||
|
||||
releaseStream();
|
||||
await Promise.all([firstRequest, duplicateRequest]);
|
||||
|
||||
expect(transport.streamFileToPeer).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('allows a peer to download again after it cancelled a previous transfer', async () => {
|
||||
const service = createService();
|
||||
|
||||
registerIncomingAttachment(9);
|
||||
runtimeStore.setOriginalFile(`${MESSAGE_ID}:${FILE_ID}`, new File([new Uint8Array(9)], 'photo.png', { type: 'image/png' }));
|
||||
|
||||
service.handleFileCancel({
|
||||
messageId: MESSAGE_ID,
|
||||
fileId: FILE_ID,
|
||||
fromPeerId: PEER_ID
|
||||
});
|
||||
|
||||
await service.handleFileRequest({
|
||||
messageId: MESSAGE_ID,
|
||||
fileId: FILE_ID,
|
||||
fromPeerId: PEER_ID
|
||||
});
|
||||
|
||||
expect(transport.streamFileToPeer).toHaveBeenCalledTimes(1);
|
||||
|
||||
const isCancelled = transport.streamFileToPeer.mock.calls[0][4] as () => boolean;
|
||||
|
||||
expect(isCancelled()).toBe(false);
|
||||
});
|
||||
|
||||
it('marks a request as pending synchronously so concurrent auto-download triggers cannot double-request', () => {
|
||||
const service = createService();
|
||||
const attachment = registerIncomingAttachment(9);
|
||||
|
||||
void service.requestFromAnyPeer(MESSAGE_ID, attachment);
|
||||
|
||||
expect(service.hasPendingRequest(MESSAGE_ID, FILE_ID)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -55,6 +55,20 @@ interface ValidFileChunkPayload {
|
||||
total: number;
|
||||
}
|
||||
|
||||
function isValidFileChunkPayload(payload: FileChunkPayload): payload is FileChunkPayload & ValidFileChunkPayload {
|
||||
const { messageId, fileId, index, total, data } = payload;
|
||||
|
||||
return !!messageId && !!fileId &&
|
||||
typeof data === 'string' &&
|
||||
typeof index === 'number' &&
|
||||
typeof total === 'number' &&
|
||||
Number.isInteger(index) &&
|
||||
Number.isInteger(total) &&
|
||||
total > 0 &&
|
||||
index >= 0 &&
|
||||
index < total;
|
||||
}
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class AttachmentTransferService {
|
||||
private readonly ngrxStore = inject(Store);
|
||||
@@ -67,6 +81,7 @@ export class AttachmentTransferService {
|
||||
|
||||
private readonly diskReceiveAssemblies = new Map<string, DiskReceiveAssembly>();
|
||||
private readonly diskReceiveChains = new Map<string, Promise<void>>();
|
||||
private readonly activeOutboundTransfers = new Set<string>();
|
||||
|
||||
getAttachmentMetasForMessages(messageIds: string[]): Record<string, AttachmentMeta[]> {
|
||||
const result: Record<string, AttachmentMeta[]> = {};
|
||||
@@ -135,12 +150,19 @@ export class AttachmentTransferService {
|
||||
}
|
||||
|
||||
async requestFromAnyPeer(messageId: string, attachment: Attachment): Promise<void> {
|
||||
const requestKey = this.buildRequestKey(messageId, attachment.id);
|
||||
const clearedRequestError = this.clearAttachmentRequestError(attachment);
|
||||
|
||||
// Mark the request pending synchronously so concurrent triggers (file-announce,
|
||||
// message sync, peer connect) cannot double-request the same file - a duplicate
|
||||
// request makes the sender stream the file twice and corrupts byte accounting.
|
||||
this.runtimeStore.setPendingRequestPeers(requestKey, new Set<string>());
|
||||
|
||||
if (!attachment.available) {
|
||||
const restoredLocally = await this.persistence.tryRestoreAttachmentFromLocal(attachment);
|
||||
|
||||
if (restoredLocally) {
|
||||
this.runtimeStore.deletePendingRequest(requestKey);
|
||||
this.runtimeStore.touch();
|
||||
return;
|
||||
}
|
||||
@@ -153,6 +175,7 @@ export class AttachmentTransferService {
|
||||
attachment.uploaderPeerId === currentUserId;
|
||||
|
||||
if (connectedPeers.length === 0) {
|
||||
this.runtimeStore.deletePendingRequest(requestKey);
|
||||
attachment.requestError = isUploader
|
||||
? this.appI18n.instant(UPLOADER_LOCAL_FILE_MISSING_ERROR_KEY)
|
||||
: this.appI18n.instant(NO_CONNECTED_PEERS_REQUEST_ERROR_KEY);
|
||||
@@ -165,11 +188,6 @@ export class AttachmentTransferService {
|
||||
if (clearedRequestError)
|
||||
this.runtimeStore.touch();
|
||||
|
||||
this.runtimeStore.setPendingRequestPeers(
|
||||
this.buildRequestKey(messageId, attachment.id),
|
||||
new Set<string>()
|
||||
);
|
||||
|
||||
this.sendFileRequestToNextPeer(messageId, attachment.id, attachment.uploaderPeerId);
|
||||
}
|
||||
|
||||
@@ -334,28 +352,23 @@ export class AttachmentTransferService {
|
||||
}
|
||||
|
||||
handleFileChunk(payload: FileChunkPayload): void {
|
||||
const { messageId, fileId, fromPeerId, index, total, data } = payload;
|
||||
|
||||
if (
|
||||
!messageId || !fileId ||
|
||||
typeof index !== 'number' ||
|
||||
typeof total !== 'number' ||
|
||||
typeof data !== 'string' ||
|
||||
!Number.isInteger(index) ||
|
||||
!Number.isInteger(total) ||
|
||||
total <= 0 ||
|
||||
index < 0 ||
|
||||
index >= total
|
||||
) {
|
||||
if (!isValidFileChunkPayload(payload)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { messageId, fileId, fromPeerId, index, total, data } = payload;
|
||||
const list = this.runtimeStore.getAttachmentsForMessage(messageId);
|
||||
const attachment = list.find((entry) => entry.id === fileId);
|
||||
|
||||
if (!attachment)
|
||||
return;
|
||||
|
||||
if (attachment.available) {
|
||||
// Transfer already completed (or restored locally) - trailing chunks from
|
||||
// a redundant stream must not restart accounting or rewrite the file.
|
||||
return;
|
||||
}
|
||||
|
||||
if ((attachment.receivedBytes ?? 0) > attachment.size) {
|
||||
return;
|
||||
}
|
||||
@@ -386,11 +399,14 @@ export class AttachmentTransferService {
|
||||
|
||||
const chunkBuffer = this.getOrCreateChunkBuffer(assemblyKey, total);
|
||||
|
||||
if (!chunkBuffer[index]) {
|
||||
chunkBuffer[index] = decodedBytes.buffer as ArrayBuffer;
|
||||
this.runtimeStore.setChunkCount(assemblyKey, (this.runtimeStore.getChunkCount(assemblyKey) ?? 0) + 1);
|
||||
if (index >= chunkBuffer.length || chunkBuffer[index]) {
|
||||
// Duplicate delivery (e.g. a redundant concurrent stream) - the chunk is
|
||||
// already buffered, so it must not count toward transfer progress.
|
||||
return;
|
||||
}
|
||||
|
||||
chunkBuffer[index] = decodedBytes.buffer as ArrayBuffer;
|
||||
this.runtimeStore.setChunkCount(assemblyKey, (this.runtimeStore.getChunkCount(assemblyKey) ?? 0) + 1);
|
||||
this.updateTransferProgress(attachment, decodedBytes, fromPeerId);
|
||||
|
||||
this.runtimeStore.touch();
|
||||
@@ -403,6 +419,110 @@ export class AttachmentTransferService {
|
||||
if (!messageId || !fileId || !fromPeerId)
|
||||
return;
|
||||
|
||||
const transferKey = this.buildTransferKey(messageId, fileId, fromPeerId);
|
||||
|
||||
if (this.activeOutboundTransfers.has(transferKey)) {
|
||||
// A stream for this exact request is already in flight - sending the file
|
||||
// twice in parallel duplicates chunks and corrupts the receiver's assembly.
|
||||
return;
|
||||
}
|
||||
|
||||
// A fresh request supersedes any earlier cancellation from this peer.
|
||||
this.runtimeStore.deleteCancelledTransfer(transferKey);
|
||||
this.activeOutboundTransfers.add(transferKey);
|
||||
|
||||
try {
|
||||
await this.streamRequestedFile(messageId, fileId, fromPeerId);
|
||||
} finally {
|
||||
this.activeOutboundTransfers.delete(transferKey);
|
||||
}
|
||||
}
|
||||
|
||||
cancelRequest(messageId: string, attachment: Attachment): void {
|
||||
const targetPeerId = attachment.uploaderPeerId;
|
||||
|
||||
if (!targetPeerId)
|
||||
return;
|
||||
|
||||
try {
|
||||
const assemblyKey = `${messageId}:${attachment.id}`;
|
||||
|
||||
this.runtimeStore.deleteChunkBuffer(assemblyKey);
|
||||
this.runtimeStore.deleteChunkCount(assemblyKey);
|
||||
void this.deleteDiskReceiveAssembly(assemblyKey);
|
||||
|
||||
attachment.receivedBytes = 0;
|
||||
attachment.speedBps = 0;
|
||||
attachment.startedAtMs = undefined;
|
||||
attachment.lastUpdateMs = undefined;
|
||||
|
||||
if (attachment.objectUrl) {
|
||||
try {
|
||||
URL.revokeObjectURL(attachment.objectUrl);
|
||||
} catch { /* ignore */ }
|
||||
|
||||
attachment.objectUrl = undefined;
|
||||
}
|
||||
|
||||
attachment.available = false;
|
||||
this.runtimeStore.touch();
|
||||
|
||||
const fileCancelEvent: FileCancelEvent = {
|
||||
type: 'file-cancel',
|
||||
messageId,
|
||||
fileId: attachment.id
|
||||
};
|
||||
|
||||
this.webrtc.sendToPeer(targetPeerId, fileCancelEvent);
|
||||
} catch { /* best-effort */ }
|
||||
}
|
||||
|
||||
handleFileCancel(payload: FileCancelPayload): void {
|
||||
const { messageId, fileId, fromPeerId } = payload;
|
||||
|
||||
if (!messageId || !fileId || !fromPeerId)
|
||||
return;
|
||||
|
||||
this.runtimeStore.addCancelledTransfer(
|
||||
this.buildTransferKey(messageId, fileId, fromPeerId)
|
||||
);
|
||||
}
|
||||
|
||||
async fulfillRequestWithFile(
|
||||
messageId: string,
|
||||
fileId: string,
|
||||
targetPeerId: string,
|
||||
file: File
|
||||
): Promise<void> {
|
||||
this.runtimeStore.setOriginalFile(`${messageId}:${fileId}`, file);
|
||||
|
||||
const transferKey = this.buildTransferKey(messageId, fileId, targetPeerId);
|
||||
|
||||
if (this.activeOutboundTransfers.has(transferKey)) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.runtimeStore.deleteCancelledTransfer(transferKey);
|
||||
this.activeOutboundTransfers.add(transferKey);
|
||||
|
||||
try {
|
||||
await this.transport.streamFileToPeer(
|
||||
targetPeerId,
|
||||
messageId,
|
||||
fileId,
|
||||
file,
|
||||
() => this.isTransferCancelled(targetPeerId, messageId, fileId)
|
||||
);
|
||||
} finally {
|
||||
this.activeOutboundTransfers.delete(transferKey);
|
||||
}
|
||||
}
|
||||
|
||||
private async streamRequestedFile(
|
||||
messageId: string,
|
||||
fileId: string,
|
||||
fromPeerId: string
|
||||
): Promise<void> {
|
||||
const exactKey = `${messageId}:${fileId}`;
|
||||
const originalFile = this.runtimeStore.getOriginalFile(exactKey)
|
||||
?? this.runtimeStore.findOriginalFileByFileId(fileId);
|
||||
@@ -484,72 +604,6 @@ export class AttachmentTransferService {
|
||||
this.webrtc.sendToPeer(fromPeerId, fileNotFoundEvent);
|
||||
}
|
||||
|
||||
cancelRequest(messageId: string, attachment: Attachment): void {
|
||||
const targetPeerId = attachment.uploaderPeerId;
|
||||
|
||||
if (!targetPeerId)
|
||||
return;
|
||||
|
||||
try {
|
||||
const assemblyKey = `${messageId}:${attachment.id}`;
|
||||
|
||||
this.runtimeStore.deleteChunkBuffer(assemblyKey);
|
||||
this.runtimeStore.deleteChunkCount(assemblyKey);
|
||||
void this.deleteDiskReceiveAssembly(assemblyKey);
|
||||
|
||||
attachment.receivedBytes = 0;
|
||||
attachment.speedBps = 0;
|
||||
attachment.startedAtMs = undefined;
|
||||
attachment.lastUpdateMs = undefined;
|
||||
|
||||
if (attachment.objectUrl) {
|
||||
try {
|
||||
URL.revokeObjectURL(attachment.objectUrl);
|
||||
} catch { /* ignore */ }
|
||||
|
||||
attachment.objectUrl = undefined;
|
||||
}
|
||||
|
||||
attachment.available = false;
|
||||
this.runtimeStore.touch();
|
||||
|
||||
const fileCancelEvent: FileCancelEvent = {
|
||||
type: 'file-cancel',
|
||||
messageId,
|
||||
fileId: attachment.id
|
||||
};
|
||||
|
||||
this.webrtc.sendToPeer(targetPeerId, fileCancelEvent);
|
||||
} catch { /* best-effort */ }
|
||||
}
|
||||
|
||||
handleFileCancel(payload: FileCancelPayload): void {
|
||||
const { messageId, fileId, fromPeerId } = payload;
|
||||
|
||||
if (!messageId || !fileId || !fromPeerId)
|
||||
return;
|
||||
|
||||
this.runtimeStore.addCancelledTransfer(
|
||||
this.buildTransferKey(messageId, fileId, fromPeerId)
|
||||
);
|
||||
}
|
||||
|
||||
async fulfillRequestWithFile(
|
||||
messageId: string,
|
||||
fileId: string,
|
||||
targetPeerId: string,
|
||||
file: File
|
||||
): Promise<void> {
|
||||
this.runtimeStore.setOriginalFile(`${messageId}:${fileId}`, file);
|
||||
await this.transport.streamFileToPeer(
|
||||
targetPeerId,
|
||||
messageId,
|
||||
fileId,
|
||||
file,
|
||||
() => this.isTransferCancelled(targetPeerId, messageId, fileId)
|
||||
);
|
||||
}
|
||||
|
||||
private resolveCurrentUserId(): Promise<string | null> {
|
||||
return new Promise<string | null>((resolve) => {
|
||||
this.ngrxStore
|
||||
@@ -617,14 +671,16 @@ export class AttachmentTransferService {
|
||||
return true;
|
||||
}
|
||||
|
||||
private getOrCreateChunkBuffer(assemblyKey: string, total: number): ArrayBuffer[] {
|
||||
private getOrCreateChunkBuffer(assemblyKey: string, total: number): (ArrayBuffer | undefined)[] {
|
||||
const existingChunkBuffer = this.runtimeStore.getChunkBuffer(assemblyKey);
|
||||
|
||||
if (existingChunkBuffer) {
|
||||
return existingChunkBuffer;
|
||||
}
|
||||
|
||||
const createdChunkBuffer = new Array(total);
|
||||
// Dense initialization - sparse arrays from `new Array(total)` skip holes in
|
||||
// `every`/`some`, which lets incomplete transfers pass completion checks.
|
||||
const createdChunkBuffer = Array.from<ArrayBuffer | undefined>({ length: total });
|
||||
|
||||
this.runtimeStore.setChunkBuffer(assemblyKey, createdChunkBuffer);
|
||||
this.runtimeStore.setChunkCount(assemblyKey, 0);
|
||||
@@ -671,18 +727,21 @@ export class AttachmentTransferService {
|
||||
const receivedChunkCount = this.runtimeStore.getChunkCount(assemblyKey) ?? 0;
|
||||
const completeBuffer = this.runtimeStore.getChunkBuffer(assemblyKey);
|
||||
|
||||
if (
|
||||
!completeBuffer
|
||||
|| (receivedChunkCount !== total && (attachment.receivedBytes ?? 0) < attachment.size)
|
||||
|| !completeBuffer.every((part) => part instanceof ArrayBuffer)
|
||||
) {
|
||||
if (!completeBuffer || receivedChunkCount < total) {
|
||||
return;
|
||||
}
|
||||
|
||||
const blob = new Blob(completeBuffer, { type: attachment.mime });
|
||||
const bufferedChunks = completeBuffer.filter(
|
||||
(part): part is ArrayBuffer => part instanceof ArrayBuffer
|
||||
);
|
||||
|
||||
this.runtimeStore.deleteChunkBuffer(assemblyKey);
|
||||
this.runtimeStore.deleteChunkCount(assemblyKey);
|
||||
// Every chunk index must be present - byte counters are never a substitute
|
||||
// for chunk completeness, otherwise partial files finalize as corrupt blobs.
|
||||
if (bufferedChunks.length !== total || completeBuffer.length !== total) {
|
||||
return;
|
||||
}
|
||||
|
||||
const blob = new Blob(bufferedChunks, { type: attachment.mime });
|
||||
|
||||
if (shouldPersistDownloadedAttachment(attachment)) {
|
||||
await this.persistence.saveFileToDisk(attachment, blob);
|
||||
@@ -691,6 +750,12 @@ export class AttachmentTransferService {
|
||||
attachment.objectUrl = URL.createObjectURL(blob);
|
||||
|
||||
attachment.available = true;
|
||||
|
||||
// Release assembly state only after the attachment is marked available so a
|
||||
// trailing duplicate chunk cannot restart accounting in a fresh buffer.
|
||||
this.runtimeStore.deleteChunkBuffer(assemblyKey);
|
||||
this.runtimeStore.deleteChunkCount(assemblyKey);
|
||||
|
||||
this.runtimeStore.touch();
|
||||
void this.persistence.persistAttachmentMeta(attachment);
|
||||
}
|
||||
@@ -758,7 +823,7 @@ export class AttachmentTransferService {
|
||||
this.updateTransferProgress(attachment, decodedBytes, payload.fromPeerId);
|
||||
this.runtimeStore.touch();
|
||||
|
||||
if (assembly.receivedCount < assembly.total && (attachment.receivedBytes ?? 0) < attachment.size) {
|
||||
if (assembly.receivedCount < assembly.total) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user