fix: stop the composer re-sending a queued message already in flight

A queued message is removed from the queue only after its send resolves,
so between dispatch and resolution it stays visible to every reader — and
a composer submit merges the whole queue into its own send. Over a relay
that window is seconds, long enough to deliver the same message twice.

The queue now tracks which entries are awaiting the server. Dispatchers
skip them, clearQueue retains them so the pending send can still remove
or restore its own entry, and the flag is not persisted because a restart
has no in-flight sends.
This commit is contained in:
Bohdan Triapitsyn
2026-08-03 23:14:14 +03:00
parent fe38f7a56b
commit 237cae16b3
5 changed files with 126 additions and 5 deletions
@@ -8,7 +8,7 @@ import {
} from "./messageQueueStore"
beforeEach(() => {
useMessageQueueStore.setState({ queuedMessages: {}, quarantinedLegacyMessages: {} })
useMessageQueueStore.setState({ queuedMessages: {}, quarantinedLegacyMessages: {}, sendingIds: {} })
})
describe("message queue runtime ownership", () => {
@@ -49,3 +49,48 @@ describe("message queue runtime ownership", () => {
expect(queue[0]?.content).toBe("message-5")
})
})
describe("in-flight queued sends", () => {
test("hides a dispatched message from the sendable queue but keeps it visible", () => {
const target = createMessageQueueTarget("session-1", "/repo", "runtime-a")!
const store = useMessageQueueStore.getState()
store.addToQueue(target, { content: "first" })
store.addToQueue(target, { content: "second" })
const [first] = useMessageQueueStore.getState().getQueueForTarget(target)
useMessageQueueStore.getState().markSending(target, first.id)
expect(useMessageQueueStore.getState().getQueueForTarget(target)).toHaveLength(2)
const sendable = useMessageQueueStore.getState().getSendableQueue(target)
expect(sendable).toHaveLength(1)
expect(sendable[0]?.content).toBe("second")
useMessageQueueStore.getState().clearSending(target, first.id)
expect(useMessageQueueStore.getState().getSendableQueue(target)).toHaveLength(2)
expect(useMessageQueueStore.getState().sendingIds).toEqual({})
})
test("clearQueue retains a message whose send is still awaiting the server", () => {
const target = createMessageQueueTarget("session-1", "/repo", "runtime-a")!
const store = useMessageQueueStore.getState()
store.addToQueue(target, { content: "in flight" })
store.addToQueue(target, { content: "merged by composer" })
const [inFlight] = useMessageQueueStore.getState().getQueueForTarget(target)
useMessageQueueStore.getState().markSending(target, inFlight.id)
useMessageQueueStore.getState().clearQueue(target)
const remaining = useMessageQueueStore.getState().getQueueForTarget(target)
expect(remaining).toHaveLength(1)
expect(remaining[0]?.id).toBe(inFlight.id)
})
test("clearQueue drops everything once no send is in flight", () => {
const target = createMessageQueueTarget("session-1", "/repo", "runtime-a")!
useMessageQueueStore.getState().addToQueue(target, { content: "queued" })
useMessageQueueStore.getState().clearQueue(target)
expect(useMessageQueueStore.getState().getQueueForTarget(target)).toHaveLength(0)
})
})