Improve attachment memory safety, downloads, and high-memory alert UX.
Queue Release Build / prepare (push) Successful in 20s
Deploy Web Apps / deploy (push) Successful in 9m2s
Queue Release Build / build-windows (push) Successful in 28m8s
Queue Release Build / build-linux (push) Successful in 47m26s
Queue Release Build / build-android (push) Successful in 19m52s
Queue Release Build / finalize (push) Successful in 4m42s
Queue Release Build / prepare (push) Successful in 20s
Deploy Web Apps / deploy (push) Successful in 9m2s
Queue Release Build / build-windows (push) Successful in 28m8s
Queue Release Build / build-linux (push) Successful in 47m26s
Queue Release Build / build-android (push) Successful in 19m52s
Queue Release Build / finalize (push) Successful in 4m42s
Stream large receives to disk with chunk acks to cap renderer RAM, evict off-screen display blobs, and route exports through a disk-aware download service. Fix the high-memory dialog (backdrop dismiss, copy, log actions), allow diagnostics paths in the path jail, and restore persisted image hydration after reload. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+153
-80
@@ -8,10 +8,11 @@ import { selectCurrentUserId } from '../../../../store/users/users.selectors';
|
||||
import { AttachmentStorageService } from '../../infrastructure/services/attachment-storage.service';
|
||||
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 { base64DecodedByteLength, decodeBase64ToUint8Array } from '../../domain/logic/attachment-blob.rules';
|
||||
import { isSharingFromThisDevice, canHostAttachment } from '../../domain/logic/attachment-sharing.rules';
|
||||
import { selectFileRequestPeer } from '../../domain/logic/attachment-request.rules';
|
||||
import {
|
||||
canReceiveAttachment,
|
||||
isAttachmentMedia,
|
||||
shouldCopyLargeUploaderFileToAppData,
|
||||
shouldPersistDownloadedAttachment,
|
||||
shouldStreamAttachmentReceiveToDisk
|
||||
@@ -24,7 +25,6 @@ import {
|
||||
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,
|
||||
ATTACHMENT_WRITE_DOWNLOAD_FAILED_KEY,
|
||||
FILE_NOT_FOUND_REQUEST_ERROR_KEY,
|
||||
@@ -37,6 +37,8 @@ import {
|
||||
type FileCancelEvent,
|
||||
type FileCancelPayload,
|
||||
type FileChunkPayload,
|
||||
type FileChunkAckPayload,
|
||||
type FileChunkAckEvent,
|
||||
type FileNotFoundEvent,
|
||||
type FileNotFoundPayload,
|
||||
type FileRequestEvent,
|
||||
@@ -46,6 +48,7 @@ import {
|
||||
import { AttachmentPersistenceService } from './attachment-persistence.service';
|
||||
import { AttachmentRuntimeStore } from './attachment-runtime.store';
|
||||
import { AttachmentTransferTransportService } from './attachment-transfer-transport.service';
|
||||
import { AttachmentChunkAckService } from './attachment-chunk-ack.service';
|
||||
|
||||
interface DiskReceiveAssembly {
|
||||
path: string;
|
||||
@@ -86,9 +89,10 @@ export class AttachmentTransferService {
|
||||
private readonly attachmentStorage = inject(AttachmentStorageService);
|
||||
private readonly persistence = inject(AttachmentPersistenceService);
|
||||
private readonly transport = inject(AttachmentTransferTransportService);
|
||||
private readonly chunkAcks = inject(AttachmentChunkAckService);
|
||||
|
||||
private readonly diskReceiveAssemblies = new Map<string, DiskReceiveAssembly>();
|
||||
private readonly diskReceiveChains = new Map<string, Promise<void>>();
|
||||
private readonly diskReceiveLocks = new Map<string, Promise<void>>();
|
||||
private readonly activeOutboundTransfers = new Set<string>();
|
||||
|
||||
getAttachmentMetasForMessages(messageIds: string[]): Record<string, AttachmentMeta[]> {
|
||||
@@ -275,6 +279,7 @@ export class AttachmentTransferService {
|
||||
}
|
||||
|
||||
await this.persistPublishedAttachment(attachment, file);
|
||||
this.releaseInMemoryUploadCopyIfPersisted(`${messageId}:${fileId}`, attachment);
|
||||
|
||||
const fileAnnounceEvent: FileAnnounceEvent = {
|
||||
type: 'file-announce',
|
||||
@@ -302,17 +307,23 @@ export class AttachmentTransferService {
|
||||
}
|
||||
}
|
||||
|
||||
handleFileAnnounce(payload: FileAnnouncePayload): void {
|
||||
handleFileAnnounce(payload: FileAnnouncePayload): boolean {
|
||||
const { messageId, file } = payload;
|
||||
|
||||
if (!messageId || !file)
|
||||
return;
|
||||
if (!messageId || !file) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (payload.fromPeerId) {
|
||||
this.runtimeStore.addAnnouncedHost(this.buildRequestKey(messageId, file.id), payload.fromPeerId);
|
||||
}
|
||||
|
||||
const list = [...this.runtimeStore.getAttachmentsForMessage(messageId)];
|
||||
const alreadyKnown = list.find((entry) => entry.id === file.id);
|
||||
|
||||
if (alreadyKnown)
|
||||
return;
|
||||
if (alreadyKnown) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const attachment: Attachment = {
|
||||
id: file.id,
|
||||
@@ -334,6 +345,8 @@ export class AttachmentTransferService {
|
||||
this.runtimeStore.setAttachmentsForMessage(messageId, list);
|
||||
this.runtimeStore.touch();
|
||||
void this.persistence.persistAttachmentMeta(attachment);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
handleFileChunk(payload: FileChunkPayload): void {
|
||||
@@ -365,7 +378,7 @@ export class AttachmentTransferService {
|
||||
}
|
||||
|
||||
if (this.shouldReceiveToDisk(attachment)) {
|
||||
this.enqueueDiskFileChunk(attachment, {
|
||||
void this.receiveDiskChunk(attachment, {
|
||||
data,
|
||||
fileId,
|
||||
fromPeerId,
|
||||
@@ -377,6 +390,12 @@ export class AttachmentTransferService {
|
||||
return;
|
||||
}
|
||||
|
||||
if (attachment.size > MAX_AUTO_SAVE_SIZE_BYTES) {
|
||||
attachment.requestError = this.appI18n.instant(ATTACHMENT_FILE_TOO_LARGE_KEY);
|
||||
this.runtimeStore.touch();
|
||||
return;
|
||||
}
|
||||
|
||||
const decodedBytes = this.transport.decodeBase64(data);
|
||||
const assemblyKey = `${messageId}:${fileId}`;
|
||||
const requestKey = this.buildRequestKey(messageId, fileId);
|
||||
@@ -394,10 +413,21 @@ export class AttachmentTransferService {
|
||||
|
||||
chunkBuffer[index] = decodedBytes.buffer as ArrayBuffer;
|
||||
this.runtimeStore.setChunkCount(assemblyKey, (this.runtimeStore.getChunkCount(assemblyKey) ?? 0) + 1);
|
||||
this.updateTransferProgress(attachment, decodedBytes, fromPeerId);
|
||||
this.updateTransferProgress(attachment, decodedBytes.byteLength, fromPeerId);
|
||||
|
||||
this.runtimeStore.touch();
|
||||
void this.finalizeTransferIfComplete(attachment, assemblyKey, total);
|
||||
this.emitChunkAck({ fileId, fromPeerId, index, messageId });
|
||||
}
|
||||
|
||||
handleFileChunkAck(payload: FileChunkAckPayload): void {
|
||||
const { messageId, fileId, index } = payload;
|
||||
|
||||
if (!messageId || !fileId || typeof index !== 'number' || !Number.isInteger(index) || index < 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.chunkAcks.resolveAck(messageId, fileId, index);
|
||||
}
|
||||
|
||||
async handleFileRequest(payload: FileRequestPayload): Promise<void> {
|
||||
@@ -511,21 +541,6 @@ export class AttachmentTransferService {
|
||||
fromPeerId: string
|
||||
): Promise<void> {
|
||||
const exactKey = `${messageId}:${fileId}`;
|
||||
const originalFile = this.runtimeStore.getOriginalFile(exactKey)
|
||||
?? this.runtimeStore.findOriginalFileByFileId(fileId);
|
||||
|
||||
if (originalFile) {
|
||||
await this.transport.streamFileToPeer(
|
||||
fromPeerId,
|
||||
messageId,
|
||||
fileId,
|
||||
originalFile,
|
||||
() => this.isTransferCancelled(fromPeerId, messageId, fileId)
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const list = this.runtimeStore.getAttachmentsForMessage(messageId);
|
||||
const attachment = list.find((entry) => entry.id === fileId);
|
||||
const diskPath = attachment
|
||||
@@ -544,6 +559,21 @@ export class AttachmentTransferService {
|
||||
return;
|
||||
}
|
||||
|
||||
const originalFile = this.runtimeStore.getOriginalFile(exactKey)
|
||||
?? this.runtimeStore.findOriginalFileByFileId(fileId);
|
||||
|
||||
if (originalFile) {
|
||||
await this.transport.streamFileToPeer(
|
||||
fromPeerId,
|
||||
messageId,
|
||||
fileId,
|
||||
originalFile,
|
||||
() => this.isTransferCancelled(fromPeerId, messageId, fileId)
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (attachment?.isImage) {
|
||||
const roomName = await this.persistence.resolveCurrentRoomName();
|
||||
const legacyDiskPath = await this.attachmentStorage.resolveLegacyImagePath(
|
||||
@@ -630,14 +660,13 @@ export class AttachmentTransferService {
|
||||
const connectedPeers = this.webrtc.getConnectedPeers();
|
||||
const requestKey = this.buildRequestKey(messageId, fileId);
|
||||
const triedPeers = this.runtimeStore.getPendingRequestPeers(requestKey) ?? new Set<string>();
|
||||
|
||||
let targetPeerId: string | undefined;
|
||||
|
||||
if (preferredPeerId && connectedPeers.includes(preferredPeerId) && !triedPeers.has(preferredPeerId)) {
|
||||
targetPeerId = preferredPeerId;
|
||||
} else {
|
||||
targetPeerId = connectedPeers.find((peerId) => !triedPeers.has(peerId));
|
||||
}
|
||||
const announcedHosts = this.runtimeStore.getAnnouncedHosts(requestKey);
|
||||
const targetPeerId = selectFileRequestPeer({
|
||||
connectedPeers,
|
||||
triedPeers,
|
||||
announcedHosts,
|
||||
uploaderPeerId: preferredPeerId
|
||||
});
|
||||
|
||||
if (!targetPeerId) {
|
||||
this.runtimeStore.deletePendingRequest(requestKey);
|
||||
@@ -677,16 +706,16 @@ export class AttachmentTransferService {
|
||||
|
||||
private updateTransferProgress(
|
||||
attachment: Attachment,
|
||||
decodedBytes: Uint8Array,
|
||||
chunkByteLength: number,
|
||||
fromPeerId?: string
|
||||
): void {
|
||||
const now = Date.now();
|
||||
const previousReceived = attachment.receivedBytes ?? 0;
|
||||
|
||||
attachment.receivedBytes = previousReceived + decodedBytes.byteLength;
|
||||
attachment.receivedBytes = previousReceived + chunkByteLength;
|
||||
|
||||
if (fromPeerId) {
|
||||
recordDebugNetworkFileChunk(fromPeerId, decodedBytes.byteLength, now);
|
||||
recordDebugNetworkFileChunk(fromPeerId, chunkByteLength, now);
|
||||
}
|
||||
|
||||
if (!attachment.startedAtMs)
|
||||
@@ -696,7 +725,7 @@ export class AttachmentTransferService {
|
||||
attachment.lastUpdateMs = now;
|
||||
|
||||
const elapsedMs = Math.max(1, now - attachment.lastUpdateMs);
|
||||
const instantaneousBps = (decodedBytes.byteLength / elapsedMs) * 1000;
|
||||
const instantaneousBps = (chunkByteLength / elapsedMs) * 1000;
|
||||
const previousSpeed = attachment.speedBps ?? instantaneousBps;
|
||||
|
||||
attachment.speedBps =
|
||||
@@ -745,6 +774,7 @@ export class AttachmentTransferService {
|
||||
|
||||
this.runtimeStore.touch();
|
||||
void this.persistence.persistAttachmentMeta(attachment);
|
||||
void this.announceLocalHost(attachment);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -789,7 +819,7 @@ export class AttachmentTransferService {
|
||||
|
||||
for (const [, attachments] of this.runtimeStore.getAttachmentEntries()) {
|
||||
for (const attachment of attachments) {
|
||||
if (!isSharingFromThisDevice(attachment, currentUserId)) {
|
||||
if (!canHostAttachment(attachment)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -799,24 +829,64 @@ export class AttachmentTransferService {
|
||||
continue;
|
||||
}
|
||||
|
||||
const fileAnnounceEvent: FileAnnounceEvent = {
|
||||
type: 'file-announce',
|
||||
messageId: attachment.messageId,
|
||||
file: {
|
||||
id: attachment.id,
|
||||
filename: attachment.filename,
|
||||
size: attachment.size,
|
||||
mime: attachment.mime,
|
||||
isImage: attachment.isImage,
|
||||
uploaderPeerId: attachment.uploaderPeerId
|
||||
}
|
||||
};
|
||||
|
||||
this.webrtc.broadcastMessage(fileAnnounceEvent);
|
||||
await this.announceLocalHost(attachment, currentUserId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private releaseInMemoryUploadCopyIfPersisted(exactKey: string, attachment: Attachment): void {
|
||||
if (!attachment.savedPath?.trim() || attachment.size <= MAX_AUTO_SAVE_SIZE_BYTES) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.runtimeStore.deleteOriginalFile(exactKey);
|
||||
|
||||
if (!attachment.objectUrl?.startsWith('blob:')) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
URL.revokeObjectURL(attachment.objectUrl);
|
||||
} catch { /* ignore */ }
|
||||
|
||||
if (!this.isPlayableMedia(attachment)) {
|
||||
attachment.objectUrl = undefined;
|
||||
attachment.available = true;
|
||||
}
|
||||
}
|
||||
|
||||
private async announceLocalHost(attachment: Attachment, hostPeerId?: string | null): Promise<void> {
|
||||
if (!canHostAttachment(attachment)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const announcingPeerId = hostPeerId ?? await this.resolveCurrentUserId();
|
||||
|
||||
if (!announcingPeerId) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.runtimeStore.addAnnouncedHost(
|
||||
this.buildRequestKey(attachment.messageId, attachment.id),
|
||||
announcingPeerId
|
||||
);
|
||||
|
||||
const fileAnnounceEvent: FileAnnounceEvent = {
|
||||
type: 'file-announce',
|
||||
messageId: attachment.messageId,
|
||||
file: {
|
||||
id: attachment.id,
|
||||
filename: attachment.filename,
|
||||
size: attachment.size,
|
||||
mime: attachment.mime,
|
||||
isImage: attachment.isImage,
|
||||
uploaderPeerId: attachment.uploaderPeerId
|
||||
}
|
||||
};
|
||||
|
||||
this.webrtc.broadcastMessage(fileAnnounceEvent);
|
||||
}
|
||||
|
||||
private async applySavedPathObjectUrl(attachment: Attachment, savedPath: string | null): Promise<void> {
|
||||
if (!savedPath) {
|
||||
return;
|
||||
@@ -845,31 +915,47 @@ export class AttachmentTransferService {
|
||||
};
|
||||
}
|
||||
|
||||
private enqueueDiskFileChunk(
|
||||
attachment: Attachment,
|
||||
payload: ValidFileChunkPayload
|
||||
): void {
|
||||
private receiveDiskChunk(attachment: Attachment, payload: ValidFileChunkPayload): void {
|
||||
const assemblyKey = `${payload.messageId}:${payload.fileId}`;
|
||||
const previous = this.diskReceiveChains.get(assemblyKey) ?? Promise.resolve();
|
||||
const previous = this.diskReceiveLocks.get(assemblyKey) ?? Promise.resolve();
|
||||
const next = previous
|
||||
.catch(() => undefined)
|
||||
.then(() => this.handleDiskFileChunk(attachment, assemblyKey, payload))
|
||||
.then(async () => {
|
||||
await this.handleDiskFileChunk(attachment, assemblyKey, payload);
|
||||
this.emitChunkAck(payload);
|
||||
})
|
||||
.catch((error: unknown) => this.handleDiskReceiveFailure(attachment, assemblyKey, error));
|
||||
|
||||
this.diskReceiveChains.set(assemblyKey, next);
|
||||
this.diskReceiveLocks.set(assemblyKey, next);
|
||||
void next.finally(() => {
|
||||
if (this.diskReceiveChains.get(assemblyKey) === next) {
|
||||
this.diskReceiveChains.delete(assemblyKey);
|
||||
if (this.diskReceiveLocks.get(assemblyKey) === next) {
|
||||
this.diskReceiveLocks.delete(assemblyKey);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private emitChunkAck(payload: Pick<ValidFileChunkPayload, 'fileId' | 'fromPeerId' | 'index' | 'messageId'>): void {
|
||||
if (!payload.fromPeerId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const ack: FileChunkAckEvent = {
|
||||
type: 'file-chunk-ack',
|
||||
messageId: payload.messageId,
|
||||
fileId: payload.fileId,
|
||||
index: payload.index
|
||||
};
|
||||
|
||||
this.webrtc.sendToPeer(payload.fromPeerId, ack);
|
||||
}
|
||||
|
||||
private async handleDiskFileChunk(
|
||||
attachment: Attachment,
|
||||
assemblyKey: string,
|
||||
payload: ValidFileChunkPayload
|
||||
): Promise<void> {
|
||||
const decodedBytes = this.transport.decodeBase64(payload.data);
|
||||
const chunkByteLength = base64DecodedByteLength(payload.data);
|
||||
const chunkBytes = decodeBase64ToUint8Array(payload.data);
|
||||
const requestKey = this.buildRequestKey(payload.messageId, payload.fileId);
|
||||
|
||||
this.runtimeStore.deletePendingRequest(requestKey);
|
||||
@@ -889,7 +975,7 @@ export class AttachmentTransferService {
|
||||
throw new Error(this.appI18n.instant(ATTACHMENT_CHUNKS_OUT_OF_ORDER_KEY));
|
||||
}
|
||||
|
||||
const didAppend = await this.attachmentStorage.appendBase64(assembly.path, payload.data);
|
||||
const didAppend = await this.attachmentStorage.appendBytes(assembly.path, chunkBytes);
|
||||
|
||||
if (!didAppend) {
|
||||
throw new Error(this.appI18n.instant(ATTACHMENT_WRITE_DOWNLOAD_FAILED_KEY));
|
||||
@@ -897,7 +983,7 @@ export class AttachmentTransferService {
|
||||
|
||||
assembly.receivedIndexes.add(payload.index);
|
||||
assembly.receivedCount += 1;
|
||||
this.updateTransferProgress(attachment, decodedBytes, payload.fromPeerId);
|
||||
this.updateTransferProgress(attachment, chunkByteLength, payload.fromPeerId);
|
||||
this.runtimeStore.touch();
|
||||
|
||||
if (assembly.receivedCount < assembly.total) {
|
||||
@@ -905,25 +991,12 @@ 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) {
|
||||
throw new Error(this.appI18n.instant(ATTACHMENT_OPEN_DOWNLOAD_FAILED_KEY));
|
||||
}
|
||||
|
||||
attachment.available = true;
|
||||
attachment.objectUrl = undefined;
|
||||
this.diskReceiveAssemblies.delete(assemblyKey);
|
||||
this.runtimeStore.touch();
|
||||
void this.persistence.persistAttachmentMeta(attachment);
|
||||
void this.announceLocalHost(attachment);
|
||||
}
|
||||
|
||||
private async getOrCreateDiskReceiveAssembly(
|
||||
|
||||
Reference in New Issue
Block a user