diff --git a/mobile/src/session/mobile-markdown-document-lifetime.test.ts b/mobile/src/session/mobile-markdown-document-lifetime.test.ts new file mode 100644 index 00000000000..749a53eeadc --- /dev/null +++ b/mobile/src/session/mobile-markdown-document-lifetime.test.ts @@ -0,0 +1,203 @@ +import { randomBytes } from 'node:crypto' +import { describe, expect, it, vi } from 'vitest' +import { hookMount, performHookAction } from '../test-support/rpc-recording/hook-mount' +import { mountFixture } from '../test-support/rpc-recording/recorder-fixture-shape' +import type { RpcResponse } from '../transport/types' +import type { MarkdownDocState, MobileSessionTab } from './mobile-session-route-types' +import { useMobileSessionDocumentReaders } from './use-mobile-session-document-readers' +import { useMobileSessionCloseActions } from './use-mobile-session-close-actions' + +type ReaderScope = Parameters[0] +type CloseScope = Parameters[0] + +function success(content: string): RpcResponse { + return { + id: 'read', + ok: true, + result: { content, version: 'version', isDirty: false, editable: false }, + _meta: { runtimeId: 'host' } + } +} + +function mountDocuments(readReply: () => Promise) { + let docs = new Map() + const tabs: { current: MobileSessionTab[] } = { current: [] } + const setDocs: ReaderScope['setMarkdownDocs'] = (update) => { + docs = typeof update === 'function' ? update(docs) : update + } + const closeReply = vi.fn(async (): Promise => success('')) + const client = { + sendRequest: vi.fn(async (method: string): Promise => + method === 'markdown.readTab' || method === 'files.read' ? readReply() : closeReply() + ) + } + let readers: ReturnType | undefined + let close: ReturnType | undefined + const hook = hookMount(() => { + readers = useMobileSessionDocumentReaders( + mountFixture({ + worktreeId: 'folder-workspace', + client, + setMarkdownDocs: setDocs, + setFileDocs: () => {} + }) + ) + close = useMobileSessionCloseActions( + mountFixture({ + worktreeId: 'folder-workspace', + client, + sessionTabsRef: tabs, + setSessionTabs: () => {}, + setFileDocs: () => {}, + setMarkdownDocs: setDocs, + reconcileBufferedDraftsRef: { current: () => {} }, + closedTabTombstonesRef: { current: new Map() }, + activeSessionTabIdRef: { current: null }, + selectedSessionTabIdRef: { current: null }, + activeSessionTabTypeRef: { current: null }, + activeHandleRef: { current: null }, + setActiveSessionTabId: () => {}, + setActiveHandle: () => {} + }) + ) + }) + hook.mount() + const tab = (id: string) => + mountFixture>({ + type: 'markdown', + id, + relativePath: id + '.md', + isDirty: false + }) + return { + get docs() { + return docs + }, + closeReply, + unmount: hook.unmount, + read(id: string) { + const target = tab(id) + tabs.current = [target] + return performHookAction(() => readers?.readMarkdownTab(target)) + }, + close(id: string) { + return performHookAction(() => close?.handleCloseSessionTab(tab(id))) + }, + dirty(id: string) { + const doc = docs.get(id) + if (doc?.status !== 'ready') { + throw new Error('Missing ready document') + } + docs.set(id, { ...doc, localContent: 'phone draft', isDirty: true }) + } + } +} + +function delayedReply() { + let resolve: (reply: RpcResponse) => void = () => {} + const promise = new Promise((done) => { + resolve = done + }) + return { promise, resolve } +} + +describe('mobile Markdown document lifetime', () => { + it('releases 40 MiB of clean content after twenty reads and successful closes', async () => { + const h = mountDocuments(async () => success(randomBytes(1536 * 1024).toString('base64'))) + try { + for (let index = 0; index < 20; index += 1) { + await h.read('tab-' + index) + const doc = h.docs.get('tab-' + index) + expect(doc?.status === 'ready' && Buffer.byteLength(doc.content)).toBe(2 * 1024 * 1024) + await h.close('tab-' + index) + } + expect(h.docs.size).toBe(0) + } finally { + h.unmount() + } + }) + + it.each(['success', 'failure', 'disk fallback'])( + 'keeps a closed read released after a late %s', + async (kind) => { + const delayed = delayedReply() + const readReply = vi + .fn() + .mockReturnValueOnce(delayed.promise) + .mockResolvedValue(success('disk')) + const h = mountDocuments(readReply) + try { + const reading = h.read('closed') + await h.close('closed') + delayed.resolve( + kind === 'success' + ? success('late') + : { + id: 'read', + ok: false, + error: { + code: kind === 'disk fallback' ? 'renderer_unavailable' : 'tab_not_found', + message: 'Unavailable' + }, + _meta: { runtimeId: 'host' } + } + ) + await reading + expect(h.docs.size).toBe(0) + if (kind === 'disk fallback') { + expect(readReply).toHaveBeenCalledTimes(2) + } + } finally { + h.unmount() + } + } + ) + + it('retains the document when the host refuses close', async () => { + const h = mountDocuments(async () => success('kept')) + try { + await h.read('kept') + const doc = h.docs.get('kept') + h.closeReply.mockResolvedValue({ + id: 'close', + ok: false, + error: { code: 'refused', message: 'Refused' }, + _meta: { runtimeId: 'host' } + }) + await h.close('kept') + expect(h.docs.get('kept')).toBe(doc) + } finally { + h.unmount() + } + }) + + it('preserves unsaved phone drafts after close', async () => { + const h = mountDocuments(async () => success('original')) + try { + await h.read('draft') + h.dirty('draft') + const draft = h.docs.get('draft') + await h.close('draft') + expect(h.docs.get('draft')).toBe(draft) + } finally { + h.unmount() + } + }) + + it('keeps a newer read when an older request finishes later', async () => { + const old = delayedReply() + const h = mountDocuments( + vi.fn().mockReturnValueOnce(old.promise).mockResolvedValue(success('new')) + ) + try { + const first = h.read('same') + await h.read('same') + const current = h.docs.get('same') + old.resolve(success('old')) + await first + expect(h.docs.get('same')).toBe(current) + } finally { + h.unmount() + } + }) +}) diff --git a/mobile/src/session/use-mobile-session-close-actions.ts b/mobile/src/session/use-mobile-session-close-actions.ts index 22fdcc58f82..442d514cc77 100644 --- a/mobile/src/session/use-mobile-session-close-actions.ts +++ b/mobile/src/session/use-mobile-session-close-actions.ts @@ -16,6 +16,7 @@ export function useMobileSessionCloseActions(scope: MobileSessionContentCreateAc terminalsRef, setSessionTabs, setFileDocs, + setMarkdownDocs, sessionTabsRef, reconcileBufferedDraftsRef, closedTabTombstonesRef, @@ -119,6 +120,17 @@ export function useMobileSessionCloseActions(scope: MobileSessionContentCreateAc }) ) if (response.accepted) { + if (tab.type === 'markdown') { + setMarkdownDocs((prev) => { + const doc = prev.get(tab.id) + if (!doc || (doc.status === 'ready' && doc.isDirty)) { + return prev + } + const next = new Map(prev) + next.delete(tab.id) + return next + }) + } if (tab.type === 'file') { setFileDocs((prev) => { if (!prev.has(tab.id)) { diff --git a/mobile/src/session/use-mobile-session-document-readers.ts b/mobile/src/session/use-mobile-session-document-readers.ts index a1e7d64a304..be9f4d74f6d 100644 --- a/mobile/src/session/use-mobile-session-document-readers.ts +++ b/mobile/src/session/use-mobile-session-document-readers.ts @@ -17,7 +17,8 @@ export function useMobileSessionDocumentReaders(scope: MobileSessionTabApplicati if (!client) { return } - setMarkdownDocs((prev) => new Map(prev).set(tab.id, { status: 'loading' })) + const loading = { status: 'loading' } as const + setMarkdownDocs((prev) => new Map(prev).set(tab.id, loading)) try { const response = await markdownTabRead.request(client, { worktree: `id:${worktreeId}`, @@ -26,19 +27,21 @@ export function useMobileSessionDocumentReaders(scope: MobileSessionTabApplicati if (response.ok) { const result = markdownTabRead.interpret(response) setMarkdownDocs((prev) => - new Map(prev).set(tab.id, { - status: 'ready', - content: result.content, - localContent: result.content, - baseVersion: result.version, - isDirty: false, - editable: result.editable === true, - stale: result.isDirty, - readOnlyReason: result.readOnlyReason, - ...(result.truncated === true - ? { truncated: true, byteLength: result.byteLength } - : {}) - }) + prev.get(tab.id) === loading + ? new Map(prev).set(tab.id, { + status: 'ready', + content: result.content, + localContent: result.content, + baseVersion: result.version, + isDirty: false, + editable: result.editable === true, + stale: result.isDirty, + readOnlyReason: result.readOnlyReason, + ...(result.truncated === true + ? { truncated: true, byteLength: result.byteLength } + : {}) + }) + : prev ) return } @@ -57,22 +60,26 @@ export function useMobileSessionDocumentReaders(scope: MobileSessionTabApplicati } const fileResult = fallback.value setMarkdownDocs((prev) => - new Map(prev).set( - tab.id, - buildMarkdownDiskFallbackDoc({ - content: fileResult.content, - truncated: fileResult.truncated, - byteLength: fileResult.byteLength, - tabIsDirty: tab.isDirty - }) - ) + prev.get(tab.id) === loading + ? new Map(prev).set( + tab.id, + buildMarkdownDiskFallbackDoc({ + content: fileResult.content, + truncated: fileResult.truncated, + byteLength: fileResult.byteLength, + tabIsDirty: tab.isDirty + }) + ) + : prev ) } catch (err) { setMarkdownDocs((prev) => - new Map(prev).set(tab.id, { - status: 'error', - message: documentReadErrorMessage(err, "Couldn't load markdown") - }) + prev.get(tab.id) === loading + ? new Map(prev).set(tab.id, { + status: 'error', + message: documentReadErrorMessage(err, "Couldn't load markdown") + }) + : prev ) } },