fix: patch all P1 bugs from project review

- pubsub: add sender.isDestroyed() checks + destroyed event auto-cleanup (P1#10)
- pubsub: add .catch() to monitor() promise + isDestroyed check (P1#11)
- connection: move server.on('error') before server.listen() (P1#12)
- preload: onSubscribeMessage/onMonitorMessage return cleanup functions (P1#9)
- CliView: save and call IPC listener cleanup in onUnmounted (P1#9)
- KeyDetail: move setInterval into onMounted, cleanup in onUnmounted (P1#8)
- key store: add loadKeyVersion guard to prevent race condition (P1#13)
- ListEditor: LSET sentinel + LREM for index-based deletion (P1#14)
- ipc-handlers: remove non-null assertion, add null guard in reorderConnections (P1#16)
- StreamEditor: add Confirm/Cancel buttons to Trim dialog (P1#17)
- KeyBrowser: remove { immediate: true } to prevent double scan (P1#18)
- P1#7 MULTI/EXEC: investigated, not a bug (Redis server handles queuing)
- docs: update PROJECT_REVIEW.md with P1 fix status
This commit is contained in:
2026-07-12 14:18:48 +08:00
parent 9d73ff019c
commit 70db25b337
12 changed files with 112 additions and 61 deletions
+3 -2
View File
@@ -95,10 +95,11 @@ export function registerIpcHandlers(): void {
ipcMain.handle('storage:reorderConnections', (_e, ids: string[]) => {
const connections = store.get('connections', [])
const reordered = ids.map((id, i) => {
const conn = connections.find((c: ConnectionConfig) => c.id === id)!
const conn = connections.find((c: ConnectionConfig) => c.id === id)
if (!conn) return null
conn.order = i
return conn
})
}).filter((c): c is ConnectionConfig => c !== null)
store.set('connections', reordered)
return reordered
})
+5 -5
View File
@@ -93,15 +93,15 @@ async function createSshTunnel(opts: ConnectionOptions): Promise<{ localPort: nu
)
})
server.listen(0, '127.0.0.1', () => {
const localPort = (server.address() as AddressInfo).port
resolve({ localPort, sshClient, server })
})
server.on('error', (err) => {
sshClient.end()
reject(new Error(`SSH tunnel server error: ${err.message}`))
})
server.listen(0, '127.0.0.1', () => {
const localPort = (server.address() as AddressInfo).port
resolve({ localPort, sshClient, server })
})
})
sshClient.on('error', (err: Error) => {
+11 -5
View File
@@ -19,20 +19,22 @@ export function startSubscribe(
const sub = new Redis(existing.options)
subClients.set(id, sub)
sender.once('destroyed', () => stopSubscribe(id))
if (isPattern) {
sub.psubscribe(...channels)
sub.on('pmessage', (_pattern, channel, message) => {
sender.send('redis:subscribeMessage', { channel, message, pattern: _pattern })
if (!sender.isDestroyed()) sender.send('redis:subscribeMessage', { channel, message, pattern: _pattern })
})
} else {
sub.subscribe(...channels)
sub.on('message', (channel, message) => {
sender.send('redis:subscribeMessage', { channel, message })
if (!sender.isDestroyed()) sender.send('redis:subscribeMessage', { channel, message })
})
}
sub.on('error', (err) => {
sender.send('redis:subscribeMessage', { error: err.message })
if (!sender.isDestroyed()) sender.send('redis:subscribeMessage', { error: err.message })
})
}
@@ -54,13 +56,17 @@ export function startMonitor(id: string, sender: WebContents): void {
const monitor = new Redis(existing.options)
monitorClients.set(id, monitor)
sender.once('destroyed', () => stopMonitor(id))
monitor.monitor().then((mon) => {
mon.on('monitor', (time: string, args: string[], source: string, database: number) => {
sender.send('redis:monitorMessage', { time, args, source, database })
if (!sender.isDestroyed()) sender.send('redis:monitorMessage', { time, args, source, database })
})
mon.on('error', (err: Error) => {
sender.send('redis:monitorMessage', { error: err.message })
if (!sender.isDestroyed()) sender.send('redis:monitorMessage', { error: err.message })
})
}).catch(err => {
if (!sender.isDestroyed()) sender.send('redis:monitorMessage', { error: err.message })
})
}
+2 -2
View File
@@ -125,11 +125,11 @@ export interface ElectronAPI {
// Pub/Sub
subscribe: (id: string, channels: string[], isPattern: boolean) => Promise<any>
unsubscribe: (id: string) => Promise<void>
onSubscribeMessage: (callback: (msg: any) => void) => void
onSubscribeMessage: (callback: (msg: any) => void) => () => void
// Monitor
monitor: (id: string) => Promise<any>
monitorStop: (id: string) => Promise<void>
onMonitorMessage: (callback: (msg: any) => void) => void
onMonitorMessage: (callback: (msg: any) => void) => () => void
}
updater: {
check: () => Promise<any>
+6 -2
View File
@@ -151,7 +151,9 @@ const electronAPI = {
unsubscribe: (id: string): Promise<void> =>
ipcRenderer.invoke('redis:unsubscribe', id),
onSubscribeMessage: (callback: (msg: any) => void) => {
ipcRenderer.on('redis:subscribeMessage', (_e, msg) => callback(msg))
const handler = (_e: any, msg: any) => callback(msg)
ipcRenderer.on('redis:subscribeMessage', handler)
return () => ipcRenderer.removeListener('redis:subscribeMessage', handler)
},
// Monitor
monitor: (id: string): Promise<any> =>
@@ -159,7 +161,9 @@ const electronAPI = {
monitorStop: (id: string): Promise<void> =>
ipcRenderer.invoke('redis:monitorStop', id),
onMonitorMessage: (callback: (msg: any) => void) => {
ipcRenderer.on('redis:monitorMessage', (_e, msg) => callback(msg))
const handler = (_e: any, msg: any) => callback(msg)
ipcRenderer.on('redis:monitorMessage', handler)
return () => ipcRenderer.removeListener('redis:monitorMessage', handler)
},
},
updater: {
@@ -81,11 +81,14 @@ async function saveEdit() {
}
}
async function removeItem(item: string) {
async function removeItem(index: number) {
if (!connStore.activeId || !keyStore.selectedKey) return
try {
await ElMessageBox.confirm(t('editor.removeElementConfirm'), t('common.confirm'))
await window.electronAPI.redis.listRemove(connStore.activeId, keyStore.selectedKey, 1, item)
// Use LSET + LREM sentinel pattern to delete by index instead of by value
const sentinel = `\x00__JREDM_DEL_${Date.now()}_${Math.random()}__`
await window.electronAPI.redis.listSet(connStore.activeId, keyStore.selectedKey, pageStart + index, sentinel)
await window.electronAPI.redis.listRemove(connStore.activeId, keyStore.selectedKey, 1, sentinel)
await loadList()
ElMessage.success(t('editor.removed'))
} catch (err: any) {
@@ -127,7 +130,7 @@ function nextPage() {
<span class="item-value">{{ item }}</span>
<div class="item-actions">
<el-button size="small" text @click="startEdit(index)">{{ t('editor.edit') }}</el-button>
<el-button size="small" type="danger" text @click="removeItem(item)">{{ t('common.delete') }}</el-button>
<el-button size="small" type="danger" text @click="removeItem(index)">{{ t('common.delete') }}</el-button>
</div>
</template>
</div>
@@ -358,6 +358,10 @@ async function trimStream() {
<el-form-item :label="t('editor.maxLength')">
<el-input-number v-model="trimMaxLen" :min="1" />
</el-form-item>
<template #footer>
<el-button @click="trimming = false">{{ t('common.cancel') }}</el-button>
<el-button type="primary" @click="trimStream()">{{ t('common.confirm') }}</el-button>
</template>
</el-dialog>
</div>
</template>
@@ -16,8 +16,7 @@ watch(
keyStore.keyData = null
await keyStore.scanKeys(newId, '0', true)
}
},
{ immediate: true }
}
)
</script>
+28 -21
View File
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed, ref, watch, onUnmounted } from 'vue'
import { computed, ref, watch, onMounted, onUnmounted } from 'vue'
import { useKeyStore } from '@renderer/stores/key'
import { useConnectionStore } from '@renderer/stores/connection'
import { useI18n } from '@renderer/i18n'
@@ -40,31 +40,38 @@ watch(() => keyStore.selectedTTL, (v) => {
liveTTL.value = v
})
let ttlTimer: ReturnType<typeof setInterval> | null = setInterval(async () => {
if (liveTTL.value > 0) {
liveTTL.value--
if (liveTTL.value === 0) {
const connId = connStore.activeId
const key = keyStore.selectedKey
if (!connId || !key) return
const exists = await window.electronAPI.redis.existsKey(connId, key)
// 用户可能已切换 key,跳过过期检查
if (keyStore.selectedKey !== key) return
if (!exists) {
keyStore.selectedType = 'none'
} else {
// key 仍存在(TTL 可能被其他客户端刷新),同步实际 TTL
const ttl = await window.electronAPI.redis.getKeyTTL(connId, key)
let ttlTimer: ReturnType<typeof setInterval> | null = null
onMounted(() => {
ttlTimer = setInterval(async () => {
if (liveTTL.value > 0) {
liveTTL.value--
if (liveTTL.value === 0) {
const connId = connStore.activeId
const key = keyStore.selectedKey
if (!connId || !key) return
const exists = await window.electronAPI.redis.existsKey(connId, key)
// 用户可能已切换 key,跳过过期检查
if (keyStore.selectedKey !== key) return
keyStore.selectedTTL = ttl
liveTTL.value = ttl
if (!exists) {
keyStore.selectedType = 'none'
} else {
// key 仍存在(TTL 可能被其他客户端刷新),同步实际 TTL
const ttl = await window.electronAPI.redis.getKeyTTL(connId, key)
if (keyStore.selectedKey !== key) return
keyStore.selectedTTL = ttl
liveTTL.value = ttl
}
}
}
}
}, 1000)
}, 1000)
})
onUnmounted(() => {
if (ttlTimer) clearInterval(ttlTimer)
if (ttlTimer) {
clearInterval(ttlTimer)
ttlTimer = null
}
})
const ttlDisplay = computed(() => {
@@ -377,9 +377,12 @@ function onClearCli() {
history.value = []
}
let unsubscribeMessageCleanup: (() => void) | null = null
let monitorMessageCleanup: (() => void) | null = null
onMounted(() => {
window.electronAPI.redis.onSubscribeMessage(onSubscribeMessage)
window.electronAPI.redis.onMonitorMessage(onMonitorMessage)
unsubscribeMessageCleanup = window.electronAPI.redis.onSubscribeMessage(onSubscribeMessage)
monitorMessageCleanup = window.electronAPI.redis.onMonitorMessage(onMonitorMessage)
window.addEventListener('clear-cli', onClearCli)
})
@@ -387,6 +390,8 @@ onUnmounted(() => {
if (connStore.activeId && streamingMode.value !== 'none') {
stopStreaming()
}
unsubscribeMessageCleanup?.()
monitorMessageCleanup?.()
window.removeEventListener('clear-cli', onClearCli)
})
</script>
+11
View File
@@ -132,10 +132,14 @@ export const useKeyStore = defineStore('key', () => {
selectedTTL.value = ttl
}
let loadKeyVersion = 0
async function loadKeyData(connId: string, key: string) {
const currentVersion = ++loadKeyVersion
loading.value = true
try {
await loadKeyTypeAndTTL(connId, key)
if (currentVersion !== loadKeyVersion) return
selectedKey.value = key
// Key no longer exists — show expired state and refresh list
@@ -160,29 +164,35 @@ export const useKeyStore = defineStore('key', () => {
break
case 'hash': {
const result = await window.electronAPI.redis.hashScan(connId, key, '0', 100)
if (currentVersion !== loadKeyVersion) return
keyData.value = result.fields
break
}
case 'list': {
const len = await window.electronAPI.redis.listLength(connId, key)
if (currentVersion !== loadKeyVersion) return
keyData.value = { length: len }
break
}
case 'set': {
const size = await window.electronAPI.redis.setSize(connId, key)
if (currentVersion !== loadKeyVersion) return
keyData.value = { size }
break
}
case 'zset': {
const size = await window.electronAPI.redis.zsetSize(connId, key)
if (currentVersion !== loadKeyVersion) return
keyData.value = { size }
break
}
case 'stream': {
try {
const info = await window.electronAPI.redis.streamInfo(connId, key)
if (currentVersion !== loadKeyVersion) return
keyData.value = { length: info.length }
} catch {
if (currentVersion !== loadKeyVersion) return
keyData.value = { length: 0 }
}
break
@@ -195,6 +205,7 @@ export const useKeyStore = defineStore('key', () => {
// command (e.g. key was overwritten). Re-fetch the type so the
// correct editor is shown.
await loadKeyTypeAndTTL(connId, key)
if (currentVersion !== loadKeyVersion) return
keyData.value = null
}
} finally {