diff --git a/src/background/index.mjs b/src/background/index.mjs index aa41ecd4f..4fe079b72 100644 --- a/src/background/index.mjs +++ b/src/background/index.mjs @@ -110,6 +110,7 @@ function setPortProxy(port, proxyTabId) { console.debug('[background] Main port closed; skipping proxy message.') return } + if (msg.done || msg.error) port._generating = false try { port.postMessage(msg) } catch (e) { @@ -156,6 +157,16 @@ function setPortProxy(port, proxyTabId) { const proxyRef = port.proxy port.proxy = null port._proxyTabId = null + if (port._generating) { + port._generating = false + if (!port._isClosed) { + try { + port.postMessage({ done: true }) + } catch (e) { + console.warn('[background] Error posting done on proxy disconnect:', e) + } + } + } if (port._reconnectTimerId) { clearTimeout(port._reconnectTimerId) port._reconnectTimerId = null @@ -257,6 +268,7 @@ function setPortProxy(port, proxyTabId) { '[background] Main port disconnected (e.g. popup/sidebar closed). Cleaning up proxy connections and listeners.', ) port._isClosed = true + port._generating = false if (port._reconnectTimerId) { clearTimeout(port._reconnectTimerId) port._reconnectTimerId = null @@ -401,6 +413,7 @@ async function executeApi(session, port, config) { console.debug('[background] Posting message to proxy tab:', { session: redactedSession }) try { port.proxy.postMessage({ session }) + port._generating = true } catch (e) { console.warn( '[background] Error posting message to existing proxy tab in executeApi (ChatGPT Web Model):', @@ -413,6 +426,7 @@ async function executeApi(session, port, config) { console.debug('[background] Proxy re-established. Attempting to post message again.') try { port.proxy.postMessage({ session }) + port._generating = true console.info('[background] Successfully posted session after proxy reconnection.') } catch (e2) { console.error( diff --git a/src/components/ConversationCard/index.jsx b/src/components/ConversationCard/index.jsx index f7137506e..71dbba2fb 100644 --- a/src/components/ConversationCard/index.jsx +++ b/src/components/ConversationCard/index.jsx @@ -66,6 +66,7 @@ function ConversationCard(props) { const [session, setSession] = useState(props.session) const windowSize = useClampWindowSize([750, 1500], [250, 1100]) const bodyRef = useRef(null) + const replacedPortRef = useRef(null) const [completeDraggable, setCompleteDraggable] = useState(false) const useForegroundFetch = isUsingBingWebModel(session) const [apiModes, setApiModes] = useState([]) @@ -118,7 +119,7 @@ function ConversationCard(props) { useEffect(() => { if (props.onUpdate) props.onUpdate(port, session, conversationItemData) - }, [session, conversationItemData]) + }, [port, session, conversationItemData]) useEffect(() => { const { offsetHeight, scrollHeight, scrollTop } = bodyRef.current @@ -289,6 +290,10 @@ function ConversationCard(props) { useEffect(() => { const portListener = () => { + if (replacedPortRef.current === port) { + replacedPortRef.current = null + return + } setPort(Browser.runtime.connect()) setIsReady(true) } @@ -329,7 +334,7 @@ function ConversationCard(props) { port.onMessage.removeListener(portMessageListener) } } - }, [conversationItemData]) + }, [port, conversationItemData]) const getRetryFn = (session) => async () => { updateAnswer(`
${t('Waiting for response...')}
`, false, 'answer') @@ -493,6 +498,11 @@ function ConversationCard(props) { text={t('Clear Conversation')} onConfirm={async () => { await postMessage({ stop: true }) + if (!useForegroundFetch) { + replacedPortRef.current = port + port.disconnect() + setPort(Browser.runtime.connect()) + } Browser.runtime.sendMessage({ type: 'DELETE_CONVERSATION', data: { @@ -507,6 +517,7 @@ function ConversationCard(props) { }) newSession.sessionId = session.sessionId setSession(newSession) + setIsReady(true) }} /> {!props.pageMode && ( diff --git a/src/services/apis/azure-openai-api.mjs b/src/services/apis/azure-openai-api.mjs index 40585348d..a8e8eaf80 100644 --- a/src/services/apis/azure-openai-api.mjs +++ b/src/services/apis/azure-openai-api.mjs @@ -68,10 +68,15 @@ export async function generateAnswersWithAzureOpenaiApi(port, question, session) } }, async onStart() {}, - async onEnd() { - port.postMessage({ done: true }) - port.onMessage.removeListener(messageListener) - port.onDisconnect.removeListener(disconnectListener) + async onEnd(aborted) { + try { + if (!aborted) { + port.postMessage({ done: true }) + } + } finally { + port.onMessage.removeListener(messageListener) + port.onDisconnect.removeListener(disconnectListener) + } }, async onError(resp) { port.onMessage.removeListener(messageListener) diff --git a/src/services/apis/chatgpt-web.mjs b/src/services/apis/chatgpt-web.mjs index 8684e9cdc..56a50466b 100644 --- a/src/services/apis/chatgpt-web.mjs +++ b/src/services/apis/chatgpt-web.mjs @@ -357,42 +357,51 @@ export async function generateAnswersWithChatgptWebApi(port, question, session, session.wsRequestId = wsRequestId port.postMessage({ session: session }) } else { - await fetchSSE(url, { - ...options, - onMessage(message) { - console.debug('sse message', message) - if (message.trim() === '[DONE]') { - finishMessage() - return - } - let data - try { - data = JSON.parse(message) - } catch (error) { - console.debug('json error', error) - return - } - handleMessage(data) - }, - async onStart() { - // sendModerations(accessToken, question, session.conversationId, session.messageId) - }, - async onEnd() { - port.postMessage({ done: true }) - cleanController() - }, - async onError(resp) { - cleanController() - if (resp instanceof Error) throw resp - if (resp.status === 403) { - throw new Error('CLOUDFLARE') - } - const error = await resp.json().catch(() => ({})) - throw new Error( - !isEmpty(error) ? JSON.stringify(error) : `${resp.status} ${resp.statusText}`, - ) - }, - }) + try { + await fetchSSE(url, { + ...options, + onMessage(message) { + console.debug('sse message', message) + if (message.trim() === '[DONE]') { + finishMessage() + return + } + let data + try { + data = JSON.parse(message) + } catch (error) { + console.debug('json error', error) + return + } + handleMessage(data) + }, + async onStart() { + // sendModerations(accessToken, question, session.conversationId, session.messageId) + }, + async onEnd(aborted) { + try { + if (!aborted) { + port.postMessage({ done: true }) + } + } finally { + cleanController() + } + }, + async onError(resp) { + cleanController() + if (resp instanceof Error) throw resp + if (resp.status === 403) { + throw new Error('CLOUDFLARE') + } + const error = await resp.json().catch(() => ({})) + throw new Error( + !isEmpty(error) ? JSON.stringify(error) : `${resp.status} ${resp.statusText}`, + ) + }, + }) + } finally { + cleanController() + } } function handleMessage(data) { diff --git a/src/services/apis/claude-api.mjs b/src/services/apis/claude-api.mjs index 648720c01..341a4dd9d 100644 --- a/src/services/apis/claude-api.mjs +++ b/src/services/apis/claude-api.mjs @@ -78,10 +78,15 @@ export async function generateAnswersWithClaudeApi(port, question, session) { } }, async onStart() {}, - async onEnd() { - port.postMessage({ done: true }) - port.onMessage.removeListener(messageListener) - port.onDisconnect.removeListener(disconnectListener) + async onEnd(aborted) { + try { + if (!aborted) { + port.postMessage({ done: true }) + } + } finally { + port.onMessage.removeListener(messageListener) + port.onDisconnect.removeListener(disconnectListener) + } }, async onError(resp) { port.onMessage.removeListener(messageListener) diff --git a/src/services/apis/moonshot-web.mjs b/src/services/apis/moonshot-web.mjs index 2f6334386..258aafb20 100644 --- a/src/services/apis/moonshot-web.mjs +++ b/src/services/apis/moonshot-web.mjs @@ -426,7 +426,11 @@ export class Conversation { } }, async onStart() {}, - async onEnd() { + async onEnd(aborted = false) { + if (aborted) { + reject(new DOMException('Aborted', 'AbortError')) + return + } resolve({ completion: fullResponse, }) diff --git a/src/services/apis/openai-compatible-core.mjs b/src/services/apis/openai-compatible-core.mjs index 86d08500d..649605d20 100644 --- a/src/services/apis/openai-compatible-core.mjs +++ b/src/services/apis/openai-compatible-core.mjs @@ -142,12 +142,30 @@ export async function generateAnswersWithOpenAICompatible({ } }, async onStart() {}, - async onEnd() { - if (!finished) { - finish() + async onEnd(aborted = false) { + try { + if (!finished) { + if (aborted) { + const shouldPostSession = Boolean(answer) || session.isRetry + if (shouldPostSession) { + if (answer) { + pushRecord(session, question, answer) + } + session.isRetry = false + try { + port.postMessage({ session }) + } catch (e) { + console.warn('[openai-compatible-core] Failed to post session on abort:', e) + } + } + } else { + finish() + } + } + } finally { + port.onMessage.removeListener(messageListener) + port.onDisconnect.removeListener(disconnectListener) } - port.onMessage.removeListener(messageListener) - port.onDisconnect.removeListener(disconnectListener) }, async onError(resp) { port.onMessage.removeListener(messageListener) diff --git a/src/services/apis/waylaidwanderer-api.mjs b/src/services/apis/waylaidwanderer-api.mjs index d30603488..a07cdf1bf 100644 --- a/src/services/apis/waylaidwanderer-api.mjs +++ b/src/services/apis/waylaidwanderer-api.mjs @@ -63,10 +63,15 @@ export async function generateAnswersWithWaylaidwandererApi(port, question, sess } }, async onStart() {}, - async onEnd() { - port.postMessage({ done: true }) - port.onMessage.removeListener(messageListener) - port.onDisconnect.removeListener(disconnectListener) + async onEnd(aborted) { + try { + if (!aborted) { + port.postMessage({ done: true }) + } + } finally { + port.onMessage.removeListener(messageListener) + port.onDisconnect.removeListener(disconnectListener) + } }, async onError(resp) { port.onMessage.removeListener(messageListener) diff --git a/src/services/clients/claude/index.mjs b/src/services/clients/claude/index.mjs index f21652fad..02b9b1117 100644 --- a/src/services/clients/claude/index.mjs +++ b/src/services/clients/claude/index.mjs @@ -627,7 +627,11 @@ export class Conversation { } }, async onStart() {}, - async onEnd() { + async onEnd(aborted = false) { + if (aborted) { + reject(new DOMException('Aborted', 'AbortError')) + return + } resolve({ completion: fullResponse, }) diff --git a/src/services/wrappers.mjs b/src/services/wrappers.mjs index 71dce351a..c36dea786 100644 --- a/src/services/wrappers.mjs +++ b/src/services/wrappers.mjs @@ -66,9 +66,30 @@ function isAbortError(err) { return name === 'AbortError' || message.includes('aborted') || message.includes('aborterror') } +function isDisconnectedPortError(err) { + if (!err || typeof err !== 'object') return false + const message = typeof err.message === 'string' ? err.message.toLowerCase() : '' + return ( + message.includes('disconnected port') || + message.includes('port closed') || + message.includes('extension context invalidated') + ) +} + export function handlePortError(session, port, err) { if (isAbortError(err)) return + if (isDisconnectedPortError(err)) { + console.warn('[handlePortError] Ignoring disconnected port error:', err.message) + return + } console.error(err) + const postError = (error) => { + try { + port.postMessage({ error }) + } catch (postErr) { + console.warn('[handlePortError] Failed to post error:', postErr) + } + } const message = typeof err?.message === 'string' ? err.message : '' if (message) { if ( @@ -76,35 +97,33 @@ export function handlePortError(session, port, err) { message.includes(m), ) ) - port.postMessage({ error: t('Exceeded maximum context length') + '\n\n' + message }) + postError(t('Exceeded maximum context length') + '\n\n' + message) else if (['CaptchaChallenge', 'CAPTCHA'].some((m) => message.includes(m))) - port.postMessage({ error: t('Bing CaptchaChallenge') + '\n\n' + message }) + postError(t('Bing CaptchaChallenge') + '\n\n' + message) else if (['exceeded your current quota'].some((m) => message.includes(m))) - port.postMessage({ error: t('Exceeded quota') + '\n\n' + message }) + postError(t('Exceeded quota') + '\n\n' + message) else if (['Rate limit reached'].some((m) => message.includes(m))) - port.postMessage({ error: t('Rate limit') + '\n\n' + message }) + postError(t('Rate limit') + '\n\n' + message) else if (['authentication token has expired'].some((m) => message.includes(m))) - port.postMessage({ error: 'UNAUTHORIZED' }) + postError('UNAUTHORIZED') else if ( isUsingClaudeWebModel(session) && ['Invalid authorization', 'Session key required'].some((m) => message.includes(m)) ) - port.postMessage({ - error: t('Please login at https://claude.ai first, and then click the retry button'), - }) + postError(t('Please login at https://claude.ai first, and then click the retry button')) else if ( isUsingBingWebModel(session) && ['/turing/conversation/create: failed to parse response body.'].some((m) => message.includes(m), ) ) - port.postMessage({ error: t('Please login at https://bing.com first') }) - else port.postMessage({ error: message }) + postError(t('Please login at https://bing.com first')) + else postError(message) } else { const errMsg = JSON.stringify(err) ?? 'unknown error' if (isUsingBingWebModel(session) && errMsg.includes('isTrusted')) - port.postMessage({ error: t('Please login at https://bing.com first') }) - else port.postMessage({ error: errMsg }) + postError(t('Please login at https://bing.com first')) + else postError(errMsg) } } diff --git a/src/utils/fetch-sse.mjs b/src/utils/fetch-sse.mjs index 059cc0430..06027cee3 100644 --- a/src/utils/fetch-sse.mjs +++ b/src/utils/fetch-sse.mjs @@ -1,11 +1,28 @@ import { createParser } from './eventsource-parser.mjs' +function isAbortError(err) { + if (!err || typeof err !== 'object') return false + const name = typeof err.name === 'string' ? err.name : '' + return name === 'AbortError' +} + export async function fetchSSE(resource, options) { const { onMessage, onStart, onEnd, onError, ...fetchOptions } = options - const resp = await fetch(resource, fetchOptions).catch(async (err) => { + let resp + try { + resp = await fetch(resource, fetchOptions) + } catch (err) { + if (isAbortError(err)) { + try { + await onEnd(true) + } catch (e) { + console.warn('[fetch-sse] onEnd threw during abort:', e) + } + return + } await onError(err) - }) - if (!resp) return + return + } if (!resp.ok) { await onError(resp) return @@ -15,15 +32,42 @@ export async function fetchSSE(resource, options) { onMessage(event.data) } }) + const handleCallbackError = async (err) => { + await onError(err) + throw err + } let hasStarted = false const reader = resp.body.getReader() let result - while (!(result = await reader.read()).done) { + let done = false + while (!done) { + try { + result = await reader.read() + } catch (err) { + if (isAbortError(err)) { + try { + await onEnd(true) + } catch (e) { + console.warn('[fetch-sse] onEnd threw during abort:', e) + } + return + } + await onError(err) + return + } + + done = result.done + if (done) break + const chunk = result.value if (!hasStarted) { const str = new TextDecoder().decode(chunk) hasStarted = true - await onStart(str) + try { + await onStart(str) + } catch (err) { + await handleCallbackError(err) + } let fakeSseData try { @@ -33,11 +77,19 @@ export async function fetchSSE(resource, options) { console.debug('not common response', error) } if (fakeSseData) { - parser.feed(new TextEncoder().encode(fakeSseData)) + try { + parser.feed(new TextEncoder().encode(fakeSseData)) + } catch (err) { + await handleCallbackError(err) + } break } } - parser.feed(chunk) + try { + parser.feed(chunk) + } catch (err) { + await handleCallbackError(err) + } } await onEnd() } diff --git a/tests/unit/services/apis/openai-api-compat.test.mjs b/tests/unit/services/apis/openai-api-compat.test.mjs index d4c6494f9..01aaccba0 100644 --- a/tests/unit/services/apis/openai-api-compat.test.mjs +++ b/tests/unit/services/apis/openai-api-compat.test.mjs @@ -138,6 +138,120 @@ test('generateAnswersWithOpenAiApiCompat emits fallback done message when stream }) }) +test('generateAnswersWithOpenAiApiCompat preserves partial retry answer when aborted port is closed', async (t) => { + t.mock.method(console, 'debug', () => {}) + t.mock.method(console, 'warn', () => {}) + setStorage({ + maxConversationContextLength: 3, + maxResponseTokenLength: 256, + temperature: 0.25, + }) + + const session = { + modelName: 'chatgptApi4oMini', + conversationRecords: [{ question: 'CurrentQ', answer: 'Old answer' }], + isRetry: true, + } + const port = createFakePort() + const originalPostMessage = port.postMessage.bind(port) + port.postMessage = (message) => { + if (message?.session && !message.done) { + throw new Error('Port closed') + } + originalPostMessage(message) + } + + t.mock.method(globalThis, 'fetch', async () => { + const encoder = new TextEncoder() + let readCount = 0 + return { + ok: true, + status: 200, + statusText: 'OK', + body: { + getReader() { + return { + async read() { + readCount += 1 + if (readCount === 1) { + return { + done: false, + value: encoder.encode('data: {"choices":[{"delta":{"content":"Partial"}}]}\n\n'), + } + } + throw Object.assign(new Error('The operation was aborted'), { + name: 'AbortError', + }) + }, + } + }, + }, + } + }) + + await generateAnswersWithOpenAiApiCompat( + 'https://api.example.com/v1', + port, + 'CurrentQ', + session, + 'sk-test', + ) + + assert.equal( + port.postedMessages.some((message) => message.done === false && message.answer === 'Partial'), + true, + ) + assert.deepEqual(session.conversationRecords, [{ question: 'CurrentQ', answer: 'Partial' }]) + assert.equal(session.isRetry, false) + assert.deepEqual(port.listenerCounts(), { onMessage: 0, onDisconnect: 0 }) +}) + +test('generateAnswersWithOpenAiApiCompat clears retry state when aborted before first chunk', async (t) => { + t.mock.method(console, 'debug', () => {}) + setStorage({ + maxConversationContextLength: 3, + maxResponseTokenLength: 256, + temperature: 0.25, + }) + + const session = { + modelName: 'chatgptApi4oMini', + conversationRecords: [{ question: 'CurrentQ', answer: 'Old answer' }], + isRetry: true, + } + const port = createFakePort() + + t.mock.method(globalThis, 'fetch', async () => ({ + ok: true, + status: 200, + statusText: 'OK', + body: { + getReader() { + return { + async read() { + throw Object.assign(new Error('The operation was aborted'), { + name: 'AbortError', + }) + }, + } + }, + }, + })) + + await generateAnswersWithOpenAiApiCompat( + 'https://api.example.com/v1', + port, + 'CurrentQ', + session, + 'sk-test', + ) + + assert.deepEqual(session.conversationRecords, [{ question: 'CurrentQ', answer: 'Old answer' }]) + assert.equal(session.isRetry, false) + assert.deepEqual(port.postedMessages.at(-1), { session }) + assert.deepEqual(port.listenerCounts(), { onMessage: 0, onDisconnect: 0 }) +}) + test('generateAnswersWithOpenAiApiCompat records an empty answer when stream ends before first chunk', async (t) => { t.mock.method(console, 'debug', () => {}) setStorage({ diff --git a/tests/unit/services/handle-port-error.test.mjs b/tests/unit/services/handle-port-error.test.mjs index 0962e0026..ab3e2d1c8 100644 --- a/tests/unit/services/handle-port-error.test.mjs +++ b/tests/unit/services/handle-port-error.test.mjs @@ -112,6 +112,20 @@ test('handlePortError ignores AbortError by name even when message text differs' assert.equal(consoleError.mock.callCount(), 0) }) +test('handlePortError ignores disconnected port errors', (t) => { + const consoleError = t.mock.method(console, 'error', () => {}) + const consoleWarn = t.mock.method(console, 'warn', () => {}) + const port = createFakePort() + + handlePortError({ modelName: 'chatgptApi4oMini' }, port, { + message: 'Attempting to use a disconnected port object', + }) + + assert.deepEqual(port.postedMessages, []) + assert.equal(consoleError.mock.callCount(), 0) + assert.equal(consoleWarn.mock.callCount(), 1) +}) + test('handlePortError reports Claude web authorization hint', (t) => { t.mock.method(console, 'error', () => {}) const port = createFakePort() @@ -197,3 +211,20 @@ test('handlePortError handles undefined thrown values without throwing again', ( assert.equal(port.postedMessages.length, 1) assert.equal(port.postedMessages[0].error, 'unknown error') }) + +test('handlePortError does not throw when the error port is closed', (t) => { + t.mock.method(console, 'error', () => {}) + const consoleWarn = t.mock.method(console, 'warn', () => {}) + const port = { + postMessage() { + throw new Error('Port closed') + }, + } + + assert.doesNotThrow(() => { + handlePortError({ modelName: 'chatgptApi4oMini' }, port, { + message: 'done failed', + }) + }) + assert.equal(consoleWarn.mock.callCount(), 1) +}) diff --git a/tests/unit/utils/fetch-sse.test.mjs b/tests/unit/utils/fetch-sse.test.mjs index dc6254c4f..b07ac4613 100644 --- a/tests/unit/utils/fetch-sse.test.mjs +++ b/tests/unit/utils/fetch-sse.test.mjs @@ -112,3 +112,64 @@ test('fetchSSE forwards fetch rejection errors to onError', async (t) => { assert.equal(errors.length, 1) assert.equal(errors[0].message, 'network down') }) + +test('fetchSSE propagates onEnd errors on normal completion', async (t) => { + t.mock.method(console, 'debug', () => {}) + t.mock.method(globalThis, 'fetch', async () => createMockSseResponse(['data: {"delta":"A"}\n\n'])) + + await assert.rejects( + fetchSSE('https://example.com/sse', { + onStart: async () => {}, + onMessage: () => {}, + onEnd: async () => { + throw new Error('done failed') + }, + onError: async () => {}, + }), + /done failed/, + ) +}) + +test('fetchSSE propagates onStart errors', async (t) => { + t.mock.method(console, 'debug', () => {}) + t.mock.method(globalThis, 'fetch', async () => createMockSseResponse(['data: {"delta":"A"}\n\n'])) + const errors = [] + + await assert.rejects( + fetchSSE('https://example.com/sse', { + onStart: async () => { + throw new Error('start failed') + }, + onMessage: () => {}, + onEnd: async () => {}, + onError: async (error) => { + errors.push(error) + }, + }), + /start failed/, + ) + assert.equal(errors.length, 1) + assert.equal(errors[0].message, 'start failed') +}) + +test('fetchSSE propagates onMessage errors', async (t) => { + t.mock.method(console, 'debug', () => {}) + t.mock.method(globalThis, 'fetch', async () => createMockSseResponse(['data: {"delta":"A"}\n\n'])) + const errors = [] + + await assert.rejects( + fetchSSE('https://example.com/sse', { + onStart: async () => {}, + onMessage: () => { + throw new Error('message failed') + }, + onEnd: async () => {}, + onError: async (error) => { + errors.push(error) + }, + }), + /message failed/, + ) + assert.equal(errors.length, 1) + assert.equal(errors[0].message, 'message failed') +})