diff --git a/apps/mobile/docs/chat-limits.md b/apps/mobile/docs/chat-limits.md new file mode 100644 index 0000000..d738101 --- /dev/null +++ b/apps/mobile/docs/chat-limits.md @@ -0,0 +1,21 @@ +# Chat request and response limits + +The mobile chat adapter accepts at most 65536 UTF-8 bytes of serialized request +JSON and at most 65536 bytes of response JSON. Limits include field names, +history, escaping and multibyte characters, not just the latest prompt. +Exactly the limit is accepted; larger messages fail with an explicit size error. +History is never silently truncated. Failed text remains visible; clear history +requires confirmation. Oversized requests do not offer an identical retry. + +Responses with an excessive declared Content-Length are rejected before reading. +Stream-capable transports also count actual bytes and cancel oversized bodies, +including those without or with misleading length headers. Buffered React Native +fetch is checked after text decoding, before JSON parsing. It may already have +downloaded the body into native memory: this is not a native download memory cap. +The first cancel, timeout or detected oversize event remains the reported cause. + +Tests cover Unicode size boundaries, offline request rejection, body cancellation, +late fetch responses, terminal-event ordering, retained UI state, and real +loopback HTTP (including stalled headers/body and unterminated oversized data). +Local Android/iOS bundle export checks compilation, not device runtime or a real +provider. Device transport behavior remains a release verification item. diff --git a/apps/mobile/src/lib/ai.ts b/apps/mobile/src/lib/ai.ts index 3be936d..183ac22 100644 --- a/apps/mobile/src/lib/ai.ts +++ b/apps/mobile/src/lib/ai.ts @@ -15,7 +15,11 @@ export interface ChatMessage { } export const CHAT_TIMEOUT_MS = 30000; +export const MAX_CHAT_REQUEST_BYTES = 65536; +export const MAX_CHAT_RESPONSE_BYTES = 65536; export type ChatFailure = + | 'request_too_large' + | 'response_too_large' | 'timeout' | 'cancelled' | 'network' @@ -46,14 +50,23 @@ function offlineDelay(signal: AbortSignal): Promise { /** One attempt. Cancellation cannot guarantee that the remote service stopped. */ export async function sendChat(history: readonly ChatMessage[], options: {signal?: AbortSignal} = {}): Promise { if (options.signal?.aborted) throw new ChatError('cancelled'); + const body = JSON.stringify({ messages: history.map(({ role, text }) => ({ role, text })) }); + if (new TextEncoder().encode(body).byteLength > MAX_CHAT_REQUEST_BYTES) + throw new ChatError('request_too_large'); const controller = new AbortController(); - let timedOut = false; - const timer = setTimeout(() => { timedOut = true; controller.abort(); }, CHAT_TIMEOUT_MS); - const forwardAbort = () => controller.abort(); + // The first terminating event wins; cancelling transport must not hide a size error. + let failure: ChatFailure = 'cancelled'; + const stop = (kind: ChatFailure) => { + if (controller.signal.aborted) return; + failure = kind; + controller.abort(); + }; + const timer = setTimeout(() => stop('timeout'), CHAT_TIMEOUT_MS); + const forwardAbort = () => stop('cancelled'); options.signal?.addEventListener('abort', forwardAbort, {once:true}); let rejectAbort: () => void = () => {}; const aborted = new Promise((_, reject) => { - rejectAbort = () => reject(new ChatError(timedOut ? 'timeout' : 'cancelled')); + rejectAbort = () => reject(new ChatError(failure)); controller.signal.addEventListener('abort', rejectAbort, {once:true}); }); const work = async () => { @@ -67,17 +80,58 @@ export async function sendChat(history: readonly ChatMessage[], options: {signal method: 'POST', headers: { 'content-type': 'application/json' }, signal: controller.signal, - body: JSON.stringify({ - messages: history.map(({ role, text }) => ({ role, text })), - }), + body, }); + if (controller.signal.aborted) { + void response.body?.cancel().catch(() => {}); + throw new ChatError(failure); + } if (!response.ok) { void response.body?.cancel().catch(() => {}); throw new ChatError('http'); } + const tooLarge = (): never => { + stop('response_too_large'); + throw new ChatError(failure); + }; + if (Number(response.headers.get('content-length')) > MAX_CHAT_RESPONSE_BYTES) { + void response.body?.cancel().catch(() => {}); + tooLarge(); + } + let text: string; + if (response.body?.getReader) { + const reader = response.body.getReader(); + const cancel = () => { void reader.cancel().catch(() => {}); }; + controller.signal.addEventListener('abort', cancel, { once: true }); + try { + const chunks: Uint8Array[] = []; + let bytes = 0; + while (true) { + const chunk = await reader.read(); + if (controller.signal.aborted) throw new ChatError(failure); + if (chunk.done) break; + bytes += chunk.value.byteLength; + if (bytes > MAX_CHAT_RESPONSE_BYTES) tooLarge(); + chunks.push(chunk.value); + } + const data = new Uint8Array(bytes); + let offset = 0; + for (const chunk of chunks) { data.set(chunk, offset); offset += chunk.byteLength; } + text = new TextDecoder().decode(data); + } finally { + controller.signal.removeEventListener('abort', cancel); + reader.releaseLock(); + } + } else { + // RN's buffered fetch may already have downloaded the whole body natively. + // This limits JSON processing/retention, not peak native download memory. + text = await response.text(); + if (controller.signal.aborted) throw new ChatError(failure); + if (new TextEncoder().encode(text).byteLength > MAX_CHAT_RESPONSE_BYTES) tooLarge(); + } let data: unknown; try { - data = await response.json(); + data = JSON.parse(text); } catch { throw new ChatError('invalid'); } @@ -95,7 +149,7 @@ export async function sendChat(history: readonly ChatMessage[], options: {signal return await Promise.race([work(), aborted]); } catch (error) { if (controller.signal.aborted) - throw new ChatError(timedOut ? 'timeout' : 'cancelled'); + throw new ChatError(failure); throw error instanceof ChatError ? error : new ChatError('network'); } finally { clearTimeout(timer); diff --git a/apps/mobile/src/screens/ChatScreen.tsx b/apps/mobile/src/screens/ChatScreen.tsx index 43ae82b..749a380 100644 --- a/apps/mobile/src/screens/ChatScreen.tsx +++ b/apps/mobile/src/screens/ChatScreen.tsx @@ -29,6 +29,8 @@ type Turn = | { message: ChatMessage; state: 'pending' } | { message: ChatMessage; state: 'failed'; reason: ChatFailure }; const FAILURE_COPY: Record = { + request_too_large: 'This message and conversation are too large to send. Your messages are still here. Start a new conversation with Clear, or send a shorter message.', + response_too_large: 'The assistant reply was too large. Your message is still here. Try a shorter request.', timeout: `No reply within ${CHAT_TIMEOUT_MS / 1000} seconds. The request may still have reached the service.`, cancelled: 'Stopped. The request may still have reached the service.', network: "Couldn't reach the assistant.", @@ -188,7 +190,7 @@ export function ChatScreen() { ? 'Waiting for reply...' : FAILURE_COPY[turn.reason]} - {busy ? 'Stop' : 'Retry'} - + } )} diff --git a/apps/mobile/test/ai-http.test.ts b/apps/mobile/test/ai-http.test.ts index 9f89df6..1808f88 100644 --- a/apps/mobile/test/ai-http.test.ts +++ b/apps/mobile/test/ai-http.test.ts @@ -58,6 +58,17 @@ async function withServer( } describe('chat using real loopback HTTP and AbortController', () => { + it('stops a chunked oversized response without awaiting its end or retrying', async () => { + let closed!: Promise; + await withServer(response => { + closed = once(response, 'close'); + response.write('{"text":"' + 'x'.repeat(65536)); + }, async requests => { + await expect(sendChat(history)).rejects.toMatchObject({ kind: 'response_too_large' }); + await expectClosed(closed); + expect(requests).toHaveLength(1); + }); + }); it('sends one POST with public conversation fields and decodes UTF-8', async () => { await withServer( (response) => { diff --git a/apps/mobile/test/ai-size.test.ts b/apps/mobile/test/ai-size.test.ts new file mode 100644 index 0000000..45a55bf --- /dev/null +++ b/apps/mobile/test/ai-size.test.ts @@ -0,0 +1,101 @@ +import { afterEach, expect, it, vi } from 'vitest'; +import { CHAT_TIMEOUT_MS, sendChat } from '../src/lib/ai'; + +const LIMIT = 65536; +const history = [{ id: '1', role: 'user' as const, text: 'hello' }]; +afterEach(() => { vi.unstubAllEnvs(); vi.unstubAllGlobals(); vi.useRealTimers(); }); +function setup(response: Response) { + vi.stubEnv('EXPO_PUBLIC_AI_ENDPOINT', 'https://ai.example'); + const fetcher = vi.fn(async () => response); + vi.stubGlobal('fetch', fetcher); + return fetcher; +} + +it.each([-1, 0, 1])('request UTF-8 boundary %+d', async delta => { + const fetcher = setup(new Response('{"text":"ok"}')); + const overhead = JSON.stringify({ messages: [{ role: 'user', text: '' }] }).length; + const text = '\u0111' + 'x'.repeat(LIMIT + delta - overhead - 2); + const input = [{ ...history[0]!, text }]; + if (delta > 0) { + await expect(sendChat(input)).rejects.toMatchObject({ kind: 'request_too_large' }); + expect(fetcher).not.toHaveBeenCalled(); + } else expect(await sendChat(input)).toBe('ok'); + expect(input[0]!.text).toBe(text); +}); +it.each([-1, 0, 1])('stream response UTF-8 boundary %+d', async delta => { + const text = '\u0111' + 'x'.repeat(LIMIT + delta - 13); + setup(new Response(JSON.stringify({ text }))); + if (delta > 0) await expect(sendChat(history)).rejects.toMatchObject({ kind: 'response_too_large' }); + else expect(await sendChat(history)).toBe(text); +}); +it.each([-1, 0, 1])('RN-like buffered response boundary %+d', async delta => { + const text = 'x'.repeat(LIMIT + delta - 11); + setup({ ok: true, headers: new Headers(), body: null, text: async () => JSON.stringify({ text }) } as Response); + if (delta > 0) await expect(sendChat(history)).rejects.toMatchObject({ kind: 'response_too_large' }); + else expect(await sendChat(history)).toBe(text); +}); +it.each([undefined, '1', '999999'])('cancels oversize streams with content-length %s', async length => { + const cancel = vi.fn(); + const stream = new ReadableStream({ start(c) { c.enqueue(new Uint8Array(LIMIT + 1)); }, cancel }); + const response = new Response(stream, { headers: length ? { 'content-length': length } : {} }); + setup(response); + await expect(sendChat(history)).rejects.toMatchObject({ kind: 'response_too_large' }); + expect(cancel).toHaveBeenCalledOnce(); +}); +it('cancels a late response from a transport which ignored abort', async () => { + vi.stubEnv('EXPO_PUBLIC_AI_ENDPOINT', 'https://ai.example'); + let resolve!: (r: Response) => void; + vi.stubGlobal('fetch', vi.fn(() => new Promise(r => { resolve = r; }))); + const controller = new AbortController(); + const result = expect(sendChat(history, { signal: controller.signal })).rejects.toMatchObject({ kind: 'cancelled' }); + controller.abort(); + await result; + const cancel = vi.fn(); + resolve(new Response(new ReadableStream({ cancel }))); + await vi.waitFor(() => expect(cancel).toHaveBeenCalledOnce()); +}); + +it('rejects oversized offline history before scheduling a reply', async () => { + vi.useFakeTimers(); + vi.stubEnv('EXPO_PUBLIC_AI_ENDPOINT', ''); + const fetcher = vi.fn(); + vi.stubGlobal('fetch', fetcher); + await expect(sendChat([{ ...history[0]!, text: 'x'.repeat(LIMIT) }])) + .rejects.toMatchObject({ kind: 'request_too_large' }); + expect(fetcher).not.toHaveBeenCalled(); + expect(vi.getTimerCount()).toBe(0); +}); + +it('rejects an oversized declared length without reading even a small body', async () => { + const cancel = vi.fn(); + setup(new Response(new ReadableStream({ + start(c) { c.enqueue(new TextEncoder().encode('{"text":"ok"}')); }, + cancel, + }), { headers: { 'content-length': String(LIMIT + 1) } })); + await expect(sendChat(history)).rejects.toMatchObject({ kind: 'response_too_large' }); + expect(cancel).toHaveBeenCalledOnce(); +}); + +it.each(['size-first', 'cancel-first', 'timeout-first'])('preserves the first terminal event: %s', async order => { + vi.useFakeTimers(); + const external = new AbortController(); + let body!: ReadableStreamDefaultController; + let cancelReason: unknown; + const cancel = vi.fn(() => { + // An external cancellation during size-triggered transport cleanup must not replace it. + cancelReason = 'cancelled'; + external.abort(); + }); + setup(new Response(new ReadableStream({ start(c) { body = c; }, cancel }))); + const result = sendChat(history, { signal: external.signal }); + const expected = order === 'size-first' ? 'response_too_large' : order === 'timeout-first' ? 'timeout' : 'cancelled'; + const assertion = expect(result).rejects.toMatchObject({ kind: expected }); + await Promise.resolve(); + if (order === 'size-first') body.enqueue(new Uint8Array(LIMIT + 1)); + else if (order === 'cancel-first') external.abort(); + else await vi.advanceTimersByTimeAsync(CHAT_TIMEOUT_MS); + await assertion; + expect(cancel).toHaveBeenCalledOnce(); + expect(cancelReason).toBe('cancelled'); + expect(vi.getTimerCount()).toBe(0); +}); diff --git a/apps/mobile/test/ai.test.ts b/apps/mobile/test/ai.test.ts index bb42aa9..3fd3243 100644 --- a/apps/mobile/test/ai.test.ts +++ b/apps/mobile/test/ai.test.ts @@ -32,7 +32,8 @@ describe('one bounded chat attempt', () => { ? new Promise(() => {}) : Promise.resolve({ ok: true, - json: () => new Promise(() => {}), + headers: new Headers(), + text: () => new Promise(() => {}), } as Response), ); vi.stubGlobal('fetch', fetcher); diff --git a/apps/mobile/test/chat-recovery.test.tsx b/apps/mobile/test/chat-recovery.test.tsx index 016490e..c840948 100644 --- a/apps/mobile/test/chat-recovery.test.tsx +++ b/apps/mobile/test/chat-recovery.test.tsx @@ -45,6 +45,22 @@ async function send(root: ReactTestInstance, text = 'hello') { await fire(button(root, 'Send message'), 'onPress'); } describe('chat recovery without hidden replay', () => { + it('retains an oversized failed turn and offers clear instead of an identical retry', async () => { + const { root } = await renderScreen(); + await send(root, 'keep this prompt'); + await fire(input(root), 'onChangeText', 'shorter draft'); + await actAsync(() => calls[0]!.reject(new ChatError('request_too_large'))); + expect(textContents(root)).toContain('keep this prompt'); + expect(input(root).props.value).toBe('shorter draft'); + expect(button(root, 'Retry message')).toBeUndefined(); + await fire(button(root, 'Clear conversation'), 'onPress'); + await fire(button(root, 'Cancel clear conversation'), 'onPress'); + expect(textContents(root)).toContain('keep this prompt'); + await fire(button(root, 'Clear conversation'), 'onPress'); + await fire(button(root, 'Confirm clear conversation'), 'onPress'); + expect(input(root).props.value).toBe('shorter draft'); + expect(calls).toHaveLength(1); + }); it('shows a timeout as a failed turn without retrying automatically', async () => { const { root } = await renderScreen(); await send(root);