refactor: Improves abort signal handling

This commit is contained in:
Aleksander Grygier 2026-01-27 14:52:23 +01:00
parent 55e73cdde8
commit f40b377e34
7 changed files with 219 additions and 69 deletions

View File

@ -55,6 +55,7 @@ import type {
DatabaseMessageExtraImageFile
} from '$lib/types/database';
import { AttachmentType, MessageRole } from '$lib/enums';
import { isAbortError } from '$lib/utils';
/**
* Converts API messages to agentic format.
@ -507,7 +508,7 @@ export class AgenticClient {
const executionResult = await mcpClient.executeTool(mcpCall, signal);
result = executionResult.content;
} catch (error) {
if (error instanceof Error && error.name === 'AbortError') {
if (isAbortError(error)) {
onComplete?.(
'',
undefined,

View File

@ -12,7 +12,8 @@ import {
normalizeModelName,
filterByLeafNodeId,
findDescendantMessages,
findLeafNode
findLeafNode,
isAbortError
} from '$lib/utils';
import { agenticStore } from '$lib/stores/agentic.svelte';
import { DEFAULT_CONTEXT } from '$lib/constants/default-context';
@ -132,7 +133,8 @@ export class ChatClient {
* @param type - Message type (text or root)
* @param parent - Parent message ID, or '-1' to append to conversation end
* @param extras - Optional attachments (images, files, etc.)
* @returns The created message or null if failed
* @returns The created message
* @throws Error if no active conversation or database operation fails
*/
async addMessage(
role: MessageRole,
@ -140,56 +142,50 @@ export class ChatClient {
type: MessageType = MessageType.TEXT,
parent: string = '-1',
extras?: DatabaseMessageExtra[]
): Promise<DatabaseMessage | null> {
): Promise<DatabaseMessage> {
const activeConv = conversationsStore.activeConversation;
if (!activeConv) {
console.error('No active conversation when trying to add message');
return null;
throw new Error('No active conversation when trying to add message');
}
try {
let parentId: string | null = null;
let parentId: string | null = null;
if (parent === '-1') {
const activeMessages = conversationsStore.activeMessages;
if (activeMessages.length > 0) {
parentId = activeMessages[activeMessages.length - 1].id;
} else {
const allMessages = await conversationsStore.getConversationMessages(activeConv.id);
const rootMessage = allMessages.find((m) => m.parent === null && m.type === 'root');
if (!rootMessage) {
parentId = await DatabaseService.createRootMessage(activeConv.id);
} else {
parentId = rootMessage.id;
}
}
if (parent === '-1') {
const activeMessages = conversationsStore.activeMessages;
if (activeMessages.length > 0) {
parentId = activeMessages[activeMessages.length - 1].id;
} else {
parentId = parent;
const allMessages = await conversationsStore.getConversationMessages(activeConv.id);
const rootMessage = allMessages.find((m) => m.parent === null && m.type === 'root');
if (!rootMessage) {
parentId = await DatabaseService.createRootMessage(activeConv.id);
} else {
parentId = rootMessage.id;
}
}
const message = await DatabaseService.createMessageBranch(
{
convId: activeConv.id,
role,
content,
type,
timestamp: Date.now(),
toolCalls: '',
children: [],
extra: extras
},
parentId
);
conversationsStore.addMessageToActive(message);
await conversationsStore.updateCurrentNode(message.id);
conversationsStore.updateConversationTimestamp();
return message;
} catch (error) {
console.error('Failed to add message:', error);
return null;
} else {
parentId = parent;
}
const message = await DatabaseService.createMessageBranch(
{
convId: activeConv.id,
role,
content,
type,
timestamp: Date.now(),
toolCalls: '',
children: [],
extra: extras
},
parentId
);
conversationsStore.addMessageToActive(message);
await conversationsStore.updateCurrentNode(message.id);
conversationsStore.updateConversationTimestamp();
return message;
}
/**
@ -334,9 +330,11 @@ export class ChatClient {
*
*/
private async createAssistantMessage(parentId?: string): Promise<DatabaseMessage | null> {
private async createAssistantMessage(parentId?: string): Promise<DatabaseMessage> {
const activeConv = conversationsStore.activeConversation;
if (!activeConv) return null;
if (!activeConv) {
throw new Error('No active conversation when creating assistant message');
}
return await DatabaseService.createMessageBranch(
{
@ -406,12 +404,10 @@ export class ChatClient {
parentIdForUserMessage ?? '-1',
extras
);
if (!userMessage) throw new Error('Failed to add user message');
if (isNewConversation && content)
await conversationsStore.updateConversationName(currentConv.id, content.trim());
const assistantMessage = await this.createAssistantMessage(userMessage.id);
if (!assistantMessage) throw new Error('Failed to create assistant message');
conversationsStore.addMessageToActive(assistantMessage);
await this.streamChatCompletion(
@ -419,7 +415,7 @@ export class ChatClient {
assistantMessage
);
} catch (error) {
if (this.isAbortError(error)) {
if (isAbortError(error)) {
this.store.setChatLoading(currentConv.id, false);
return;
}
@ -611,7 +607,7 @@ export class ChatClient {
onError: (error: Error) => {
this.store.setStreamingActive(false);
if (this.isAbortError(error)) {
if (isAbortError(error)) {
this.store.setChatLoading(assistantMessage.convId, false);
this.store.clearChatStreaming(assistantMessage.convId);
this.store.setProcessingState(assistantMessage.convId, null);
@ -806,7 +802,6 @@ export class ChatClient {
this.store.clearChatStreaming(activeConv.id);
const assistantMessage = await this.createAssistantMessage();
if (!assistantMessage) throw new Error('Failed to create assistant message');
conversationsStore.addMessageToActive(assistantMessage);
@ -822,7 +817,7 @@ export class ChatClient {
}
);
} catch (error) {
if (!this.isAbortError(error)) console.error('Failed to update message:', error);
if (!isAbortError(error)) console.error('Failed to update message:', error);
}
}
@ -861,14 +856,13 @@ export class ChatClient {
? conversationsStore.activeMessages[conversationsStore.activeMessages.length - 1].id
: undefined;
const assistantMessage = await this.createAssistantMessage(parentMessageId);
if (!assistantMessage) throw new Error('Failed to create assistant message');
conversationsStore.addMessageToActive(assistantMessage);
await this.streamChatCompletion(
conversationsStore.activeMessages.slice(0, -1),
assistantMessage
);
} catch (error) {
if (!this.isAbortError(error)) console.error('Failed to regenerate message:', error);
if (!isAbortError(error)) console.error('Failed to regenerate message:', error);
this.store.setChatLoading(activeConv?.id || '', false);
}
}
@ -926,7 +920,7 @@ export class ChatClient {
modelToUse
);
} catch (error) {
if (!this.isAbortError(error))
if (!isAbortError(error))
console.error('Failed to regenerate message with branching:', error);
this.store.setChatLoading(activeConv?.id || '', false);
}
@ -1153,7 +1147,7 @@ export class ChatClient {
},
onError: async (error: Error) => {
if (this.isAbortError(error)) {
if (isAbortError(error)) {
if (hasReceivedContent && appendedContent) {
await DatabaseService.updateMessage(msg.id, {
content: originalContent + appendedContent,
@ -1189,7 +1183,7 @@ export class ChatClient {
abortController.signal
);
} catch (error) {
if (!this.isAbortError(error)) console.error('Failed to continue message:', error);
if (!isAbortError(error)) console.error('Failed to continue message:', error);
if (activeConv) this.store.setChatLoading(activeConv.id, false);
}
}
@ -1564,10 +1558,6 @@ export class ChatClient {
*
*/
private isAbortError(error: unknown): boolean {
return error instanceof Error && (error.name === 'AbortError' || error instanceof DOMException);
}
private isChatLoading(convId: string): boolean {
const streamingState = this.store.getChatStreaming(convId);
return streamingState !== undefined;

View File

@ -1,4 +1,4 @@
import { getJsonHeaders, formatAttachmentText } from '$lib/utils';
import { getJsonHeaders, formatAttachmentText, isAbortError } from '$lib/utils';
import { AGENTIC_REGEX } from '$lib/constants/agentic';
import { AttachmentType, MessageRole, ReasoningFormat } from '$lib/enums';
import type { ApiChatMessageContentPart } from '$lib/types/api';
@ -257,7 +257,7 @@ export class ChatService {
);
}
} catch (error) {
if (error instanceof Error && error.name === 'AbortError') {
if (isAbortError(error)) {
console.log('Chat completion request was aborted');
return;
}

View File

@ -37,6 +37,7 @@ import type {
} from '$lib/types';
import { MCPConnectionPhase, MCPLogLevel, MCPTransportType } from '$lib/enums';
import { DEFAULT_MCP_CONFIG } from '$lib/constants/mcp';
import { throwIfAborted, isAbortError } from '$lib/utils';
interface ToolResultContentItem {
type: string;
@ -341,9 +342,7 @@ export class MCPService {
params: ToolCallParams,
signal?: AbortSignal
): Promise<ToolExecutionResult> {
if (signal?.aborted) {
throw new DOMException('Aborted', 'AbortError');
}
throwIfAborted(signal);
try {
const result = await connection.client.callTool(
@ -357,7 +356,7 @@ export class MCPService {
isError: (result as ToolCallResult).isError ?? false
};
} catch (error) {
if (error instanceof DOMException && error.name === 'AbortError') {
if (isAbortError(error)) {
throw error;
}

View File

@ -395,7 +395,7 @@ class ChatStore {
type: MessageType = MessageType.TEXT,
parent: string = '-1',
extras?: DatabaseMessageExtra[]
): Promise<DatabaseMessage | null> {
): Promise<DatabaseMessage> {
return chatClient.addMessage(role, content, type, parent, extras);
}

View File

@ -0,0 +1,151 @@
/**
* Abort Signal Utilities
*
* Provides utilities for consistent AbortSignal propagation across the application.
* These utilities help ensure that async operations can be properly cancelled
* when needed (e.g., user stops generation, navigates away, etc.).
*/
/**
* Throws an AbortError if the signal is aborted.
* Use this at the start of async operations to fail fast.
*
* @param signal - Optional AbortSignal to check
* @throws DOMException with name 'AbortError' if signal is aborted
*
* @example
* ```ts
* async function fetchData(signal?: AbortSignal) {
* throwIfAborted(signal);
* // ... proceed with operation
* }
* ```
*/
export function throwIfAborted(signal?: AbortSignal): void {
if (signal?.aborted) {
throw new DOMException('Operation was aborted', 'AbortError');
}
}
/**
* Checks if an error is an AbortError.
* Use this to distinguish between user-initiated cancellation and actual errors.
*
* @param error - Error to check
* @returns true if the error is an AbortError
*
* @example
* ```ts
* try {
* await fetchData(signal);
* } catch (error) {
* if (isAbortError(error)) {
* // User cancelled - no error dialog needed
* return;
* }
* // Handle actual error
* }
* ```
*/
export function isAbortError(error: unknown): boolean {
if (error instanceof DOMException && error.name === 'AbortError') {
return true;
}
if (error instanceof Error && error.name === 'AbortError') {
return true;
}
return false;
}
/**
* Creates a new AbortController that is linked to one or more parent signals.
* When any parent signal aborts, the returned controller also aborts.
*
* Useful for creating child operations that should be cancelled when
* either the parent operation or their own timeout/condition triggers.
*
* @param signals - Parent signals to link to (undefined signals are ignored)
* @returns A new AbortController linked to all provided signals
*
* @example
* ```ts
* // Link to user's abort signal and add a timeout
* const linked = createLinkedController(userSignal, timeoutSignal);
* await fetch(url, { signal: linked.signal });
* ```
*/
export function createLinkedController(...signals: (AbortSignal | undefined)[]): AbortController {
const controller = new AbortController();
for (const signal of signals) {
if (!signal) continue;
// If already aborted, abort immediately
if (signal.aborted) {
controller.abort(signal.reason);
return controller;
}
// Link to parent signal
signal.addEventListener('abort', () => controller.abort(signal.reason), { once: true });
}
return controller;
}
/**
* Creates an AbortSignal that times out after the specified duration.
*
* @param ms - Timeout duration in milliseconds
* @returns AbortSignal that will abort after the timeout
*
* @example
* ```ts
* const signal = createTimeoutSignal(5000); // 5 second timeout
* await fetch(url, { signal });
* ```
*/
export function createTimeoutSignal(ms: number): AbortSignal {
return AbortSignal.timeout(ms);
}
/**
* Wraps a promise to reject if the signal is aborted.
* Useful for making non-abortable promises respect an AbortSignal.
*
* @param promise - Promise to wrap
* @param signal - AbortSignal to respect
* @returns Promise that rejects with AbortError if signal aborts
*
* @example
* ```ts
* // Make a non-abortable operation respect abort signal
* const result = await withAbortSignal(
* someNonAbortableOperation(),
* signal
* );
* ```
*/
export async function withAbortSignal<T>(promise: Promise<T>, signal?: AbortSignal): Promise<T> {
if (!signal) return promise;
throwIfAborted(signal);
return new Promise<T>((resolve, reject) => {
const abortHandler = () => {
reject(new DOMException('Operation was aborted', 'AbortError'));
};
signal.addEventListener('abort', abortHandler, { once: true });
promise
.then((value) => {
signal.removeEventListener('abort', abortHandler);
resolve(value);
})
.catch((error) => {
signal.removeEventListener('abort', abortHandler);
reject(error);
});
});
}

View File

@ -121,3 +121,12 @@ export { parseAgenticContent, type AgenticSection } from './agentic';
// Cache utilities
export { TTLCache, ReactiveTTLMap, type TTLCacheOptions } from './cache-ttl';
// Abort signal utilities
export {
throwIfAborted,
isAbortError,
createLinkedController,
createTimeoutSignal,
withAbortSignal
} from './abort';