diff --git a/agents-docs/features/attachments.md b/agents-docs/features/attachments.md index e38be94..25ea20d 100644 --- a/agents-docs/features/attachments.md +++ b/agents-docs/features/attachments.md @@ -78,6 +78,12 @@ 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. +- 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. + - `revokeAttachmentDisplayBlob` also drops the `originalFiles` entry when `savedPath` exists, so revocation actually frees the bytes. + - Message rows always revoke their display blobs on destroy (pins are respected), not only when they were visible. + - Room switch sweeps display blobs of all other rooms (`releaseDisplayBlobsForInactiveRooms`, driven by `collectMessageIdsForInactiveRoomBlobRelease`). Messages with unknown room mapping are left alone. - "Shared from your device" badge only when bytes are local to the viewing user. --- @@ -114,5 +120,6 @@ This area does **not** own: | Date | Change | |------|--------| +| 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/agents-docs/features/messaging.md b/agents-docs/features/messaging.md index 016aa08..5ed159e 100644 --- a/agents-docs/features/messaging.md +++ b/agents-docs/features/messaging.md @@ -95,8 +95,9 @@ Rules (`message-sync.rules.ts`, `message-integrity.rules.ts`): - Merges are **additive** — sparser peers never wipe richer local history. - `findMissingIds` compares remote inventory to local `revision` / `headHash` (and legacy `ts` / `rc` / `ac`). -- `INVENTORY_LIMIT` = 1_000_000 (safety ceiling for pathological rooms). +- `INVENTORY_LIMIT` = `FULL_SYNC_LIMIT` = **20_000** (2026-07-14, RAM investigation; previously 1_000_000). Building an inventory or full-sync batch loads full message rows into memory, so the ceiling must stay bounded. Only the most recent 20k messages per room are reconciled peer-to-peer; older messages stay local-only. `ACCOUNT_SYNC_MESSAGE_LIMIT` follows `FULL_SYNC_LIMIT`. - Sync polling: 10 s when catching up, 15 min after a clean cycle (`SYNC_POLL_FAST_MS` / `SYNC_POLL_SLOW_MS`). +- NgRx store retention: on room switch, inactive rooms are pruned to the most recent `CACHED_INACTIVE_ROOM_MESSAGE_LIMIT` = **100** messages each (`messages.reducer.ts`), keeping return-visit rendering instant while bounding store growth across many rooms. The active room is never pruned; the local DB keeps full history. --- @@ -204,6 +205,7 @@ Deletes keep tombstone semantics (`isDeleted`, empty `content`) so inventory syn | Date | Change | |------|--------| +| 2026-07-14 | RAM bounds: `INVENTORY_LIMIT`/`FULL_SYNC_LIMIT` lowered to 20k (most recent messages reconcile); NgRx prunes inactive rooms to 100 cached messages on room switch | | 2026-07-13 | Incoming DMs raise system notifications through the notifications domain (previously unread-badge only) | | 2026-07-13 | Recipient matching for DM and `direct-call` events must span all local identity aliases (provisioned actor ids included) | | 2026-07-05 | Initial comprehensive messaging contract (replaces thin direct-messaging summary) | diff --git a/agents-docs/features/voice-webrtc.md b/agents-docs/features/voice-webrtc.md index d030cf1..55cc07b 100644 --- a/agents-docs/features/voice-webrtc.md +++ b/agents-docs/features/voice-webrtc.md @@ -40,7 +40,14 @@ Relay rules: RTC messages require shared server membership (except DM-specific t ## P2P data channel -Carries voice/screen control messages, chat, attachments, and state sync — not server-relayed. Data-channel failure triggers peer renegotiation or full rebuild (see realtime README). +Carries voice/screen control messages, chat, attachments, and state sync — not server-relayed. Data-channel failure triggers peer renegotiation or full rebuild (see realtime README). When a failed control channel is replaced (`replaceDataChannel`), the old channel is closed first so its SCTP resources are released. + +## Media memory invariants (2026-07-14, RAM investigation) + +- `removePeer` / `closeAllPeers` clear **all four** remote stream maps, including `remotePeerCameraStreams` (previously leaked per departed peer). +- Video tiles (`voice-workspace-stream-tile`) pause and null `srcObject` in `ngOnDestroy` (`voice-workspace-stream-video.rules.ts`) so Chromium releases decoder/frame buffers immediately. +- `debug-network-metrics` drops a peer's entry when the peer is fully removed and caps the store at `MAX_TRACKED_DEBUG_NETWORK_PEERS` = 200 (oldest evicted). +- Electron registers `setDisplayMediaRequestHandler` once per app run (guarded in `create-window.ts`), not on every window recreation. ## Mobile / Capacitor @@ -57,4 +64,5 @@ Background voice uses Android foreground service + iOS audio/CallKit bridges — | Date | Change | |------|--------| +| 2026-07-14 | Media memory invariants: camera-stream map cleanup, tile `srcObject` release, debug-metrics cap, single display-media handler registration, replaced data channels closed | | 2026-07-05 | Initial cross-context voice/WebRTC contract | diff --git a/electron/window/create-window.ts b/electron/window/create-window.ts index 1270f2d..730bc0f 100644 --- a/electron/window/create-window.ts +++ b/electron/window/create-window.ts @@ -12,12 +12,14 @@ import * as path from 'path'; import { DESKTOP_APP_DISPLAY_NAME } from '../app/desktop-branding.rules'; import { readDesktopSettings } from '../desktop-settings'; import { resolveDevelopmentClientUrl } from './dev-client-url.rules'; +import { shouldRegisterDisplayMediaHandler } from './display-media-handler.rules'; let mainWindow: BrowserWindow | null = null; let tray: Tray | null = null; let closeToTrayEnabled = true; let appQuitting = false; let youtubeRequestHeadersConfigured = false; +let displayMediaHandlerConfigured = false; const WINDOW_STATE_CHANGED_CHANNEL = 'window-state-changed'; const YOUTUBE_EMBED_REFERRER = 'https://toju.app/'; @@ -189,31 +191,12 @@ function emitWindowState(): void { }); } -export async function createWindow(): Promise { - const windowIconPath = getWindowIconPath(); +function ensureDisplayMediaRequestHandler(): void { + if (!shouldRegisterDisplayMediaHandler(process.platform, displayMediaHandlerConfigured)) { + return; + } - closeToTrayEnabled = readDesktopSettings().closeToTray; - ensureTray(); - ensureYoutubeEmbedRequestHeaders(); - - mainWindow = new BrowserWindow({ - width: 1400, - height: 900, - minWidth: 800, - minHeight: 600, - frame: false, - title: DESKTOP_APP_DISPLAY_NAME, - titleBarStyle: 'hidden', - backgroundColor: '#0a0a0f', - ...(windowIconPath ? { icon: windowIconPath } : {}), - webPreferences: { - backgroundThrottling: false, - nodeIntegration: false, - contextIsolation: true, - preload: path.join(__dirname, '..', 'preload.js'), - webSecurity: true - } - }); + displayMediaHandlerConfigured = true; if (process.platform === 'linux') { session.defaultSession.setDisplayMediaRequestHandler( @@ -241,41 +224,70 @@ export async function createWindow(): Promise { }, { useSystemPicker: true } ); + + return; } - if (process.platform === 'win32') { - session.defaultSession.setDisplayMediaRequestHandler( - async (request, respond) => { - // On Windows the system picker (useSystemPicker: true) is preferred. - // This handler is only reached when the system picker is unavailable. - // Include loopback audio when the renderer requested it so that - // getDisplayMedia receives an audio track and the renderer-side - // restrictOwnAudio constraint can keep the app's own voice playback - // out of the captured stream. - try { - const sources = await desktopCapturer.getSources({ - types: ['window', 'screen'], - thumbnailSize: { width: 150, height: 150 } + session.defaultSession.setDisplayMediaRequestHandler( + async (request, respond) => { + // On Windows the system picker (useSystemPicker: true) is preferred. + // This handler is only reached when the system picker is unavailable. + // Include loopback audio when the renderer requested it so that + // getDisplayMedia receives an audio track and the renderer-side + // restrictOwnAudio constraint can keep the app's own voice playback + // out of the captured stream. + try { + const sources = await desktopCapturer.getSources({ + types: ['window', 'screen'], + thumbnailSize: { width: 150, height: 150 } + }); + const firstSource = sources[0]; + + if (firstSource) { + respond({ + video: firstSource, + ...(request.audioRequested ? { audio: 'loopback' } : {}) }); - const firstSource = sources[0]; - if (firstSource) { - respond({ - video: firstSource, - ...(request.audioRequested ? { audio: 'loopback' } : {}) - }); - - return; - } - } catch { - // desktopCapturer also unavailable + return; } + } catch { + // desktopCapturer also unavailable + } - respond({}); - }, - { useSystemPicker: true } - ); - } + respond({}); + }, + { useSystemPicker: true } + ); +} + +export async function createWindow(): Promise { + const windowIconPath = getWindowIconPath(); + + closeToTrayEnabled = readDesktopSettings().closeToTray; + ensureTray(); + ensureYoutubeEmbedRequestHeaders(); + + mainWindow = new BrowserWindow({ + width: 1400, + height: 900, + minWidth: 800, + minHeight: 600, + frame: false, + title: DESKTOP_APP_DISPLAY_NAME, + titleBarStyle: 'hidden', + backgroundColor: '#0a0a0f', + ...(windowIconPath ? { icon: windowIconPath } : {}), + webPreferences: { + backgroundThrottling: false, + nodeIntegration: false, + contextIsolation: true, + preload: path.join(__dirname, '..', 'preload.js'), + webSecurity: true + } + }); + + ensureDisplayMediaRequestHandler(); if (process.env['NODE_ENV'] === 'development') { await mainWindow.loadURL(resolveDevelopmentClientUrl(process.env['SSL'] === 'true')); diff --git a/electron/window/display-media-handler.rules.spec.ts b/electron/window/display-media-handler.rules.spec.ts new file mode 100644 index 0000000..86a5ba1 --- /dev/null +++ b/electron/window/display-media-handler.rules.spec.ts @@ -0,0 +1,22 @@ +import { + describe, + expect, + it +} from 'vitest'; +import { shouldRegisterDisplayMediaHandler } from './display-media-handler.rules'; + +describe('shouldRegisterDisplayMediaHandler', () => { + it('registers once for platforms that need a fallback picker', () => { + expect(shouldRegisterDisplayMediaHandler('linux', false)).toBe(true); + expect(shouldRegisterDisplayMediaHandler('win32', false)).toBe(true); + }); + + it('does not re-register on window recreation', () => { + expect(shouldRegisterDisplayMediaHandler('linux', true)).toBe(false); + expect(shouldRegisterDisplayMediaHandler('win32', true)).toBe(false); + }); + + it('never registers on platforms with a native picker', () => { + expect(shouldRegisterDisplayMediaHandler('darwin', false)).toBe(false); + }); +}); diff --git a/electron/window/display-media-handler.rules.ts b/electron/window/display-media-handler.rules.ts new file mode 100644 index 0000000..475eb63 --- /dev/null +++ b/electron/window/display-media-handler.rules.ts @@ -0,0 +1,16 @@ +/** + * The display-media request handler is a session-level singleton. Registering + * it inside `createWindow()` without a guard re-installed a fresh handler + * (holding fresh closures) every time the window was recreated from the tray + * or a deep link. Registration happens at most once per app run. + */ +export function shouldRegisterDisplayMediaHandler( + platform: NodeJS.Platform, + alreadyConfigured: boolean +): boolean { + if (alreadyConfigured) { + return false; + } + + return platform === 'linux' || platform === 'win32'; +} diff --git a/toju-app/src/app/domains/attachment/README.md b/toju-app/src/app/domains/attachment/README.md index 6991129..02cda8e 100644 --- a/toju-app/src/app/domains/attachment/README.md +++ b/toju-app/src/app/domains/attachment/README.md @@ -210,9 +210,11 @@ Components read attachment state reactively through the store's signals. The sto Image inline previews on Electron/desktop use renderer `blob:` URLs rebuilt from disk. To cap RAM in media-heavy channels: - **Room restore** (`restoreLocalAttachmentsForRoom`) resolves `savedPath` for hosting only — it does not hydrate every image blob up front. -- **Visibility** (`ChatMessageItemComponent` + `IntersectionObserver` on the chat scrollport) hydrates blobs when a message enters view (with `ATTACHMENT_BLOB_VISIBILITY_ROOT_MARGIN`) and revokes them when it leaves, as long as a disk path can rehydrate later (`canRevokeAttachmentDisplayBlob`). +- **Visibility** (`ChatMessageItemComponent` + `IntersectionObserver` on the chat scrollport) hydrates blobs when a message enters view (with `ATTACHMENT_BLOB_VISIBILITY_ROOT_MARGIN`) and revokes them when it leaves, as long as a disk path can rehydrate later (`canRevokeAttachmentDisplayBlob`). Hydration itself is visibility-gated (`attachment-hydration-visibility.rules.ts`) — off-screen rows never load blobs, and destroyed rows always release theirs. +- **No byte duplication:** disk-hydrated blobs are never copied into `AttachmentRuntimeStore.originalFiles` (`applyAttachmentBlob`); revocation of a disk-backed blob also drops any stale `originalFiles` entry. `originalFiles` only carries uploads/downloads that have no disk copy yet. +- **Room switch sweep:** `releaseDisplayBlobsForInactiveRooms` revokes display blobs for messages of all other rooms when navigation lands on a different room. - **Pinned overlays** (lightbox / image gallery) call `pinDisplayBlobs` so an open full-screen view is not revoked while its message scrolls off-screen. -- **Serving** is unaffected: peers still download from `savedPath` / `filePath`; blob URLs are display-only. +- **Serving** is unaffected: peers still download from `savedPath` / `filePath` (`streamRequestedFile` prefers the disk path); blob URLs are display-only. While a revoked image waits to rehydrate, chat renders the existing image-grid spinner skeleton (`isAttachmentPendingInlineHydration`). 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 8bf4c9d..6ef26f1 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 @@ -10,7 +10,11 @@ import { RealtimeSessionFacade } from '../../../../core/realtime'; import { selectCurrentUserId } from '../../../../store/users/users.selectors'; import { DatabaseService } from '../../../../infrastructure/persistence'; import { yieldToAttachmentHydrationLoop } from '../../domain/logic/attachment-blob.rules'; -import { buildAttachmentDisplayPinKey, shouldRevokeDisplayBlobForAttachment } from '../../domain/logic/attachment-blob-eviction.rules'; +import { + buildAttachmentDisplayPinKey, + collectMessageIdsForInactiveRoomBlobRelease, + shouldRevokeDisplayBlobForAttachment +} from '../../domain/logic/attachment-blob-eviction.rules'; import { getWatchedAttachmentRoomIdFromUrl, isDirectMessageAttachmentRoomId, @@ -68,8 +72,14 @@ export class AttachmentManagerService { return; } + const previousRoomId = this.watchedRoomId; + this.watchedRoomId = this.extractWatchedRoomId(event.urlAfterRedirects || event.url); + if (this.watchedRoomId !== previousRoomId) { + this.releaseDisplayBlobsForInactiveRooms(this.watchedRoomId); + } + if (this.watchedRoomId) { void this.restoreLocalAttachmentsForRoom(this.watchedRoomId); void this.requestAutoDownloadsForRoom(this.watchedRoomId); @@ -211,6 +221,18 @@ export class AttachmentManagerService { } } + releaseDisplayBlobsForInactiveRooms(activeRoomId: string | null): void { + const messageIds = collectMessageIdsForInactiveRoomBlobRelease( + Array.from(this.runtimeStore.getAttachmentEntries(), ([messageId]) => messageId), + (messageId) => this.runtimeStore.getMessageRoomId(messageId) ?? null, + activeRoomId + ); + + for (const messageId of messageIds) { + this.revokeOffscreenDisplayBlobsForMessage(messageId); + } + } + requestFile(messageId: string, attachment: Attachment): Promise { return this.transfer.requestFile(messageId, attachment); } diff --git a/toju-app/src/app/domains/attachment/application/services/attachment-persistence.service.spec.ts b/toju-app/src/app/domains/attachment/application/services/attachment-persistence.service.spec.ts index 6476ce4..ea568ad 100644 --- a/toju-app/src/app/domains/attachment/application/services/attachment-persistence.service.spec.ts +++ b/toju-app/src/app/domains/attachment/application/services/attachment-persistence.service.spec.ts @@ -134,6 +134,35 @@ describe('AttachmentPersistenceService', () => { expect(attachmentStorage.readFile).not.toHaveBeenCalled(); }); + it('does not duplicate disk-hydrated bytes into the original-file cache', async () => { + const injector = Injector.create({ + providers: [ + AttachmentPersistenceService, + AttachmentRuntimeStore, + { provide: DatabaseService, useValue: database }, + { provide: AttachmentStorageService, useValue: attachmentStorage }, + { provide: Store, useValue: { select: () => of('room-1') } } + ] + }); + const service = runInInjectionContext(injector, () => injector.get(AttachmentPersistenceService)); + const runtimeStore = injector.get(AttachmentRuntimeStore); + const attachment = { + id: 'att-1', + messageId: 'msg-1', + filename: 'photo.png', + size: 3, + mime: 'image/png', + isImage: true, + savedPath: '/appdata/photo.png', + available: false + }; + + await expect(service.ensureInlineDisplayObjectUrl(attachment)).resolves.toBe(true); + + expect(attachment.objectUrl).toMatch(/^blob:/); + expect(runtimeStore.getOriginalFile('msg-1:att-1')).toBeUndefined(); + }); + it('restores a blob from a whole-file read when the store cannot read chunks (browser store)', async () => { attachmentStorage.canReadFileChunks.mockReturnValue(false); @@ -263,4 +292,66 @@ describe('AttachmentPersistenceService', () => { revokeSpy.mockRestore(); }); + + it('releases the cached original file when revoking a disk-backed display blob', () => { + const injector = Injector.create({ + providers: [ + AttachmentPersistenceService, + AttachmentRuntimeStore, + { provide: DatabaseService, useValue: database }, + { provide: AttachmentStorageService, useValue: attachmentStorage }, + { provide: Store, useValue: { select: () => of('room-1') } } + ] + }); + const service = runInInjectionContext(injector, () => injector.get(AttachmentPersistenceService)); + const runtimeStore = injector.get(AttachmentRuntimeStore); + const attachment = { + id: 'att-1', + messageId: 'msg-1', + filename: 'photo.png', + size: 3, + mime: 'image/png', + isImage: true, + savedPath: '/appdata/photo.png', + available: true, + objectUrl: 'blob:http://localhost/abc' + }; + const revokeSpy = vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => undefined); + + runtimeStore.setOriginalFile('msg-1:att-1', new File(['abc'], 'photo.png', { type: 'image/png' })); + + expect(service.revokeAttachmentDisplayBlob(attachment)).toBe(true); + expect(runtimeStore.getOriginalFile('msg-1:att-1')).toBeUndefined(); + + revokeSpy.mockRestore(); + }); + + it('keeps the cached original file when the attachment is not persisted to disk yet', () => { + const injector = Injector.create({ + providers: [ + AttachmentPersistenceService, + AttachmentRuntimeStore, + { provide: DatabaseService, useValue: database }, + { provide: AttachmentStorageService, useValue: attachmentStorage }, + { provide: Store, useValue: { select: () => of('room-1') } } + ] + }); + const service = runInInjectionContext(injector, () => injector.get(AttachmentPersistenceService)); + const runtimeStore = injector.get(AttachmentRuntimeStore); + const attachment = { + id: 'att-2', + messageId: 'msg-1', + filename: 'clip.mp4', + size: 3, + mime: 'video/mp4', + isImage: false, + available: true, + objectUrl: 'blob:http://localhost/def' + }; + + runtimeStore.setOriginalFile('msg-1:att-2', new File(['abc'], 'clip.mp4', { type: 'video/mp4' })); + + expect(service.revokeAttachmentDisplayBlob(attachment)).toBe(false); + expect(runtimeStore.getOriginalFile('msg-1:att-2')).toBeDefined(); + }); }); diff --git a/toju-app/src/app/domains/attachment/application/services/attachment-persistence.service.ts b/toju-app/src/app/domains/attachment/application/services/attachment-persistence.service.ts index d95b5db..d35f0d6 100644 --- a/toju-app/src/app/domains/attachment/application/services/attachment-persistence.service.ts +++ b/toju-app/src/app/domains/attachment/application/services/attachment-persistence.service.ts @@ -141,6 +141,13 @@ export class AttachmentPersistenceService { this.revokeAttachmentObjectUrl(attachment); attachment.objectUrl = undefined; + // Once the bytes live on disk, the cached File duplicate is redundant: + // peer requests are served from the disk path and re-display rehydrates + // from disk, so keeping it would double the attachment's memory cost. + if (attachment.savedPath?.trim()) { + this.runtimeStore.deleteOriginalFile(`${attachment.messageId}:${attachment.id}`); + } + return true; } @@ -388,15 +395,12 @@ export class AttachmentPersistenceService { return true; } + // The blob always comes from a disk path here, so peers are served from + // disk and no original-file copy is cached; caching one would keep a second + // full copy of the bytes alive for the whole session. private applyAttachmentBlob(attachment: Attachment, blob: Blob): void { attachment.objectUrl = URL.createObjectURL(blob); attachment.available = true; - - this.runtimeStore.setOriginalFile( - `${attachment.messageId}:${attachment.id}`, - new File([blob], attachment.filename, { type: attachment.mime }) - ); - this.runtimeStore.touch(); } diff --git a/toju-app/src/app/domains/attachment/domain/logic/attachment-blob-eviction.rules.spec.ts b/toju-app/src/app/domains/attachment/domain/logic/attachment-blob-eviction.rules.spec.ts index 8d47f43..d88ce15 100644 --- a/toju-app/src/app/domains/attachment/domain/logic/attachment-blob-eviction.rules.spec.ts +++ b/toju-app/src/app/domains/attachment/domain/logic/attachment-blob-eviction.rules.spec.ts @@ -7,6 +7,7 @@ import { import { buildAttachmentDisplayPinKey, canRevokeAttachmentDisplayBlob, + collectMessageIdsForInactiveRoomBlobRelease, shouldRevokeDisplayBlobForAttachment } from './attachment-blob-eviction.rules'; @@ -58,4 +59,40 @@ describe('attachment-blob-eviction rules', () => { expect(shouldRevokeDisplayBlobForAttachment('msg-1', attachment, new Set())).toBe(true); }); + + describe('collectMessageIdsForInactiveRoomBlobRelease', () => { + const messageRoomIds = new Map([ + ['msg-a', 'room-1'], + ['msg-b', 'room-2'], + ['msg-c', 'room-2'] + ]); + + it('selects messages that belong to rooms other than the active one', () => { + expect(collectMessageIdsForInactiveRoomBlobRelease( + [ + 'msg-a', + 'msg-b', + 'msg-c' + ], + (messageId) => messageRoomIds.get(messageId) ?? null, + 'room-1' + )).toEqual(['msg-b', 'msg-c']); + }); + + it('skips messages with an unknown room so they are not evicted by mistake', () => { + expect(collectMessageIdsForInactiveRoomBlobRelease( + ['msg-a', 'msg-unknown'], + (messageId) => messageRoomIds.get(messageId) ?? null, + 'room-2' + )).toEqual(['msg-a']); + }); + + it('selects every known-room message when no room is active', () => { + expect(collectMessageIdsForInactiveRoomBlobRelease( + ['msg-a', 'msg-b'], + (messageId) => messageRoomIds.get(messageId) ?? null, + null + )).toEqual(['msg-a', 'msg-b']); + }); + }); }); diff --git a/toju-app/src/app/domains/attachment/domain/logic/attachment-blob-eviction.rules.ts b/toju-app/src/app/domains/attachment/domain/logic/attachment-blob-eviction.rules.ts index dddfe50..0d1fb65 100644 --- a/toju-app/src/app/domains/attachment/domain/logic/attachment-blob-eviction.rules.ts +++ b/toju-app/src/app/domains/attachment/domain/logic/attachment-blob-eviction.rules.ts @@ -45,6 +45,31 @@ export function shouldRevokeDisplayBlobForAttachment( return canRevokeAttachmentDisplayBlob(attachment); } +/** + * On room switch, display blobs from every other room are released so their + * memory does not accumulate across the servers a user visits in a session. + * Messages whose room is unknown are left alone rather than evicted blindly. + */ +export function collectMessageIdsForInactiveRoomBlobRelease( + messageIds: Iterable, + resolveMessageRoomId: (messageId: string) => string | null, + activeRoomId: string | null +): string[] { + const selected: string[] = []; + + for (const messageId of messageIds) { + const roomId = resolveMessageRoomId(messageId); + + if (!roomId || roomId === activeRoomId) { + continue; + } + + selected.push(messageId); + } + + return selected; +} + function hasNonEmptyString(value: string | null | undefined): boolean { return typeof value === 'string' && value.trim().length > 0; } diff --git a/toju-app/src/app/domains/attachment/domain/logic/attachment-hydration-visibility.rules.spec.ts b/toju-app/src/app/domains/attachment/domain/logic/attachment-hydration-visibility.rules.spec.ts new file mode 100644 index 0000000..d4b9ba3 --- /dev/null +++ b/toju-app/src/app/domains/attachment/domain/logic/attachment-hydration-visibility.rules.spec.ts @@ -0,0 +1,46 @@ +import { shouldHydrateInlineImageForVisibility, shouldHydratePlayableMediaForVisibility } from './attachment-hydration-visibility.rules'; + +const diskBackedImage = { + available: false, + filename: 'photo.png', + id: 'att-1', + isImage: true, + mime: 'image/png', + savedPath: '/appdata/photo.png' +}; +const diskBackedVideo = { + available: false, + mime: 'video/mp4', + savedPath: '/appdata/clip.mp4' +}; + +describe('attachment hydration visibility rules', () => { + it('hydrates a disk-backed image only when the message is visible', () => { + expect(shouldHydrateInlineImageForVisibility(diskBackedImage, true)).toBe(true); + expect(shouldHydrateInlineImageForVisibility(diskBackedImage, false)).toBe(false); + }); + + it('never hydrates an image that is already displayable', () => { + const displayable = { + ...diskBackedImage, + available: true, + objectUrl: 'blob:http://localhost/abc' + }; + + expect(shouldHydrateInlineImageForVisibility(displayable, true)).toBe(false); + }); + + it('hydrates disk-backed playable media only when the message is visible', () => { + expect(shouldHydratePlayableMediaForVisibility(diskBackedVideo, true)).toBe(true); + expect(shouldHydratePlayableMediaForVisibility(diskBackedVideo, false)).toBe(false); + }); + + it('never hydrates media that already has an object URL', () => { + const hydrated = { + ...diskBackedVideo, + objectUrl: 'blob:http://localhost/def' + }; + + expect(shouldHydratePlayableMediaForVisibility(hydrated, true)).toBe(false); + }); +}); diff --git a/toju-app/src/app/domains/attachment/domain/logic/attachment-hydration-visibility.rules.ts b/toju-app/src/app/domains/attachment/domain/logic/attachment-hydration-visibility.rules.ts new file mode 100644 index 0000000..bfbcd7c --- /dev/null +++ b/toju-app/src/app/domains/attachment/domain/logic/attachment-hydration-visibility.rules.ts @@ -0,0 +1,49 @@ +import type { Attachment } from '../models/attachment.model'; +import { isAttachmentPendingInlineHydration, isInlineDisplayableImage } from './attachment-image.rules'; +import { isAttachmentPendingMediaHydration } from './attachment.logic'; + +type InlineImageCandidate = Pick< + Attachment, + 'available' | 'filePath' | 'filename' | 'isImage' | 'mime' | 'objectUrl' | 'receivedBytes' | 'savedPath' +>; + +type PlayableMediaCandidate = Pick< + Attachment, + 'available' | 'filePath' | 'mime' | 'objectUrl' | 'receivedBytes' | 'savedPath' +>; + +/** + * Display blobs are only hydrated for messages inside (or near) the viewport. + * Hydrating off-screen rows loads full decoded media into the blob store for + * rows the user may never scroll to, which is the main renderer/browser-process + * memory driver in attachment-heavy rooms. + */ +export function shouldHydrateInlineImageForVisibility( + image: InlineImageCandidate, + isMessageVisible: boolean +): boolean { + if (!isMessageVisible) { + return false; + } + + if (isInlineDisplayableImage(image)) { + return false; + } + + return isAttachmentPendingInlineHydration(image); +} + +export function shouldHydratePlayableMediaForVisibility( + media: PlayableMediaCandidate, + isMessageVisible: boolean +): boolean { + if (!isMessageVisible) { + return false; + } + + if (media.objectUrl) { + return false; + } + + return isAttachmentPendingMediaHydration(media); +} diff --git a/toju-app/src/app/domains/chat/domain/rules/message-sync.rules.spec.ts b/toju-app/src/app/domains/chat/domain/rules/message-sync.rules.spec.ts index 4f12f9c..19a3e27 100644 --- a/toju-app/src/app/domains/chat/domain/rules/message-sync.rules.spec.ts +++ b/toju-app/src/app/domains/chat/domain/rules/message-sync.rules.spec.ts @@ -3,9 +3,23 @@ import { it, expect } from 'vitest'; -import { findMissingIds } from './message-sync.rules'; +import { + findMissingIds, + FULL_SYNC_LIMIT, + INVENTORY_LIMIT +} from './message-sync.rules'; describe('message-sync.rules', () => { + it('keeps sync limits bounded so full-room loads cannot spike memory unbounded', () => { + // Sync inventories and full-sync batches load complete message rows into + // memory. An effectively-unlimited ceiling (previously 1,000,000) turns a + // pathological room into a multi-hundred-MB allocation in one sync cycle. + expect(INVENTORY_LIMIT).toBeLessThanOrEqual(20_000); + expect(FULL_SYNC_LIMIT).toBeLessThanOrEqual(20_000); + expect(INVENTORY_LIMIT).toBeGreaterThanOrEqual(5_000); + expect(FULL_SYNC_LIMIT).toBeGreaterThanOrEqual(5_000); + }); + it('requests ids with newer revision or mismatched head hash', () => { const localMap = new Map(); diff --git a/toju-app/src/app/domains/chat/domain/rules/message-sync.rules.ts b/toju-app/src/app/domains/chat/domain/rules/message-sync.rules.ts index 4f88830..54be37b 100644 --- a/toju-app/src/app/domains/chat/domain/rules/message-sync.rules.ts +++ b/toju-app/src/app/domains/chat/domain/rules/message-sync.rules.ts @@ -6,12 +6,14 @@ import { /** Maximum number of messages to include in sync inventories. * - * The inventory protocol now ships every message in the room (id, ts, rc, ac) - * chunked at `CHUNK_SIZE`, so peers converge on the full history regardless - * of how lopsided their message counts are. The constant remains as a safety - * ceiling for pathological rooms. + * The inventory protocol ships messages in the room (id, ts, rc, ac) chunked + * at `CHUNK_SIZE`. Building an inventory loads the full message rows into + * memory, so this ceiling must stay bounded: the previous effectively- + * unlimited value (1,000,000) let a single sync cycle allocate hundreds of MB + * in a pathological room. The most recent `INVENTORY_LIMIT` messages are + * reconciled; anything older stays local-only. */ -export const INVENTORY_LIMIT = 1_000_000; +export const INVENTORY_LIMIT = 20_000; /** Number of messages per chunk for inventory / batch transfers. */ export const CHUNK_SIZE = 200; @@ -25,8 +27,8 @@ export const SYNC_POLL_SLOW_MS = 900_000; /** Sync timeout duration before auto-completing a cycle (5 seconds). */ export const SYNC_TIMEOUT_MS = 5_000; -/** Large limit used for legacy full-sync operations. */ -export const FULL_SYNC_LIMIT = 1_000_000; +/** Ceiling for legacy full-sync and account-sync batches (most recent first). */ +export const FULL_SYNC_LIMIT = 20_000; /** Inventory item representing a message's sync state. */ export interface InventoryItem { diff --git a/toju-app/src/app/domains/chat/feature/chat-messages/components/message-item/chat-message-item.component.ts b/toju-app/src/app/domains/chat/feature/chat-messages/components/message-item/chat-message-item.component.ts index 6c1d6d6..a9bb78a 100644 --- a/toju-app/src/app/domains/chat/feature/chat-messages/components/message-item/chat-message-item.component.ts +++ b/toju-app/src/app/domains/chat/feature/chat-messages/components/message-item/chat-message-item.component.ts @@ -1,4 +1,4 @@ -/* eslint-disable @typescript-eslint/member-ordering, */ + import { CommonModule } from '@angular/common'; import { FormsModule } from '@angular/forms'; import { @@ -50,6 +50,8 @@ import { isInlineDisplayableImage } from '../../../../../attachment/domain/logic/attachment-image.rules'; import { isAttachmentPendingMediaHydration } from '../../../../../attachment/domain/logic/attachment.logic'; +import { shouldHydrateInlineImageForVisibility } from '../../../../../attachment/domain/logic/attachment-hydration-visibility.rules'; +import { shouldHydratePlayableMediaForVisibility } from '../../../../../attachment/domain/logic/attachment-hydration-visibility.rules'; import { ATTACHMENT_BLOB_VISIBILITY_ROOT_MARGIN } from '../../../../../attachment/domain/logic/attachment-blob-eviction.rules'; import { PlatformService, ViewportService } from '../../../../../../core/platform'; import { ElectronBridgeService } from '../../../../../../core/platform/electron/electron-bridge.service'; @@ -275,11 +277,7 @@ export class ChatMessageItemComponent implements AfterViewInit, OnDestroy { const isVisible = this.isMessageVisible(); for (const image of images) { - if (isInlineDisplayableImage(image)) { - continue; - } - - if (!isAttachmentPendingInlineHydration(image)) { + if (!shouldHydrateInlineImageForVisibility(image, isVisible)) { continue; } @@ -293,7 +291,7 @@ export class ChatMessageItemComponent implements AfterViewInit, OnDestroy { } for (const media of mediaAttachments) { - if (media.objectUrl || !isAttachmentPendingMediaHydration(media)) { + if (!shouldHydratePlayableMediaForVisibility(media, isVisible)) { continue; } @@ -595,9 +593,10 @@ export class ChatMessageItemComponent implements AfterViewInit, OnDestroy { this.visibilityObserver?.disconnect(); this.visibilityObserver = null; - if (this.isMessageVisible()) { - this.attachmentsSvc.revokeOffscreenDisplayBlobsForMessage(this.message().id); - } + // Destroyed rows always release their display blobs (pins are respected + // inside the facade); rows destroyed while off-screen previously kept + // their blobs alive for the rest of the session. + this.attachmentsSvc.revokeOffscreenDisplayBlobsForMessage(this.message().id); this.clearLongPressTimer(); this.detachMobileSheet(); diff --git a/toju-app/src/app/features/room/voice-workspace/voice-workspace-stream-tile/voice-workspace-stream-tile.component.ts b/toju-app/src/app/features/room/voice-workspace/voice-workspace-stream-tile/voice-workspace-stream-tile.component.ts index 50673d0..d3f82ff 100644 --- a/toju-app/src/app/features/room/voice-workspace/voice-workspace-stream-tile/voice-workspace-stream-tile.component.ts +++ b/toju-app/src/app/features/room/voice-workspace/voice-workspace-stream-tile/voice-workspace-stream-tile.component.ts @@ -28,6 +28,7 @@ import { ViewportService } from '../../../../core/platform'; import { MobileAppLifecycleService, MobilePictureInPictureService } from '../../../../infrastructure/mobile'; import { VoiceWorkspacePlaybackService } from '../voice-workspace-playback.service'; import { VoiceWorkspaceStreamItem } from '../voice-workspace.models'; +import { releaseVideoElementStream } from './voice-workspace-stream-video.rules'; import { APP_TRANSLATE_IMPORTS, AppI18nService } from '../../../../core/i18n'; @Component({ @@ -169,6 +170,7 @@ export class VoiceWorkspaceStreamTileComponent implements OnDestroy { void document.exitFullscreen().catch(() => {}); } + releaseVideoElementStream(this.videoRef()?.nativeElement); this.unlockOrientation(); } diff --git a/toju-app/src/app/features/room/voice-workspace/voice-workspace-stream-tile/voice-workspace-stream-video.rules.spec.ts b/toju-app/src/app/features/room/voice-workspace/voice-workspace-stream-tile/voice-workspace-stream-video.rules.spec.ts new file mode 100644 index 0000000..ecfa45c --- /dev/null +++ b/toju-app/src/app/features/room/voice-workspace/voice-workspace-stream-tile/voice-workspace-stream-video.rules.spec.ts @@ -0,0 +1,33 @@ +import { releaseVideoElementStream } from './voice-workspace-stream-video.rules'; + +describe('releaseVideoElementStream', () => { + it('pauses the element and detaches the media stream so the decoder can be released', () => { + const pause = vi.fn(); + const video = { + srcObject: { getTracks: () => [] } as unknown as MediaStream, + pause + }; + + releaseVideoElementStream(video); + + expect(pause).toHaveBeenCalled(); + expect(video.srcObject).toBeNull(); + }); + + it('is a no-op for a missing element', () => { + expect(() => releaseVideoElementStream(undefined)).not.toThrow(); + }); + + it('still detaches when pause throws', () => { + const video = { + srcObject: {} as MediaStream, + pause: vi.fn(() => { + throw new Error('detached element'); + }) + }; + + releaseVideoElementStream(video); + + expect(video.srcObject).toBeNull(); + }); +}); diff --git a/toju-app/src/app/features/room/voice-workspace/voice-workspace-stream-tile/voice-workspace-stream-video.rules.ts b/toju-app/src/app/features/room/voice-workspace/voice-workspace-stream-tile/voice-workspace-stream-video.rules.ts new file mode 100644 index 0000000..a72df0b --- /dev/null +++ b/toju-app/src/app/features/room/voice-workspace/voice-workspace-stream-tile/voice-workspace-stream-video.rules.ts @@ -0,0 +1,22 @@ +export interface DetachableVideoElement { + srcObject: MediaStream | MediaSource | Blob | null | undefined; + pause(): void; +} + +/** + * Detach a media stream from a video element so Chromium can release the + * decoder and frame buffers immediately instead of waiting for GC. Destroyed + * tiles that keep `srcObject` bound retain decode state for streams that are + * no longer rendered, which accumulates across camera/screen-share churn. + */ +export function releaseVideoElementStream(video: DetachableVideoElement | null | undefined): void { + if (!video) { + return; + } + + try { + video.pause(); + } catch { /* already detached from the document */ } + + video.srcObject = null; +} diff --git a/toju-app/src/app/infrastructure/realtime/logging/debug-network-metrics.spec.ts b/toju-app/src/app/infrastructure/realtime/logging/debug-network-metrics.spec.ts new file mode 100644 index 0000000..6081892 --- /dev/null +++ b/toju-app/src/app/infrastructure/realtime/logging/debug-network-metrics.spec.ts @@ -0,0 +1,30 @@ +import { + clearDebugNetworkPeerMetrics, + getDebugNetworkMetricSnapshot, + MAX_TRACKED_DEBUG_NETWORK_PEERS, + recordDebugNetworkPing +} from './debug-network-metrics'; + +describe('debug network metrics retention', () => { + it('releases a peer metric entry when the peer is cleared', () => { + recordDebugNetworkPing('peer-cleared', 42); + expect(getDebugNetworkMetricSnapshot('peer-cleared')).not.toBeNull(); + + clearDebugNetworkPeerMetrics('peer-cleared'); + + expect(getDebugNetworkMetricSnapshot('peer-cleared')).toBeNull(); + }); + + it('evicts the oldest peer entries once the tracked-peer cap is exceeded', () => { + recordDebugNetworkPing('peer-oldest', 10); + + for (let index = 0; index < MAX_TRACKED_DEBUG_NETWORK_PEERS; index++) { + recordDebugNetworkPing(`peer-fill-${index}`, index); + } + + expect(getDebugNetworkMetricSnapshot('peer-oldest')).toBeNull(); + expect( + getDebugNetworkMetricSnapshot(`peer-fill-${MAX_TRACKED_DEBUG_NETWORK_PEERS - 1}`) + ).not.toBeNull(); + }); +}); diff --git a/toju-app/src/app/infrastructure/realtime/logging/debug-network-metrics.ts b/toju-app/src/app/infrastructure/realtime/logging/debug-network-metrics.ts index e329c3d..bc24bb3 100644 --- a/toju-app/src/app/infrastructure/realtime/logging/debug-network-metrics.ts +++ b/toju-app/src/app/infrastructure/realtime/logging/debug-network-metrics.ts @@ -3,6 +3,13 @@ type DebugNetworkHandshakeType = 'answer' | 'ice_candidate' | 'offer'; const FILE_RATE_WINDOW_MS = 6_000; +/** + * Peer metric entries are tiny, but the store is keyed by every peer id ever + * seen in the session. Long sessions with peer churn would otherwise grow the + * map for the app lifetime, so the oldest entries are evicted past this cap. + */ +export const MAX_TRACKED_DEBUG_NETWORK_PEERS = 200; + export interface DebugNetworkMetricHandshakeCounts { answersReceived: number; answersSent: number; @@ -236,12 +243,25 @@ class DebugNetworkMetricsStore { }; } + clearPeer(peerId: string): void { + this.metrics.delete(peerId); + } + private ensure(peerId: string): InternalDebugNetworkMetricState { const existing = this.metrics.get(peerId); if (existing) return existing; + while (this.metrics.size >= MAX_TRACKED_DEBUG_NETWORK_PEERS) { + const oldestPeerId = this.metrics.keys().next().value; + + if (oldestPeerId === undefined) + break; + + this.metrics.delete(oldestPeerId); + } + const created: InternalDebugNetworkMetricState = { connectionDrops: 0, downloads: createDownloadRates(), @@ -384,3 +404,8 @@ export function recordDebugNetworkFileChunk( export function getDebugNetworkMetricSnapshot(peerId: string): DebugNetworkMetricSnapshot | null { return debugNetworkMetricsStore.getSnapshot(peerId); } + +/** Drop the metric entry for a peer that fully left (no reconnect pending). */ +export function clearDebugNetworkPeerMetrics(peerId: string): void { + debugNetworkMetricsStore.clearPeer(peerId); +} diff --git a/toju-app/src/app/infrastructure/realtime/peer-connection-manager/peer-connection.manager.ts b/toju-app/src/app/infrastructure/realtime/peer-connection-manager/peer-connection.manager.ts index 7629878..7dcea75 100644 --- a/toju-app/src/app/infrastructure/realtime/peer-connection-manager/peer-connection.manager.ts +++ b/toju-app/src/app/infrastructure/realtime/peer-connection-manager/peer-connection.manager.ts @@ -340,6 +340,12 @@ export class PeerConnectionManager { return false; try { + // Release the failed channel before adopting the replacement; otherwise + // its SCTP resources and handlers stay alive for the connection lifetime. + try { + expectedChannel.close(); + } catch { /* channel may already be closed */ } + const replacement = peerData.connection.createDataChannel(DATA_CHANNEL_LABEL, { ordered: true }); peerData.dataChannel = replacement; diff --git a/toju-app/src/app/infrastructure/realtime/peer-connection-manager/recovery/peer-recovery.spec.ts b/toju-app/src/app/infrastructure/realtime/peer-connection-manager/recovery/peer-recovery.spec.ts index a8bf93c..7be0730 100644 --- a/toju-app/src/app/infrastructure/realtime/peer-connection-manager/recovery/peer-recovery.spec.ts +++ b/toju-app/src/app/infrastructure/realtime/peer-connection-manager/recovery/peer-recovery.spec.ts @@ -5,7 +5,11 @@ import { PeerConnectionManagerContext, RecoveryHandlers } from '../shared'; -import { scheduleDataChannelRecovery } from './peer-recovery'; +import { + closeAllPeers, + removePeer, + scheduleDataChannelRecovery +} from './peer-recovery'; describe('peer recovery', () => { afterEach(() => { @@ -13,6 +17,40 @@ describe('peer recovery', () => { vi.useRealTimers(); }); + it('removes every remote stream map entry for a removed peer, including camera streams', () => { + const channel = createDataChannel(DATA_CHANNEL_STATE_OPEN); + const context = createContext('alice'); + + context.state.activePeerConnections.set('bob', createPeerData(channel, 'connected')); + context.state.remotePeerStreams.set('bob', createMediaStream()); + context.state.remotePeerVoiceStreams.set('bob', createMediaStream()); + context.state.remotePeerScreenShareStreams.set('bob', createMediaStream()); + context.state.remotePeerCameraStreams.set('bob', createMediaStream()); + + removePeer(context, 'bob'); + + expect(context.state.remotePeerStreams.has('bob')).toBe(false); + expect(context.state.remotePeerVoiceStreams.has('bob')).toBe(false); + expect(context.state.remotePeerScreenShareStreams.has('bob')).toBe(false); + expect(context.state.remotePeerCameraStreams.has('bob')).toBe(false); + }); + + it('clears every remote stream map when all peers close, including camera streams', () => { + const context = createContext('alice'); + + context.state.remotePeerStreams.set('bob', createMediaStream()); + context.state.remotePeerVoiceStreams.set('bob', createMediaStream()); + context.state.remotePeerScreenShareStreams.set('bob', createMediaStream()); + context.state.remotePeerCameraStreams.set('bob', createMediaStream()); + + closeAllPeers(context.state); + + expect(context.state.remotePeerStreams.size).toBe(0); + expect(context.state.remotePeerVoiceStreams.size).toBe(0); + expect(context.state.remotePeerScreenShareStreams.size).toBe(0); + expect(context.state.remotePeerCameraStreams.size).toBe(0); + }); + it('recreates a peer immediately when the data channel is already closed', () => { vi.useFakeTimers(); @@ -205,9 +243,14 @@ function createPeerData( }; } +function createMediaStream(): MediaStream { + return { getTracks: () => [] } as unknown as MediaStream; +} + function createDataChannel(readyState: RTCDataChannelState): RTCDataChannel { return { bufferedAmount: 0, + close: vi.fn(), label: 'chat', readyState } as unknown as RTCDataChannel; diff --git a/toju-app/src/app/infrastructure/realtime/peer-connection-manager/recovery/peer-recovery.ts b/toju-app/src/app/infrastructure/realtime/peer-connection-manager/recovery/peer-recovery.ts index cc44e0f..9b36f8a 100644 --- a/toju-app/src/app/infrastructure/realtime/peer-connection-manager/recovery/peer-recovery.ts +++ b/toju-app/src/app/infrastructure/realtime/peer-connection-manager/recovery/peer-recovery.ts @@ -14,6 +14,7 @@ import { RemovePeerOptions } from '../shared'; import { clearAllPingTimers, stopPingInterval } from '../messaging/ping'; +import { clearDebugNetworkPeerMetrics } from '../../logging/debug-network-metrics'; /** * Close and remove a peer connection, data channel, and emit a disconnect event. @@ -33,11 +34,13 @@ export function removePeer( if (!preserveReconnectState) { clearPeerReconnectTimer(state, peerId); state.disconnectedPeerTracker.delete(peerId); + clearDebugNetworkPeerMetrics(peerId); } state.remotePeerStreams.delete(peerId); state.remotePeerVoiceStreams.delete(peerId); state.remotePeerScreenShareStreams.delete(peerId); + state.remotePeerCameraStreams.delete(peerId); if (peerData) { if (peerData.dataChannel) @@ -72,6 +75,7 @@ export function closeAllPeers(state: PeerConnectionManagerState): void { state.remotePeerStreams.clear(); state.remotePeerVoiceStreams.clear(); state.remotePeerScreenShareStreams.clear(); + state.remotePeerCameraStreams.clear(); state.peerNegotiationQueue.clear(); state.peerLatencies.clear(); state.pendingPings.clear(); diff --git a/toju-app/src/app/store/messages/messages.reducer.spec.ts b/toju-app/src/app/store/messages/messages.reducer.spec.ts new file mode 100644 index 0000000..516da2f --- /dev/null +++ b/toju-app/src/app/store/messages/messages.reducer.spec.ts @@ -0,0 +1,104 @@ +import type { Message } from '../../shared-kernel'; +import { MessagesActions } from './messages.actions'; +import { + CACHED_INACTIVE_ROOM_MESSAGE_LIMIT, + initialState, + messagesReducer +} from './messages.reducer'; + +function buildMessage(id: string, roomId: string, timestamp: number): Message { + return { + id, + roomId, + senderId: 'user-1', + senderName: 'User One', + content: `message ${id}`, + timestamp, + reactions: [], + isDeleted: false + }; +} + +function buildMessages(roomId: string, count: number, startTimestamp = 0): Message[] { + return Array.from({ length: count }, (_, index) => + buildMessage(`${roomId}-msg-${index}`, roomId, startTimestamp + index) + ); +} + +describe('messagesReducer inactive-room pruning', () => { + it('prunes an inactive room down to the cached limit when switching to another room', () => { + const overflow = 40; + const seeded = messagesReducer( + initialState, + MessagesActions.loadMessagesSuccess({ + messages: buildMessages('room-a', CACHED_INACTIVE_ROOM_MESSAGE_LIMIT + overflow) + }) + ); + const loadedRoomA = { ...seeded, currentRoomId: 'room-a' }; + const afterSwitch = messagesReducer( + loadedRoomA, + MessagesActions.loadMessages({ roomId: 'room-b' }) + ); + const roomAMessages = Object.values(afterSwitch.entities).filter( + (message) => message?.roomId === 'room-a' + ); + + expect(roomAMessages).toHaveLength(CACHED_INACTIVE_ROOM_MESSAGE_LIMIT); + // The most recent messages survive; the oldest are dropped. + expect(afterSwitch.entities[`room-a-msg-${overflow - 1}`]).toBeUndefined(); + expect(afterSwitch.entities[`room-a-msg-${overflow}`]).toBeDefined(); + }); + + it('keeps every message of the room being loaded', () => { + const seeded = messagesReducer( + initialState, + MessagesActions.loadMessagesSuccess({ + messages: buildMessages('room-a', CACHED_INACTIVE_ROOM_MESSAGE_LIMIT + 50) + }) + ); + const loadedRoomA = { ...seeded, currentRoomId: 'room-b' }; + const afterLoad = messagesReducer( + loadedRoomA, + MessagesActions.loadMessages({ roomId: 'room-a' }) + ); + const roomAMessages = Object.values(afterLoad.entities).filter( + (message) => message?.roomId === 'room-a' + ); + + expect(roomAMessages).toHaveLength(CACHED_INACTIVE_ROOM_MESSAGE_LIMIT + 50); + }); + + it('does not prune anything when reloading the same room', () => { + const seeded = messagesReducer( + initialState, + MessagesActions.loadMessagesSuccess({ + messages: buildMessages('room-a', CACHED_INACTIVE_ROOM_MESSAGE_LIMIT + 50) + }) + ); + const loadedRoomA = { ...seeded, currentRoomId: 'room-a' }; + const afterReload = messagesReducer( + loadedRoomA, + MessagesActions.loadMessages({ roomId: 'room-a' }) + ); + + expect(afterReload.ids).toHaveLength(CACHED_INACTIVE_ROOM_MESSAGE_LIMIT + 50); + }); + + it('prunes each inactive room independently', () => { + let state = messagesReducer( + initialState, + MessagesActions.loadMessagesSuccess({ + messages: [...buildMessages('room-a', CACHED_INACTIVE_ROOM_MESSAGE_LIMIT + 10, 0), ...buildMessages('room-b', 5, 100_000)] + }) + ); + + state = { ...state, currentRoomId: 'room-a' }; + state = messagesReducer(state, MessagesActions.loadMessages({ roomId: 'room-c' })); + + const roomACount = Object.values(state.entities).filter((message) => message?.roomId === 'room-a').length; + const roomBCount = Object.values(state.entities).filter((message) => message?.roomId === 'room-b').length; + + expect(roomACount).toBe(CACHED_INACTIVE_ROOM_MESSAGE_LIMIT); + expect(roomBCount).toBe(5); + }); +}); diff --git a/toju-app/src/app/store/messages/messages.reducer.ts b/toju-app/src/app/store/messages/messages.reducer.ts index f60c52c..7b619b1 100644 --- a/toju-app/src/app/store/messages/messages.reducer.ts +++ b/toju-app/src/app/store/messages/messages.reducer.ts @@ -41,6 +41,41 @@ export const initialState: MessagesState = messagesAdapter.getInitialState({ exhaustedConversations: {} }); +/** + * Maximum messages retained in the store per *inactive* room. Cached rooms + * keep a recent slice so a return visit renders immediately, but sync and + * scroll-up can grow a room's slice to thousands of entries; without pruning, + * the store grows unbounded across every room visited in a session. The + * active room is never pruned - its window is managed by the chat view. + */ +export const CACHED_INACTIVE_ROOM_MESSAGE_LIMIT = 100; + +function pruneInactiveRoomMessages(state: MessagesState, activeRoomId: string): MessagesState { + const retainedPerRoom = new Map(); + const idsToRemove: string[] = []; + const ids = state.ids as string[]; + + // ids are sorted oldest-to-newest; walk backwards so the newest messages of + // each inactive room are the ones retained. + for (let index = ids.length - 1; index >= 0; index--) { + const message = state.entities[ids[index]]; + + if (!message || message.roomId === activeRoomId) { + continue; + } + + const retained = (retainedPerRoom.get(message.roomId) ?? 0) + 1; + + retainedPerRoom.set(message.roomId, retained); + + if (retained > CACHED_INACTIVE_ROOM_MESSAGE_LIMIT) { + idsToRemove.push(ids[index]); + } + } + + return idsToRemove.length > 0 ? messagesAdapter.removeMany(idsToRemove, state) : state; +} + export const messagesReducer = createReducer( initialState, @@ -48,17 +83,23 @@ export const messagesReducer = createReducer( // return visit (or a prefetched room) renders immediately from memory. The // selectors (`selectChannelMessages`, `channelMessages` computed) already // filter by `currentRoom.id`, so leaving stale rooms in the entity adapter - // is safe. Memory cost is ~30 messages per saved room; tracked at - // /memories/repo/electron-server-switch-performance.md. - on(MessagesActions.loadMessages, (state, { roomId }) => ({ - ...state, - loading: true, - error: null, - currentRoomId: roomId, - exhaustedConversations: state.currentRoomId === roomId - ? state.exhaustedConversations - : {} - })), + // is safe. On room switch, inactive rooms are pruned to + // `CACHED_INACTIVE_ROOM_MESSAGE_LIMIT` so the cache stays bounded. + on(MessagesActions.loadMessages, (state, { roomId }) => { + const pruned = state.currentRoomId === roomId + ? state + : pruneInactiveRoomMessages(state, roomId); + + return { + ...pruned, + loading: true, + error: null, + currentRoomId: roomId, + exhaustedConversations: state.currentRoomId === roomId + ? state.exhaustedConversations + : {} + }; + }), on(MessagesActions.loadMessagesSuccess, (state, { messages }) => messagesAdapter.upsertMany(messages, {