fix(chat): isolate pending flows and cancel stale requests
This commit is contained in:
@@ -4,6 +4,7 @@ import type { UiMessage } from "@/stores/chat/ui-message";
|
||||
import { loadChatRepository } from "@/data/repositories/chat_repository_loader";
|
||||
import { Logger } from "@/utils/logger";
|
||||
import { Result } from "@/utils/result";
|
||||
import { isAbortError } from "@/utils/abort";
|
||||
|
||||
import {
|
||||
readLocalHistorySnapshot,
|
||||
@@ -38,6 +39,7 @@ export const loadHistoryActor = fromCallback<
|
||||
ChatHistoryActorInput
|
||||
>(({ input, sendBack }) => {
|
||||
let cancelled = false;
|
||||
const controller = new AbortController();
|
||||
|
||||
void (async () => {
|
||||
const cacheIdentity = await resolveHistoryCacheIdentity(input.characterId);
|
||||
@@ -52,6 +54,7 @@ export const loadHistoryActor = fromCallback<
|
||||
input.characterId,
|
||||
localSnapshot.localCount,
|
||||
cacheIdentity,
|
||||
controller.signal,
|
||||
);
|
||||
if (cancelled || !networkSnapshot) return;
|
||||
sendBack({
|
||||
@@ -59,13 +62,14 @@ export const loadHistoryActor = fromCallback<
|
||||
output: networkSnapshot,
|
||||
});
|
||||
})().catch((error: unknown) => {
|
||||
if (cancelled) return;
|
||||
if (cancelled || isAbortError(error)) return;
|
||||
log.error("[chat-machine] loadHistoryActor failed", { error });
|
||||
sendBack({ type: "ChatHistoryLoadFailed", error });
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
controller.abort();
|
||||
};
|
||||
});
|
||||
|
||||
@@ -74,10 +78,13 @@ export const loadMoreHistoryActor =
|
||||
({ input, receive, sendBack }) => {
|
||||
let cancelled = false;
|
||||
let running = false;
|
||||
let activeController: AbortController | null = null;
|
||||
|
||||
receive((event) => {
|
||||
if (running || cancelled) return;
|
||||
running = true;
|
||||
const controller = new AbortController();
|
||||
activeController = controller;
|
||||
|
||||
void (async () => {
|
||||
const chatRepo = await loadChatRepository();
|
||||
@@ -85,6 +92,7 @@ export const loadMoreHistoryActor =
|
||||
input.characterId,
|
||||
event.limit,
|
||||
event.offset,
|
||||
{ signal: controller.signal },
|
||||
);
|
||||
if (Result.isErr(historyResult)) throw historyResult.error;
|
||||
if (cancelled) return;
|
||||
@@ -111,17 +119,19 @@ export const loadMoreHistoryActor =
|
||||
);
|
||||
})()
|
||||
.catch((error: unknown) => {
|
||||
if (cancelled) return;
|
||||
if (cancelled || isAbortError(error)) return;
|
||||
log.warn("[chat-machine] loadMoreHistoryActor failed", { error });
|
||||
sendBack({ type: "ChatOlderHistoryLoadFailed", error });
|
||||
})
|
||||
.finally(() => {
|
||||
if (activeController === controller) activeController = null;
|
||||
running = false;
|
||||
});
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
activeController?.abort();
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
@@ -7,6 +7,7 @@ import { loadChatRepository } from "@/data/repositories/chat_repository_loader";
|
||||
import { resolveChatConversationKey } from "@/data/repositories/chat_cache_identity";
|
||||
import { Logger } from "@/utils/logger";
|
||||
import { Result } from "@/utils/result";
|
||||
import { isAbortError } from "@/utils/abort";
|
||||
|
||||
import type { ChatEvent } from "../../chat-events";
|
||||
import { sendResponseToUiMessage } from "../../helper/message-mappers";
|
||||
@@ -18,8 +19,8 @@ type UiMessage = import("@/stores/chat/ui-message").UiMessage;
|
||||
export const sendMessageHttpActor = fromPromise<
|
||||
{ response: ChatSendResponse; reply: UiMessage | null },
|
||||
{ characterId: string; content: string }
|
||||
>(async ({ input }) => {
|
||||
return sendMessageViaHttp(input.characterId, input.content);
|
||||
>(async ({ input, signal }) => {
|
||||
return sendMessageViaHttp(input.characterId, input.content, signal);
|
||||
});
|
||||
|
||||
export const httpMessageQueueActor = fromCallback<
|
||||
@@ -37,13 +38,21 @@ function createMessageQueueActor(
|
||||
receive: (listener: (event: ChatEvent) => void) => void,
|
||||
): () => void {
|
||||
const queue = new MessageQueue();
|
||||
let activeController: AbortController | null = null;
|
||||
|
||||
queue.setConsumer(async (content) => {
|
||||
const controller = new AbortController();
|
||||
activeController = controller;
|
||||
sendBack({ type: "ChatQueuedSendStarted" });
|
||||
try {
|
||||
const output = await sendMessageViaHttp(characterId, content);
|
||||
const output = await sendMessageViaHttp(
|
||||
characterId,
|
||||
content,
|
||||
controller.signal,
|
||||
);
|
||||
sendBack({ type: "ChatQueuedHttpDone", output });
|
||||
} catch (error) {
|
||||
if (isAbortError(error)) return;
|
||||
const errorMessage = ExceptionHandler.message(error);
|
||||
log.error("[chat-machine] message queue send failed", {
|
||||
error,
|
||||
@@ -53,6 +62,8 @@ function createMessageQueueActor(
|
||||
content,
|
||||
errorMessage,
|
||||
});
|
||||
} finally {
|
||||
if (activeController === controller) activeController = null;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -63,22 +74,31 @@ function createMessageQueueActor(
|
||||
queue.enqueue(content);
|
||||
});
|
||||
|
||||
return () => queue.dispose();
|
||||
return () => {
|
||||
activeController?.abort();
|
||||
queue.dispose();
|
||||
};
|
||||
}
|
||||
|
||||
async function sendMessageViaHttp(characterId: string, content: string): Promise<{
|
||||
async function sendMessageViaHttp(
|
||||
characterId: string,
|
||||
content: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<{
|
||||
response: ChatSendResponse;
|
||||
reply: UiMessage | null;
|
||||
}> {
|
||||
const chatRepo = await loadChatRepository();
|
||||
const cacheIdentityResult = await resolveChatConversationKey(characterId);
|
||||
const result = await chatRepo.sendMessage(characterId, content);
|
||||
const result = await chatRepo.sendMessage(characterId, content, { signal });
|
||||
if (Result.isErr(result)) {
|
||||
if (isAbortError(result.error)) throw result.error;
|
||||
log.error("[chat-machine] sendMessageHttpActor failed", {
|
||||
error: result.error,
|
||||
});
|
||||
throw result.error;
|
||||
}
|
||||
signal?.throwIfAborted();
|
||||
if (Result.isOk(cacheIdentityResult)) {
|
||||
void chatRepo.prefetchMediaForSendResponse(
|
||||
result.data,
|
||||
|
||||
@@ -4,6 +4,7 @@ import { loadChatRepository } from "@/data/repositories/chat_repository_loader";
|
||||
import { resolveChatConversationKey } from "@/data/repositories/chat_cache_identity";
|
||||
import { Logger } from "@/utils/logger";
|
||||
import { Result } from "@/utils/result";
|
||||
import { isAbortError } from "@/utils/abort";
|
||||
|
||||
import { readAndSyncHistory } from "../../chat-history-sync";
|
||||
import {
|
||||
@@ -25,17 +26,21 @@ export interface UnlockHistoryOutput {
|
||||
export const unlockHistoryActor = fromPromise<
|
||||
UnlockHistoryOutput,
|
||||
{ characterId: string }
|
||||
>(async ({ input }) => {
|
||||
>(async ({ input, signal }) => {
|
||||
const chatRepo = await loadChatRepository();
|
||||
const unlockResult = await chatRepo.unlockHistory(input.characterId);
|
||||
const unlockResult = await chatRepo.unlockHistory(input.characterId, {
|
||||
signal,
|
||||
});
|
||||
if (Result.isErr(unlockResult)) {
|
||||
if (isAbortError(unlockResult.error)) throw unlockResult.error;
|
||||
log.error("[chat-machine] unlockHistoryActor failed", {
|
||||
error: unlockResult.error,
|
||||
});
|
||||
throw unlockResult.error;
|
||||
}
|
||||
|
||||
const history = await readAndSyncHistory(input.characterId);
|
||||
signal.throwIfAborted();
|
||||
const history = await readAndSyncHistory(input.characterId, signal);
|
||||
return {
|
||||
unlocked: unlockResult.data.unlocked,
|
||||
reason: unlockResult.data.reason,
|
||||
@@ -47,18 +52,22 @@ export const unlockHistoryActor = fromPromise<
|
||||
export const unlockMessageActor = fromPromise<
|
||||
UnlockMessageOutput,
|
||||
UnlockMessageRequest & { characterId: string }
|
||||
>(async ({ input }) => {
|
||||
>(async ({ input, signal }) => {
|
||||
const chatRepo = await loadChatRepository();
|
||||
const cacheIdentityResult = await resolveChatConversationKey(
|
||||
input.characterId,
|
||||
);
|
||||
const unlockResult = await chatRepo.unlockPrivateMessage({
|
||||
characterId: input.characterId,
|
||||
...(input.messageId ? { messageId: input.messageId } : {}),
|
||||
...(input.lockType ? { lockType: input.lockType } : {}),
|
||||
...(input.clientLockId ? { clientLockId: input.clientLockId } : {}),
|
||||
});
|
||||
const unlockResult = await chatRepo.unlockPrivateMessage(
|
||||
{
|
||||
characterId: input.characterId,
|
||||
...(input.messageId ? { messageId: input.messageId } : {}),
|
||||
...(input.lockType ? { lockType: input.lockType } : {}),
|
||||
...(input.clientLockId ? { clientLockId: input.clientLockId } : {}),
|
||||
},
|
||||
{ signal },
|
||||
);
|
||||
if (Result.isErr(unlockResult)) {
|
||||
if (isAbortError(unlockResult.error)) throw unlockResult.error;
|
||||
log.error("[chat-machine] unlockMessageActor failed", {
|
||||
messageId: input.messageId,
|
||||
clientLockId: input.clientLockId,
|
||||
@@ -66,6 +75,7 @@ export const unlockMessageActor = fromPromise<
|
||||
});
|
||||
throw unlockResult.error;
|
||||
}
|
||||
signal.throwIfAborted();
|
||||
|
||||
if (
|
||||
unlockResult.data.unlocked &&
|
||||
|
||||
Reference in New Issue
Block a user