Fix ack/message race condition where out of sequence acks and messages would cause dropped acks

This commit is contained in:
Jack Kingsman
2026-02-14 21:19:25 -08:00
parent 9afaee24a0
commit 6e3cf28577
2 changed files with 237 additions and 19 deletions
@@ -0,0 +1,124 @@
import { act, renderHook, waitFor } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import * as messageCache from '../messageCache';
import { useConversationMessages } from '../hooks/useConversationMessages';
import type { Conversation, Message } from '../types';
const mockGetMessages = vi.fn<(...args: unknown[]) => Promise<Message[]>>();
vi.mock('../api', () => ({
api: {
getMessages: (...args: unknown[]) => mockGetMessages(...args),
},
isAbortError: (err: unknown) => err instanceof DOMException && err.name === 'AbortError',
}));
function createConversation(): Conversation {
return {
type: 'contact',
id: 'abc123',
name: 'Test Contact',
};
}
function createMessage(overrides: Partial<Message> = {}): Message {
return {
id: 42,
type: 'PRIV',
conversation_key: 'abc123',
text: 'hello',
sender_timestamp: 1700000000,
received_at: 1700000001,
paths: null,
txt_type: 0,
signature: null,
outgoing: true,
acked: 0,
...overrides,
};
}
function createDeferred<T>() {
let resolve: (value: T | PromiseLike<T>) => void = () => {};
const promise = new Promise<T>((res) => {
resolve = res;
});
return { promise, resolve };
}
describe('useConversationMessages ACK ordering', () => {
beforeEach(() => {
mockGetMessages.mockReset();
messageCache.clear();
});
it('applies buffered ACK when message is added after ACK event', async () => {
mockGetMessages.mockResolvedValueOnce([]);
const { result } = renderHook(() => useConversationMessages(createConversation()));
await waitFor(() => expect(mockGetMessages).toHaveBeenCalledTimes(1));
await waitFor(() => expect(result.current.messagesLoading).toBe(false));
const paths = [{ path: 'A1B2', received_at: 1700000010 }];
act(() => {
result.current.updateMessageAck(42, 2, paths);
});
act(() => {
const added = result.current.addMessageIfNew(createMessage({ id: 42, acked: 0, paths: null }));
expect(added).toBe(true);
});
expect(result.current.messages).toHaveLength(1);
expect(result.current.messages[0].acked).toBe(2);
expect(result.current.messages[0].paths).toEqual(paths);
});
it('applies buffered ACK to message returned by in-flight fetch', async () => {
const deferred = createDeferred<Message[]>();
mockGetMessages.mockReturnValueOnce(deferred.promise);
const { result } = renderHook(() => useConversationMessages(createConversation()));
await waitFor(() => expect(mockGetMessages).toHaveBeenCalledTimes(1));
const paths = [{ path: 'C3D4', received_at: 1700000011 }];
act(() => {
result.current.updateMessageAck(42, 1, paths);
});
deferred.resolve([createMessage({ id: 42, acked: 0, paths: null })]);
await waitFor(() => expect(result.current.messages).toHaveLength(1));
expect(result.current.messages[0].acked).toBe(1);
expect(result.current.messages[0].paths).toEqual(paths);
});
it('keeps highest ACK state when out-of-order ACK updates arrive', async () => {
mockGetMessages.mockResolvedValueOnce([]);
const { result } = renderHook(() => useConversationMessages(createConversation()));
await waitFor(() => expect(mockGetMessages).toHaveBeenCalledTimes(1));
await waitFor(() => expect(result.current.messagesLoading).toBe(false));
act(() => {
result.current.addMessageIfNew(createMessage({ id: 42, acked: 0, paths: null }));
});
const highAckPaths = [
{ path: 'A1B2', received_at: 1700000010 },
{ path: 'A1C3', received_at: 1700000011 },
];
const staleAckPaths = [{ path: 'A1B2', received_at: 1700000010 }];
act(() => {
result.current.updateMessageAck(42, 3, highAckPaths);
result.current.updateMessageAck(42, 2, staleAckPaths);
});
expect(result.current.messages[0].acked).toBe(3);
expect(result.current.messages[0].paths).toEqual(highAckPaths);
});
});