Skip to content

Commit 8f05fe3

Browse files
Programcodeclaude
andcommitted
Skip the session read for a request cancelled before it starts
A request whose caller had already left still read the session, and if that read failed nobody handled the error. Check the abort first, and let untilAborted absorb a read it no longer waits for. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 595cbdc commit 8f05fe3

2 files changed

Lines changed: 16 additions & 2 deletions

File tree

‎apps/mobile/src/lib/ai-request.ts‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,11 @@ export function abortError(): Error {
4545

4646
/** Settles with the read, or rejects as soon as the signal aborts. The read itself cannot be cancelled, only no longer waited for. */
4747
export function untilAborted<T>(read: Promise<T>, signal: AbortSignal): Promise<T> {
48-
if (signal.aborted) return Promise.reject(abortError());
48+
if (signal.aborted) {
49+
// Nobody waits for the read any more, so its own failure must not go unhandled.
50+
read.catch(() => {});
51+
return Promise.reject(abortError());
52+
}
4953
return new Promise<T>((resolve, reject) => {
5054
const onAbort = () => reject(abortError());
5155
signal.addEventListener('abort', onAbort, { once: true });
@@ -107,6 +111,7 @@ export async function postFunction<T>(
107111
signal?.addEventListener('abort', abortForCaller);
108112
if (signal?.aborted) controller.abort();
109113
try {
114+
if (controller.signal.aborted) throw abortError();
110115
const token = await untilAborted(accessToken(), controller.signal);
111116
const json = JSON.stringify(body);
112117
if (utf8Length(json) > MAX_REQUEST_BODY_BYTES) throw new AIError('tooLarge', 'Request is too large');

‎apps/mobile/src/lib/crossfire.test.ts‎

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,15 @@ import { afterEach, describe, expect, it, vi } from 'vitest';
33
const fetchMock = vi.hoisted(() => vi.fn());
44

55
// The session read never settles, as when a token refresh stalls.
6-
vi.mock('./supabase', () => ({ supabase: { auth: { getSession: () => new Promise<never>(() => {}) } } }));
6+
const getSession = vi.hoisted(() => vi.fn(() => new Promise<never>(() => {})));
7+
vi.mock('./supabase', () => ({ supabase: { auth: { getSession } } }));
78
vi.mock('expo/fetch', () => ({ fetch: fetchMock }));
89

910
import { AIError, postFunction, untilAborted } from './ai-request';
1011
import { withTurnDeadline } from './crossfire';
1112

1213
afterEach(() => {
14+
vi.clearAllMocks();
1315
vi.useRealTimers();
1416
});
1517

@@ -45,4 +47,11 @@ describe('postFunction', () => {
4547
expect((error as AIError).kind).toBe('timeout');
4648
expect(fetchMock).not.toHaveBeenCalled();
4749
});
50+
51+
it('never reads the session for a request cancelled before it starts', async () => {
52+
const leaving = new AbortController();
53+
leaving.abort();
54+
await expect(postFunction('ai', {}, leaving.signal, async () => 'answer')).rejects.toMatchObject({ name: 'AbortError' });
55+
expect(getSession).not.toHaveBeenCalled();
56+
});
4857
});

0 commit comments

Comments
 (0)