From 0c169bb470a7ba2c5b625ec3cc13459016024c63 Mon Sep 17 00:00:00 2001 From: HandSonic <8078023+handsonic@users.noreply.github.com> Date: Sun, 30 Aug 2026 13:52:07 +0800 Subject: [PATCH] fix(desktop): settle command request lifecycle --- chat2db-community-client/package.json | 3 +- .../src/hooks/useAbortRequest.ts | 12 +- .../src/service/commandLine/commandLine.ts | 125 +++++------ .../commandLine/requestSettlement.test.ts | 197 ++++++++++++++++++ .../service/commandLine/requestSettlement.ts | 131 ++++++++++++ 5 files changed, 401 insertions(+), 67 deletions(-) create mode 100644 chat2db-community-client/src/service/commandLine/requestSettlement.test.ts create mode 100644 chat2db-community-client/src/service/commandLine/requestSettlement.ts diff --git a/chat2db-community-client/package.json b/chat2db-community-client/package.json index d93ebd176c..6aac89347c 100644 --- a/chat2db-community-client/package.json +++ b/chat2db-community-client/package.json @@ -12,7 +12,7 @@ "build": "yarn run build:web:community", "build:prod": "yarn run build:web:community", "build:web": "umi build", - "prebuild:web:community": "yarn test:community-boundary && yarn test:database-capabilities && yarn test:table-editor && yarn test:retired-ai && yarn test:tree-title-highlight && yarn test:tree-search-lifecycle && yarn test:driver-upload && yarn test:tree-loading && yarn test:tree-node-lookup && yarn test:tree-node-path && yarn test:data-source-authorization && yarn test:active-transactions && yarn test:data-source-mutation-refresh && yarn test:ai-model-config && yarn test:ai-model-select && yarn test:export-connections && yarn test:main-page-navigation && yarn test:shortcut && yarn test:console-tab-name && yarn test:workspace-tab-scroll && yarn test:workspace-split-lifecycle && yarn test:sql-completion-context && yarn test:file-manager-label && yarn test:local-file-encoding && yarn test:local-file-save && yarn test:local-file-tree-path && yarn test:saved-console-dirty && yarn test:editor-close && yarn test:result-set-ui && yarn test:result-pagination && yarn test:sql-execution-stream && yarn test:result-table-layout && yarn test:result-status && yarn test:data-source-identity && yarn test:data-source-execution-snapshot && yarn test:data-source-watermark && yarn test:hot-update && yarn test:task-center && yarn test:application-exit && yarn test:monaco-lifecycle && yarn test:result-set-editor && yarn test:account-grants-request", + "prebuild:web:community": "yarn test:community-boundary && yarn test:database-capabilities && yarn test:table-editor && yarn test:retired-ai && yarn test:tree-title-highlight && yarn test:tree-search-lifecycle && yarn test:driver-upload && yarn test:tree-loading && yarn test:tree-node-lookup && yarn test:tree-node-path && yarn test:data-source-authorization && yarn test:active-transactions && yarn test:data-source-mutation-refresh && yarn test:ai-model-config && yarn test:ai-model-select && yarn test:export-connections && yarn test:main-page-navigation && yarn test:shortcut && yarn test:console-tab-name && yarn test:desktop-request-settlement && yarn test:workspace-tab-scroll && yarn test:workspace-split-lifecycle && yarn test:sql-completion-context && yarn test:file-manager-label && yarn test:local-file-encoding && yarn test:local-file-save && yarn test:local-file-tree-path && yarn test:saved-console-dirty && yarn test:editor-close && yarn test:result-set-ui && yarn test:result-pagination && yarn test:sql-execution-stream && yarn test:result-table-layout && yarn test:result-status && yarn test:data-source-identity && yarn test:data-source-execution-snapshot && yarn test:data-source-watermark && yarn test:hot-update && yarn test:task-center && yarn test:application-exit && yarn test:monaco-lifecycle && yarn test:result-set-editor && yarn test:account-grants-request", "postbuild:web:community": "node ./scripts/verify-production-bundles.cjs", "build:web:2java": "yarn run build:web:community && rm -rf ../chat2db-community-server/chat2db-community-start/src/main/resources/thymeleaf/* && cp -r dist/index.html ../chat2db-community-server/chat2db-community-start/src/main/resources/thymeleaf/", "build:web:community": "cross-env UMI_ENV=community cross-env APP_NAME=chat2db-community cross-env APP_VERSION=${npm_config_app_version} cross-env PRINT_LOGS=${npm_config_print_logs} cross-env APP_PORT=${npm_config_app_port} umi build", @@ -69,6 +69,7 @@ "test:sql-execution-log": "tsx src/service/sqlExecutionLog.test.ts", "test:sql-execution-batch": "tsx src/service/sqlExecutionBatch.test.ts", "test:data-source-execution-snapshot": "tsx src/service/dataSourceExecutionSnapshot.test.ts", + "test:desktop-request-settlement": "tsx src/service/commandLine/requestSettlement.test.ts", "test:sql-execution-request-tracker": "tsx src/service/sqlExecutionRequestTracker.test.ts", "test:sql-execution-stream": "tsx src/service/sqlExecutionStream.test.ts && tsx src/pages/main/workspace/components/SQLExecute/streamResultExecutionParams.test.ts", "test:result-set-editor": "tsx src/blocks/SearchResult/components/ResultSetTable/utils/editorType.test.ts && tsx src/blocks/CanvasTable/editor/SelectIEditor/index.test.ts && tsx src/blocks/CanvasTable/editor/MultiSelectIEditor/index.test.ts && tsx src/blocks/CanvasTable/editor/SelectIEditor/index.dom.test.ts", diff --git a/chat2db-community-client/src/hooks/useAbortRequest.ts b/chat2db-community-client/src/hooks/useAbortRequest.ts index f309f80536..88bb600b7f 100644 --- a/chat2db-community-client/src/hooks/useAbortRequest.ts +++ b/chat2db-community-client/src/hooks/useAbortRequest.ts @@ -4,8 +4,11 @@ import { useCallback, useMemo, useState } from 'react'; import { isDesktop } from '@/utils/env'; -import { DesktopRequestOptions, DesktopAbortControllerSignalParams } from '@/service/commandLine/commandLine'; -import { useGlobalStore } from '@/store/global'; +import { + DesktopRequestOptions, + DesktopAbortControllerSignalParams, + rejectCommandLineRequest, +} from '@/service/commandLine/commandLine'; type UseDiscardRequestReturnType = [(() => AbortSignal) | (() => DesktopRequestOptions['signal']), () => void]; @@ -20,10 +23,7 @@ const useAbortRequest: UseDiscardRequestType = () => { const abortRequest = useCallback(() => { if (isDesktop) { if (!desktopController) return; - // Remove the request id so that a later backend response is ignored by the frontend. - useGlobalStore.getState().removeCommandLineRequestListItem(desktopController.id); - // Promise requested by the terminal - desktopController.reject({ message: 'signal is aborted without reason' }); + rejectCommandLineRequest(desktopController.id, { message: 'signal is aborted without reason' }); return; } diff --git a/chat2db-community-client/src/service/commandLine/commandLine.ts b/chat2db-community-client/src/service/commandLine/commandLine.ts index 3d4968fa7a..8d36e92633 100644 --- a/chat2db-community-client/src/service/commandLine/commandLine.ts +++ b/chat2db-community-client/src/service/commandLine/commandLine.ts @@ -5,6 +5,11 @@ import { ErrorCodesWithoutToast } from '@/constants/request'; import interceptorsResponse from '@/service/interceptorsResponse'; import { IErrorLevel, PermissionError } from '@/service/base'; import { staticMessage } from '@chat2db/ui'; +import { + cleanupTrackedCommandLineRequest, + rejectTrackedCommandLineRequest, + settleTrackedCommandLineResponse, +} from './requestSettlement'; export interface ICommandLineRequest { requestUrl: string; @@ -49,6 +54,15 @@ export const TIMEOUT = 300000; // TODO: must be deleted // window._PRINT_LOGS = true; +const commandLineRequestRegistry = { + get: (id: string) => useGlobalStore.getState().commandLineRequestList[id], + remove: (id: string) => useGlobalStore.getState().removeCommandLineRequestListItem(id), + clearTimer: (timer: unknown) => clearTimeout(timer as ReturnType), +}; + +export const rejectCommandLineRequest = (id: string, reason: unknown) => + rejectTrackedCommandLineRequest(id, reason, commandLineRequestRegistry); + // JCEF request requestJCEF export const commandLineRequest = (data: ICommandLineRequest, options: IOptions) => { const language = useGlobalStore.getState().baseSetting.language; @@ -84,7 +98,7 @@ export const commandLineRequest = (data: ICommandLineRequest, options: IOptio requestTimeoutTimer = setTimeout(() => { const item = useGlobalStore.getState().commandLineRequestList[id]; if (item) { - useGlobalStore.getState().removeCommandLineRequestListItem(id); + cleanupTrackedCommandLineRequest(id, commandLineRequestRegistry); reject?.(`timeout_error:${item.requestData.requestUrl}`); } }, TIMEOUT); @@ -100,20 +114,29 @@ export const commandLineRequest = (data: ICommandLineRequest, options: IOptio }; useGlobalStore.getState().addCommandLineRequestListItem(commandLineRequestListItem); if (typeof window.javaQuery === 'function') { - window.javaQuery({ - request: JSON.stringify(res), - onSuccess: function (_data) { - // console.log('%cCHAT2DB_IPC_RESPONSE', 'color: #B8860B', _data); - pushMessageFlow(_data); - }, - onFailure: function (error_code, error_message) { - alert(error_message); - console.log('error', error_message); - reject(error_message); - }, - }); + try { + window.javaQuery({ + request: JSON.stringify(res), + onSuccess: function (_data) { + // console.log('%cCHAT2DB_IPC_RESPONSE', 'color: #B8860B', _data); + pushMessageFlow(_data); + }, + onFailure: function (error_code, error_message) { + try { + alert(error_message); + console.log('error', error_code, error_message); + } finally { + rejectCommandLineRequest(id, error_message); + } + }, + }); + } catch (error) { + rejectCommandLineRequest(id, error); + } } else { - console.error("JCEF's javaQuery is not available!"); + const error = new Error("JCEF's javaQuery is not available"); + console.error(error.message); + rejectCommandLineRequest(id, error); } }); }; @@ -124,7 +147,7 @@ export const pushMessageFlow = (_data) => { if (__PRINT_LOGS__ || window._PRINT_LOGS) { console.log('%cCHAT2DB_IPC_RESPONSE', 'color: #B8860B', new Date().toISOString(), data); } - const { setServiceStatus, commandLineRequestList, removeCommandLineRequestListItem } = useGlobalStore.getState(); + const { setServiceStatus } = useGlobalStore.getState(); // Special handling application startup if (data === 'CHAT2DB_IPC_RESPONSE_SERVICE_STATUS_SUCCESS') { @@ -133,52 +156,34 @@ export const pushMessageFlow = (_data) => { } // Only process logged requests - if (data?.uuid && commandLineRequestList?.[data.uuid]) { + if (data?.uuid) { const { message: messageData, uuid } = data; - - const { errorCode, success, errorMessage, errorDetail, solutionLink, eventualUrl } = messageData || {}; - - const { resolve, reject, options, requestData, requestTimeoutTimer } = commandLineRequestList[uuid]; - - // Clear timeout timer - if (requestTimeoutTimer) { - clearTimeout(requestTimeoutTimer); - } - - // response interception - responseInterceptor(messageData, requestData, options); - // Process request results - if (success) { - resolve?.(options.fullResponse ? messageData : messageData?.data); - } else { - reject({ - errorCode: errorCode, - errorMessage: errorMessage, - }); - // If there is no need to pop up the toast error code - if (ErrorCodesWithoutToast.includes(errorCode)) { - return; - } - switch (options.errorLevel) { - case 'toast': - staticMessage.error(errorMessage); - break; - case 'notification': - useGlobalStore?.getState()?.systemErrorMessageApi?.({ - errorCode, - errorMessage, - errorDetail, - solutionLink, - requestUrl: eventualUrl, - requestParams: JSON.stringify(requestData), - }); - break; - default: - break; - } - } - // Remove request record - removeCommandLineRequestListItem(uuid); + settleTrackedCommandLineResponse({ + requestId: uuid, + message: messageData || {}, + registry: commandLineRequestRegistry, + beforeSettle: (request, message) => responseInterceptor(message, request.requestData, request.options), + suppressErrorReport: (errorCode) => ErrorCodesWithoutToast.includes(errorCode as any), + reportError: (request, message) => { + switch (request.options.errorLevel) { + case 'toast': + staticMessage.error(message.errorMessage); + break; + case 'notification': + useGlobalStore?.getState()?.systemErrorMessageApi?.({ + errorCode: message.errorCode, + errorMessage: message.errorMessage, + errorDetail: message.errorDetail, + solutionLink: message.solutionLink, + requestUrl: message.eventualUrl, + requestParams: JSON.stringify(request.requestData), + }); + break; + default: + break; + } + }, + }); } }; diff --git a/chat2db-community-client/src/service/commandLine/requestSettlement.test.ts b/chat2db-community-client/src/service/commandLine/requestSettlement.test.ts new file mode 100644 index 0000000000..75e5a8962e --- /dev/null +++ b/chat2db-community-client/src/service/commandLine/requestSettlement.test.ts @@ -0,0 +1,197 @@ +import assert from 'node:assert/strict'; +import { + cleanupTrackedCommandLineRequest, + rejectTrackedCommandLineRequest, + settleCommandLineRequest, + settleTrackedCommandLineResponse, + type TrackedCommandLineRequest, +} from './requestSettlement'; + +interface TestRequest extends TrackedCommandLineRequest { + label: string; +} + +function registry(records: Record, clearedTimers: unknown[]) { + return { + get: (requestId: string) => records[requestId], + remove: (requestId: string) => { + delete records[requestId]; + }, + clearTimer: (timer: unknown) => clearedTimers.push(timer), + }; +} + +function testSilentFailureStillCleansUp() { + const calls: string[] = []; + const error = { errorCode: 'api.networkError' }; + + settleCommandLineRequest({ + beforeSettle: () => calls.push('intercept'), + success: false, + successValue: undefined, + error, + suppressErrorReport: true, + reject: (reason) => calls.push(reason === error ? 'reject' : 'wrong-error'), + reportError: () => calls.push('report'), + cleanup: () => calls.push('cleanup'), + }); + + assert.deepEqual(calls, ['intercept', 'reject', 'cleanup']); +} + +function testReportedFailureCleansUpAfterReporting() { + const calls: string[] = []; + settleCommandLineRequest({ + beforeSettle: () => calls.push('intercept'), + success: false, + successValue: undefined, + error: new Error('failed'), + suppressErrorReport: false, + reject: () => calls.push('reject'), + reportError: () => calls.push('report'), + cleanup: () => calls.push('cleanup'), + }); + assert.deepEqual(calls, ['intercept', 'reject', 'report', 'cleanup']); +} + +function testSuccessCleansUp() { + const calls: string[] = []; + settleCommandLineRequest({ + beforeSettle: () => calls.push('intercept'), + success: true, + successValue: 'data', + error: undefined, + suppressErrorReport: false, + resolve: (value) => calls.push(`resolve:${value}`), + reportError: () => calls.push('report'), + cleanup: () => calls.push('cleanup'), + }); + assert.deepEqual(calls, ['intercept', 'resolve:data', 'cleanup']); +} + +function testCleanupRunsWhenInterceptionThrows() { + const calls: string[] = []; + const failure = new Error('interceptor failed'); + settleCommandLineRequest({ + beforeSettle: () => { + throw failure; + }, + success: true, + successValue: undefined, + error: undefined, + suppressErrorReport: false, + reject: (reason) => calls.push(reason === failure ? 'reject' : 'wrong-error'), + reportError: () => calls.push('report'), + cleanup: () => calls.push('cleanup'), + }); + assert.deepEqual(calls, ['reject', 'cleanup']); +} + +function testTrackedSilentResponseClearsTimerAndRecord() { + const timer = Symbol('timer'); + const calls: string[] = []; + const clearedTimers: unknown[] = []; + const records: Record = { + silent: { + label: 'silent', + requestTimeoutTimer: timer, + requestData: { requestUrl: '/api/test' }, + options: { fullResponse: false }, + reject: (reason) => calls.push(`reject:${String((reason as { errorCode?: string }).errorCode)}`), + }, + }; + const requestRegistry = registry(records, clearedTimers); + const response = { + success: false, + errorCode: 'api.networkError', + errorMessage: 'offline', + }; + + assert.equal( + settleTrackedCommandLineResponse({ + requestId: 'silent', + message: response, + registry: requestRegistry, + beforeSettle: () => calls.push('intercept'), + suppressErrorReport: (errorCode) => errorCode === 'api.networkError', + reportError: () => calls.push('report'), + }), + true, + ); + assert.deepEqual(calls, ['intercept', 'reject:api.networkError']); + assert.deepEqual(clearedTimers, [timer]); + assert.equal(records.silent, undefined); + assert.equal( + settleTrackedCommandLineResponse({ + requestId: 'silent', + message: response, + registry: requestRegistry, + beforeSettle: () => calls.push('late-intercept'), + suppressErrorReport: () => true, + reportError: () => calls.push('late-report'), + }), + false, + 'duplicate late responses are ignored after the record is removed', + ); + assert.deepEqual(calls, ['intercept', 'reject:api.networkError']); +} + +function testNativeFailureRejectsAndRemovesTrackedRequest() { + const timer = Symbol('native-timer'); + const failure = new Error('native failure'); + const clearedTimers: unknown[] = []; + let rejected: unknown; + const records: Record = { + native: { + label: 'native', + requestTimeoutTimer: timer, + requestData: {}, + options: {}, + reject: (reason) => { + rejected = reason; + }, + }, + }; + const requestRegistry = registry(records, clearedTimers); + + assert.equal(rejectTrackedCommandLineRequest('native', failure, requestRegistry), true); + assert.equal(rejected, failure); + assert.deepEqual(clearedTimers, [timer]); + assert.deepEqual(records, {}); + assert.equal(rejectTrackedCommandLineRequest('native', failure, requestRegistry), false); +} + +function testExplicitCleanupRemovesRequestWhenTimerClearThrows() { + const records: Record = { + cleanup: { + label: 'cleanup', + requestTimeoutTimer: Symbol('timer'), + requestData: {}, + options: {}, + }, + }; + const clearFailure = new Error('clear failed'); + assert.throws( + () => + cleanupTrackedCommandLineRequest('cleanup', { + get: (requestId) => records[requestId], + remove: (requestId) => { + delete records[requestId]; + }, + clearTimer: () => { + throw clearFailure; + }, + }), + clearFailure, + ); + assert.deepEqual(records, {}); +} + +testSilentFailureStillCleansUp(); +testReportedFailureCleansUpAfterReporting(); +testSuccessCleansUp(); +testCleanupRunsWhenInterceptionThrows(); +testTrackedSilentResponseClearsTimerAndRecord(); +testNativeFailureRejectsAndRemovesTrackedRequest(); +testExplicitCleanupRemovesRequestWhenTimerClearThrows(); +console.log('Desktop request settlement tests passed'); diff --git a/chat2db-community-client/src/service/commandLine/requestSettlement.ts b/chat2db-community-client/src/service/commandLine/requestSettlement.ts new file mode 100644 index 0000000000..afbdcd0ffc --- /dev/null +++ b/chat2db-community-client/src/service/commandLine/requestSettlement.ts @@ -0,0 +1,131 @@ +export interface RequestSettlementOptions { + beforeSettle: () => void; + success: boolean; + successValue: T; + error: unknown; + suppressErrorReport: boolean; + resolve?: (value: T) => void; + reject?: (reason?: unknown) => void; + reportError: () => void; + cleanup: () => void; +} + +export interface TrackedCommandLineRequest { + requestTimeoutTimer?: unknown; + resolve?: (value: unknown) => void; + reject?: (reason?: unknown) => void; + options: { fullResponse?: boolean }; + requestData: unknown; +} + +export interface CommandLineRequestRegistry { + get: (requestId: string) => TRequest | undefined; + remove: (requestId: string) => void; + clearTimer: (timer: unknown) => void; +} + +export interface CommandLineResponseMessage { + success?: boolean; + data?: any; + errorCode?: any; + errorMessage?: string; + errorDetail?: any; + solutionLink?: string; + eventualUrl?: string; + [key: string]: unknown; +} + +export function settleCommandLineRequest(options: RequestSettlementOptions) { + try { + try { + options.beforeSettle(); + } catch (error) { + options.reject?.(error); + return; + } + if (options.success) { + options.resolve?.(options.successValue); + return; + } + + options.reject?.(options.error); + if (!options.suppressErrorReport) { + options.reportError(); + } + } finally { + options.cleanup(); + } +} + +function cleanupRequest( + requestId: string, + request: TRequest, + registry: CommandLineRequestRegistry, +) { + try { + if (request.requestTimeoutTimer !== null && request.requestTimeoutTimer !== undefined) { + registry.clearTimer(request.requestTimeoutTimer); + } + } finally { + registry.remove(requestId); + } +} + +export function cleanupTrackedCommandLineRequest( + requestId: string, + registry: CommandLineRequestRegistry, +) { + const request = registry.get(requestId); + if (!request) { + return false; + } + cleanupRequest(requestId, request, registry); + return true; +} + +export function rejectTrackedCommandLineRequest( + requestId: string, + reason: unknown, + registry: CommandLineRequestRegistry, +) { + const request = registry.get(requestId); + if (!request) { + return false; + } + try { + request.reject?.(reason); + } finally { + cleanupRequest(requestId, request, registry); + } + return true; +} + +export function settleTrackedCommandLineResponse(options: { + requestId: string; + message: CommandLineResponseMessage; + registry: CommandLineRequestRegistry; + beforeSettle: (request: TRequest, message: CommandLineResponseMessage) => void; + suppressErrorReport: (errorCode: unknown) => boolean; + reportError: (request: TRequest, message: CommandLineResponseMessage) => void; +}) { + const request = options.registry.get(options.requestId); + if (!request) { + return false; + } + + settleCommandLineRequest({ + beforeSettle: () => options.beforeSettle(request, options.message), + success: options.message.success === true, + successValue: request.options.fullResponse ? options.message : options.message.data, + error: { + errorCode: options.message.errorCode, + errorMessage: options.message.errorMessage, + }, + suppressErrorReport: options.suppressErrorReport(options.message.errorCode), + resolve: request.resolve, + reject: request.reject, + reportError: () => options.reportError(request, options.message), + cleanup: () => cleanupRequest(options.requestId, request, options.registry), + }); + return true; +}