diff --git a/agents-docs/features/attachments.md b/agents-docs/features/attachments.md index 25ea20d..535ea2b 100644 --- a/agents-docs/features/attachments.md +++ b/agents-docs/features/attachments.md @@ -78,6 +78,7 @@ This area does **not** own: - Transfers are between connected peers only (no server CDN). - Receive strategy is decided once at request time by `canReceiveAttachment` (`attachment.logic.ts`): ≤ 10 MB assembles in memory everywhere; > 10 MB streams to disk on Electron/Capacitor, assembles in memory on the browser up to its 50 MB persist cap, and is rejected with a visible `fileTooLarge` error beyond that. `handleFileChunk` must accept whatever the request gate admitted — a stricter chunk-time size cap silently drops chunks and stalls the transfer. - Visibility-based blob lifecycle on desktop: revoke `blob:` URLs when messages scroll off-screen if disk can rehydrate. +- Startup hydration is an availability boundary: file requests and host re-announcements wait for persisted metadata before inspecting local files. A host re-announces persisted files after reload and on peer connection even from non-chat routes, and can recover an original Electron source path by copying it into app data on demand before serving. - Display-blob memory invariants (added 2026-07-14, RAM investigation): - Inline hydration (`chat-message-item` effect) only runs for messages that are visible or within the `IntersectionObserver` root margin — gated by `attachment-hydration-visibility.rules.ts`. Off-screen rows never load blobs. - Disk-hydrated blobs are **not** duplicated into `AttachmentRuntimeStore.originalFiles`; peer requests are served from the disk path (`streamRequestedFile` prefers `resolveExistingPath`). `originalFiles` only holds uploads/downloads that have no disk copy yet. @@ -120,6 +121,7 @@ This area does **not** own: | Date | Change | |------|--------| +| 2026-07-14 | Prevented reload-time `file-not-found` responses by waiting for metadata hydration, re-announcing hosts outside chat routes, and recovering persisted source paths on demand | | 2026-07-14 | Blob-memory invariants: visibility-gated hydration, no `originalFiles` duplication for disk-backed blobs, revoke-on-destroy, inactive-room blob sweep | | 2026-07-13 | Capacitor download/export to public `Documents` via `CapacitorAttachmentExportService` | | 2026-07-05 | Expanded to full contract style | diff --git a/toju-app/src/app/domains/attachment/README.md b/toju-app/src/app/domains/attachment/README.md index 02cda8e..43419aa 100644 --- a/toju-app/src/app/domains/attachment/README.md +++ b/toju-app/src/app/domains/attachment/README.md @@ -117,6 +117,8 @@ If the sender cannot find the file, it replies with `file-not-found`. The transf Peers that finish downloading a file re-announce it and register themselves as mirror hosts. New download requests prefer mirror hosts over the original uploader so the sharer's device is not the only upload source. Repeat `file-announce` events for already-known attachments update the host list but do not re-trigger auto-download. Outgoing `file-announce` broadcasts are also relayed to sibling devices through `account_sync` (see `infrastructure/realtime/account-sync/account-sync.rules.ts`) so a second client of the same user learns attachment metadata even when it cannot P2P to itself. +After reload, serving and re-announcing wait for persisted attachment metadata to finish hydrating. Hosted files are announced even when the uploader is not currently viewing a room, so reconnecting peers can discover them from dashboard and other non-chat routes. If only an original Electron file-picker path survived, the first request copies that file into app data before streaming it; a request must not receive `file-not-found` merely because it raced startup hydration. + ```mermaid sequenceDiagram participant R as Receiver diff --git a/toju-app/src/app/domains/attachment/application/services/attachment-manager.service.ts b/toju-app/src/app/domains/attachment/application/services/attachment-manager.service.ts index 6ef26f1..81af07e 100644 --- a/toju-app/src/app/domains/attachment/application/services/attachment-manager.service.ts +++ b/toju-app/src/app/domains/attachment/application/services/attachment-manager.service.ts @@ -61,8 +61,11 @@ export class AttachmentManagerService { void this.persistence.initFromDatabase().then(async () => { if (this.watchedRoomId) { await this.restoreLocalAttachmentsForRoom(this.watchedRoomId); - await this.announceHostedAttachments(); } + + // Announce regardless of the current route - a reloaded uploader + // sitting on the dashboard still hosts its persisted files. + await this.announceHostedAttachments(); }); } }); @@ -88,12 +91,19 @@ export class AttachmentManagerService { this.webrtc.onPeerConnected.subscribe(() => { if (this.watchedRoomId) { - void this.restoreLocalAttachmentsForRoom(this.watchedRoomId).then(async () => { + const watchedRoomId = this.watchedRoomId; + + void this.restoreLocalAttachmentsForRoom(watchedRoomId).then(async () => { await this.announceHostedAttachments(); }); - void this.requestAutoDownloadsForRoom(this.watchedRoomId); + void this.requestAutoDownloadsForRoom(watchedRoomId); + return; } + + // No room open (e.g. reloaded onto the dashboard) - still announce + // persisted files so peers relearn this device hosts them. + void this.announceHostedAttachments(); }); } diff --git a/toju-app/src/app/domains/attachment/application/services/attachment-transfer.service.spec.ts b/toju-app/src/app/domains/attachment/application/services/attachment-transfer.service.spec.ts index bbc453f..083075c 100644 --- a/toju-app/src/app/domains/attachment/application/services/attachment-transfer.service.spec.ts +++ b/toju-app/src/app/domains/attachment/application/services/attachment-transfer.service.spec.ts @@ -42,6 +42,7 @@ describe('AttachmentTransferService', () => { resolveCurrentRoomName: ReturnType; resolveStorageContainerName: ReturnType; ensureInlineDisplayObjectUrl: ReturnType; + ensurePersistedUploadHost: ReturnType; }; let attachmentStorage: { canWriteFiles: ReturnType; @@ -83,7 +84,8 @@ describe('AttachmentTransferService', () => { persistUploadCopyFromSourcePath: vi.fn(async () => null), resolveCurrentRoomName: vi.fn(async () => null), resolveStorageContainerName: vi.fn(async () => 'room'), - ensureInlineDisplayObjectUrl: vi.fn(async () => true) + ensureInlineDisplayObjectUrl: vi.fn(async () => true), + ensurePersistedUploadHost: vi.fn(async () => false) }; attachmentStorage = { @@ -741,6 +743,93 @@ describe('AttachmentTransferService', () => { ); }); + it('hydrates persisted metadata before serving a file request after reload', async () => { + // Reload race: the peer's file-request arrives before initFromDatabase has + // filled the runtime store. Serving must wait for hydration instead of + // replying file-not-found for a file that is on disk. + attachmentStorage.resolveExistingPath.mockResolvedValue('/appdata/server/room/files/setup.exe'); + attachmentStorage.getFileSize.mockResolvedValue(12 * 1024 * 1024); + persistence.whenReady.mockImplementation(async () => { + const attachment = registerIncomingGenericFile(12 * 1024 * 1024); + + attachment.savedPath = '/appdata/server/room/files/setup.exe'; + }); + + const service = createService(); + + await service.handleFileRequest({ + messageId: MESSAGE_ID, + fileId: FILE_ID, + fromPeerId: 'peer-2' + }); + + expect(transport.streamFileFromDiskToPeer).toHaveBeenCalledWith( + 'peer-2', + MESSAGE_ID, + FILE_ID, + '/appdata/server/room/files/setup.exe', + expect.any(Function) + ); + + expect(webrtc.sendToPeer).not.toHaveBeenCalledWith('peer-2', expect.objectContaining({ type: 'file-not-found' })); + }); + + it('copies an external upload into app data when serving a request after reload', async () => { + // savedPath missing (publish copy failed or pre-fix upload) but the original + // file-picker path survived - the serve path must persist it on demand + // instead of replying file-not-found. + attachmentStorage.resolveExistingPath + .mockResolvedValueOnce(null) + .mockResolvedValue('/appdata/server/room/files/setup.exe'); + + attachmentStorage.getFileSize.mockResolvedValue(628 * 1024 * 1024); + persistence.ensurePersistedUploadHost.mockImplementation(async (attachment: Attachment) => { + attachment.savedPath = '/appdata/server/room/files/setup.exe'; + return true; + }); + + const service = createService(); + const attachment = registerIncomingGenericFile(628 * 1024 * 1024); + + attachment.filePath = '/home/nim/Downloads/setup.exe'; + + await service.handleFileRequest({ + messageId: MESSAGE_ID, + fileId: FILE_ID, + fromPeerId: 'peer-2' + }); + + expect(persistence.ensurePersistedUploadHost).toHaveBeenCalledWith(attachment, { hydrateMediaForDisplay: false }); + expect(transport.streamFileFromDiskToPeer).toHaveBeenCalledWith( + 'peer-2', + MESSAGE_ID, + FILE_ID, + '/appdata/server/room/files/setup.exe', + expect.any(Function) + ); + + expect(webrtc.sendToPeer).not.toHaveBeenCalledWith('peer-2', expect.objectContaining({ type: 'file-not-found' })); + }); + + it('hydrates persisted metadata before re-announcing hosted attachments', async () => { + attachmentStorage.resolveExistingPath.mockResolvedValue('/appdata/server/room/files/setup.exe'); + persistence.whenReady.mockImplementation(async () => { + const attachment = registerIncomingGenericFile(12 * 1024 * 1024); + + attachment.savedPath = '/appdata/server/room/files/setup.exe'; + }); + + const service = createService(); + + await service.reannounceHostedAttachments(PEER_ID); + + expect(webrtc.broadcastMessage).toHaveBeenCalledWith(expect.objectContaining({ + type: 'file-announce', + messageId: MESSAGE_ID, + file: expect.objectContaining({ id: FILE_ID }) + })); + }); + it('re-announces hosted attachments that can still be served from disk', async () => { attachmentStorage.resolveExistingPath.mockResolvedValue('/appdata/server/room/files/setup.exe'); diff --git a/toju-app/src/app/domains/attachment/application/services/attachment-transfer.service.ts b/toju-app/src/app/domains/attachment/application/services/attachment-transfer.service.ts index fd95c44..17e3c72 100644 --- a/toju-app/src/app/domains/attachment/application/services/attachment-transfer.service.ts +++ b/toju-app/src/app/domains/attachment/application/services/attachment-transfer.service.ts @@ -587,13 +587,32 @@ export class AttachmentTransferService { fileId: string, fromPeerId: string ): Promise { + // A request can race ahead of post-reload metadata hydration; answering + // file-not-found for a file that is on disk makes peers give up for good. + await this.persistence.whenReady(); + const exactKey = `${messageId}:${fileId}`; const list = this.runtimeStore.getAttachmentsForMessage(messageId); const attachment = list.find((entry) => entry.id === fileId); - const diskPath = attachment + + let diskPath = attachment ? await this.attachmentStorage.resolveExistingPath(attachment) : null; + if (!diskPath && attachment && attachment.filePath?.trim()) { + // Only the original file-picker path survived the reload (publish copy + // failed or predates it). Copy it into app data now so this and future + // requests are served from disk instead of failing. + const persisted = await this.persistence.ensurePersistedUploadHost( + attachment, + { hydrateMediaForDisplay: false } + ); + + if (persisted) { + diskPath = await this.attachmentStorage.resolveExistingPath(attachment); + } + } + if (diskPath && shouldServeAttachmentFromDiskPath(await this.attachmentStorage.getFileSize(diskPath))) { await this.transport.streamFileFromDiskToPeer( fromPeerId, @@ -868,6 +887,10 @@ export class AttachmentTransferService { return; } + // After a reload the runtime store is only populated once persisted + // metadata has been hydrated - announcing before that sees no attachments. + await this.persistence.whenReady(); + for (const [, attachments] of this.runtimeStore.getAttachmentEntries()) { for (const attachment of attachments) { if (!canHostAttachment(attachment)) {