From 446e995063a4f76883bd27b46e003f9a0b9eb767 Mon Sep 17 00:00:00 2001 From: t8y2 <1156263951@qq.com> Date: Mon, 13 Jul 2026 20:03:04 +0800 Subject: [PATCH] fix(mysql): harden process list refresh and kill handling --- .../src/components/admin/MySqlProcessList.vue | 13 +++++-- .../database/mysqlProcessList.spec.ts | 29 ++++++++++++++- .../src/lib/database/mysqlProcessList.ts | 36 +++++++++++++++++++ 3 files changed, 74 insertions(+), 4 deletions(-) diff --git a/apps/desktop/src/components/admin/MySqlProcessList.vue b/apps/desktop/src/components/admin/MySqlProcessList.vue index 4cb6f35788..864347c2b0 100644 --- a/apps/desktop/src/components/admin/MySqlProcessList.vue +++ b/apps/desktop/src/components/admin/MySqlProcessList.vue @@ -11,7 +11,7 @@ import { useToast } from "@/composables/useToast"; import type { ConnectionConfig } from "@/types/database"; import * as api from "@/lib/backend/api"; import { executeWithProductionSqlGuard } from "@/lib/database/productionExecutionGuard"; -import { buildKillSql, clampInterval, DEFAULT_REFRESH_SECONDS, mapProcessRows, PROCESS_LIST_SQL, type ProcessRow } from "@/lib/database/mysqlProcessList"; +import { buildKillSql, clampInterval, createProcessListLoadCoordinator, DEFAULT_REFRESH_SECONDS, mapProcessRows, processListExecutionError, processListSessionCount, PROCESS_LIST_SQL, type ProcessRow } from "@/lib/database/mysqlProcessList"; const props = defineProps<{ connection: ConnectionConfig; @@ -24,8 +24,10 @@ const { toast } = useToast(); type SortKey = keyof ProcessRow; const rows = ref([]); +const truncated = ref(false); const ownSessionId = ref(null); const loading = ref(false); +const loadCoordinator = createProcessListLoadCoordinator(); const loadError = ref(""); const search = ref(""); const sortKey = ref("time"); @@ -75,7 +77,7 @@ function toggleSort(key: SortKey) { } async function load(options: { silent?: boolean } = {}) { - if (loading.value) return; + if (!loadCoordinator.tryStart()) return; if (!options.silent) loading.value = true; loadError.value = ""; try { @@ -92,10 +94,12 @@ async function load(options: { silent?: boolean } = {}) { } const result = await api.executeQuery(props.connection.id, "", PROCESS_LIST_SQL, undefined, undefined, { maxRows: 5000 }); rows.value = mapProcessRows(result); + truncated.value = result.truncated === true; } catch (error: any) { loadError.value = error?.message || String(error); } finally { loading.value = false; + loadCoordinator.finish(); } } @@ -122,6 +126,8 @@ async function confirmKill() { execute: () => api.executeMulti(props.connection.id, "", killSql, undefined, undefined, { maxRows: 1 }), }); if (result === undefined) return; + const executionError = processListExecutionError(result); + if (executionError) throw new Error(executionError); toast(t("processList.killSuccess", { id: target.id }), 2500); killTarget.value = null; await load({ silent: true }); @@ -165,6 +171,7 @@ watch( () => props.connection.id, () => { rows.value = []; + truncated.value = false; ownSessionId.value = null; search.value = ""; void load(); @@ -182,7 +189,7 @@ onBeforeUnmount(stopTimer);
{{ t("processList.title") }}
{{ connection.name }} - {{ t("processList.sessionCount", { count: filteredRows.length }) }} + {{ t("processList.sessionCount", { count: processListSessionCount(truncated ? rows.length : filteredRows.length, truncated) }) }}
diff --git a/apps/desktop/src/lib/__tests__/database/mysqlProcessList.spec.ts b/apps/desktop/src/lib/__tests__/database/mysqlProcessList.spec.ts index 4d556be5f8..43c2ada939 100644 --- a/apps/desktop/src/lib/__tests__/database/mysqlProcessList.spec.ts +++ b/apps/desktop/src/lib/__tests__/database/mysqlProcessList.spec.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; import type { QueryResult } from "@/types/database"; -import { buildKillSql, clampInterval, mapProcessRows, supportsProcessList } from "@/lib/database/mysqlProcessList"; +import { buildKillSql, clampInterval, createProcessListLoadCoordinator, mapProcessRows, processListExecutionError, processListSessionCount, supportsProcessList } from "@/lib/database/mysqlProcessList"; function result(columns: string[], rows: (string | number | boolean | null)[][]): QueryResult { return { columns, rows, affected_rows: 0, execution_time_ms: 0 }; @@ -63,6 +63,33 @@ describe("clampInterval", () => { }); }); +describe("createProcessListLoadCoordinator", () => { + it("prevents overlapping manual and timer-driven refreshes", () => { + const coordinator = createProcessListLoadCoordinator(); + expect(coordinator.tryStart()).toBe(true); + expect(coordinator.tryStart()).toBe(false); + coordinator.finish(); + expect(coordinator.tryStart()).toBe(true); + }); +}); + +describe("processListExecutionError", () => { + it("returns the message from a synthesized execution error", () => { + expect(processListExecutionError([{ ...result(["Error"], [["command denied"]]), execution_error: true }])).toBe("command denied"); + }); + + it("does not treat a successful Error column as a failure", () => { + expect(processListExecutionError([result(["Error"], [["application value"]])])).toBeNull(); + }); +}); + +describe("processListSessionCount", () => { + it("marks truncated counts as a lower bound", () => { + expect(processListSessionCount(5000, true)).toBe("5000+"); + expect(processListSessionCount(42, false)).toBe(42); + }); +}); + describe("supportsProcessList", () => { it("is limited to connections using the MySQL driver type", () => { expect(supportsProcessList("mysql")).toBe(true); diff --git a/apps/desktop/src/lib/database/mysqlProcessList.ts b/apps/desktop/src/lib/database/mysqlProcessList.ts index 81b59f19ef..def833f004 100644 --- a/apps/desktop/src/lib/database/mysqlProcessList.ts +++ b/apps/desktop/src/lib/database/mysqlProcessList.ts @@ -26,6 +26,11 @@ export const MIN_REFRESH_SECONDS = 1; export const MAX_REFRESH_SECONDS = 3600; export const DEFAULT_REFRESH_SECONDS = 5; +export interface ProcessListLoadCoordinator { + tryStart(): boolean; + finish(): void; +} + export interface ProcessRow { id: number; user: string; @@ -37,6 +42,37 @@ export interface ProcessRow { info: string | null; } +/** + * Keep manual and timer-driven refreshes on the same single-flight guard. Slow + * servers must not accumulate process-list queries faster than they complete. + */ +export function createProcessListLoadCoordinator(): ProcessListLoadCoordinator { + let inFlight = false; + return { + tryStart() { + if (inFlight) return false; + inFlight = true; + return true; + }, + finish() { + inFlight = false; + }, + }; +} + +/** Return the server-provided message for the first failed batch statement. */ +export function processListExecutionError(results: QueryResult[]): string | null { + const failed = results.find((result) => result.execution_error === true); + if (!failed) return null; + const message = failed.rows?.[0]?.[0]; + return message === null || message === undefined || String(message).length === 0 ? "Query execution failed" : String(message); +} + +/** A truncated result only establishes a lower bound for the session count. */ +export function processListSessionCount(count: number, truncated: boolean): number | string { + return truncated ? `${count}+` : count; +} + function columnIndex(columns: string[], name: string): number { const target = name.toLowerCase(); return columns.findIndex((column) => column.toLowerCase() === target);