From 1704b196cf6fbac486e347ba819e6384a91476d4 Mon Sep 17 00:00:00 2001 From: Augustin Date: Fri, 24 Apr 2026 15:41:01 +0200 Subject: [PATCH] fix(terminal): refactor WebSocket cleanup, buffer management, and disposal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add proper disposal tracking to prevent memory leaks - Move terminal buffer from localStorage to sessionStorage - Restore buffer immediately after first WS message - Fix clear detection logic and error handling - Add signal parameter support for abortable fetch requests 💘 Generated with Crush Assisted-by: MiniMax-M2.7 via Crush --- internal/api/terminal.go | 2 - web/src/api/client.js | 3 +- web/src/components/Shell.jsx | 152 +++++++++++++++++++++-------------- 3 files changed, 92 insertions(+), 65 deletions(-) diff --git a/internal/api/terminal.go b/internal/api/terminal.go index 236f794..3b3018a 100644 --- a/internal/api/terminal.go +++ b/internal/api/terminal.go @@ -165,8 +165,6 @@ func (s *Server) handleTerminalWS(w http.ResponseWriter, r *http.Request) { n, err := ptmx.Read(buf) if err != nil { cleanup() - conn.WriteMessage(websocket.CloseMessage, - websocket.FormatCloseMessage(websocket.CloseNormalClosure, "")) return } if err := conn.WriteJSON(wsMessage{ diff --git a/web/src/api/client.js b/web/src/api/client.js index 0946736..3845dea 100644 --- a/web/src/api/client.js +++ b/web/src/api/client.js @@ -105,7 +105,7 @@ const api = { }).catch(reject) }) }, - sendShellChat: (message, context = {}, stream = true, onChunk) => { + sendShellChat: (message, context = {}, stream = true, onChunk, signal) => { const payload = { message, cwd: context.cwd || '', @@ -120,6 +120,7 @@ const api = { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload), + signal, }).then(async (res) => { if (!res.ok) { const err = await res.json().catch(() => ({ error: res.statusText })) diff --git a/web/src/components/Shell.jsx b/web/src/components/Shell.jsx index ce4da13..c24ebf8 100644 --- a/web/src/components/Shell.jsx +++ b/web/src/components/Shell.jsx @@ -149,7 +149,7 @@ function createTerminal(container, settings = {}) { return { term, fitAddon } } -function connectWebSocket(term, fitAddon, initPayload, onStateChange) { +function connectWebSocket(term, fitAddon, initPayload, onStateChange, onFirstMessage) { const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:' const ws = new WebSocket(`${proto}//${window.location.host}/api/ws/terminal`) @@ -162,7 +162,12 @@ function connectWebSocket(term, fitAddon, initPayload, onStateChange) { if (onStateChange) onStateChange(true) }) + let firstMessage = true ws.addEventListener('message', (event) => { + if (firstMessage) { + firstMessage = false + if (onFirstMessage) onFirstMessage() + } try { const msg = JSON.parse(event.data) if (msg.type === 'output') { @@ -185,12 +190,6 @@ function connectWebSocket(term, fitAddon, initPayload, onStateChange) { if (onStateChange) onStateChange(false) }) - term.onData((data) => { - if (ws.readyState === WebSocket.OPEN) { - ws.send(JSON.stringify({ type: 'input', data })) - } - }) - term.onResize(({ rows, cols }) => { if (ws.readyState === WebSocket.OPEN) { ws.send(JSON.stringify({ type: 'resize', rows, cols })) @@ -256,6 +255,7 @@ export default function Shell({ api }) { const [analysisContent, setAnalysisContent] = useState('') const aiMessagesRef = useRef(null) const aiLoadedRef = useRef(false) + const aiLoadingRef = useRef(false) useEffect(() => { aiMessagesRef.current?.scrollTo(0, aiMessagesRef.current.scrollHeight) @@ -338,23 +338,7 @@ export default function Shell({ api }) { } } - const onWsState = (connected) => { - if (!connected) saveBuffer() - setTabs(prev => prev.map(t => t.id === tabId ? { ...t, connected } : t)) - } - const ws = connectWebSocket(term, fitAddon, initPayload, onWsState) - - // Restore saved terminal buffer after first output settles - const restoreBuffer = () => { - try { - const savedBuffers = JSON.parse(localStorage.getItem(TERMINAL_BUFFER_KEY) || '{}') - if (savedBuffers[tabId]) { - term.write('\x1b[90m— session restaurée —\x1b[0m\r\n') - term.write(savedBuffers[tabId]) - } - } catch {} - } - setTimeout(restoreBuffer, 300) + let disposed = false const saveBuffer = () => { try { @@ -364,33 +348,50 @@ export default function Shell({ api }) { const line = buf.getLine(i) if (line) lines.push(line.translateToString(true)) } - const savedBuffers = JSON.parse(localStorage.getItem(TERMINAL_BUFFER_KEY) || '{}') + const savedBuffers = JSON.parse(sessionStorage.getItem(TERMINAL_BUFFER_KEY) || '{}') savedBuffers[tabId] = lines.join('\n') - localStorage.setItem(TERMINAL_BUFFER_KEY, JSON.stringify(savedBuffers)) - } catch {} + sessionStorage.setItem(TERMINAL_BUFFER_KEY, JSON.stringify(savedBuffers)) + } catch (e) { console.warn('[Shell] Buffer save failed:', e) } } - const bufferSaveInterval = setInterval(saveBuffer, 5000) + const onWsState = (connected) => { + if (disposed) return + if (!connected) saveBuffer() + setTabs(prev => prev.map(t => t.id === tabId ? { ...t, connected } : t)) + } + + const restoreBuffer = () => { + try { + const savedBuffers = JSON.parse(sessionStorage.getItem(TERMINAL_BUFFER_KEY) || '{}') + if (savedBuffers[tabId]) { + term.write('\x1b[90m— session restaurée —\x1b[0m\r\n') + term.write(savedBuffers[tabId]) + } + } catch (e) { console.warn('[Shell] Buffer restore failed:', e) } + } + + const ws = connectWebSocket(term, fitAddon, initPayload, onWsState, restoreBuffer) const clearBufferOnClear = () => { try { const buf = term.buffer.active - const lineY = buf.viewportY + buf.cursorY + const lineY = buf.length - 1 const line = buf.getLine(lineY) if (line) { const text = line.translateToString(true).trim().toLowerCase() if (text === 'clear' || text === '$ clear' || text.endsWith(' clear')) { - const savedBuffers = JSON.parse(localStorage.getItem(TERMINAL_BUFFER_KEY) || '{}') + const savedBuffers = JSON.parse(sessionStorage.getItem(TERMINAL_BUFFER_KEY) || '{}') delete savedBuffers[tabId] - localStorage.setItem(TERMINAL_BUFFER_KEY, JSON.stringify(savedBuffers)) + sessionStorage.setItem(TERMINAL_BUFFER_KEY, JSON.stringify(savedBuffers)) } } - } catch {} + } catch (e) { console.warn('[Shell] Clear detection failed:', e) } } term.onData((data) => { - if (data === '\r') { - clearBufferOnClear() + if (data === '\r') clearBufferOnClear() + if (ws.readyState === WebSocket.OPEN) { + ws.send(JSON.stringify({ type: 'input', data })) } }) @@ -405,35 +406,47 @@ export default function Shell({ api }) { resizeObserver.observe(container) window.addEventListener('resize', onResize) - tabsRef.current[tabId] = { term, fitAddon, ws, resizeObserver, onResize, bufferSaveInterval, saveBuffer } + const bufferSaveInterval = setInterval(() => { if (!disposed) saveBuffer() }, 5000) + + tabsRef.current[tabId] = { term, fitAddon, ws, resizeObserver, onResize, bufferSaveInterval, saveBuffer, disposed: () => disposed } + const origDispose = () => { disposed = true } + tabsRef.current[tabId]._markDisposed = origDispose }, []) useEffect(() => { const tab = tabs.find(t => t.id === activeTab) if (!tab) return + let cancelled = false + const pending = [] + const tryInit = (attempt) => { - if (attempt > 20) return + if (cancelled || attempt > 20) return const shellCol = document.querySelector('.shell-terminal-col') if (!shellCol || shellCol.offsetParent === null) { - setTimeout(() => tryInit(attempt + 1), 150) + pending.push(setTimeout(() => tryInit(attempt + 1), 150)) return } const container = document.getElementById(`terminal-${tab.id}`) if (!container || container.offsetHeight === 0) { - setTimeout(() => tryInit(attempt + 1), 100) + pending.push(setTimeout(() => tryInit(attempt + 1), 100)) return } if (!tabsRef.current[tab.id]) { initTerminal(tab.id, tab) } requestAnimationFrame(() => { + if (cancelled) return const entry = tabsRef.current[tab.id] if (entry) entry.fitAddon.fit() }) } tryInit(0) + return () => { + cancelled = true + pending.forEach(clearTimeout) + } }, [activeTab, tabs, initTerminal]) useEffect(() => { @@ -451,6 +464,20 @@ export default function Shell({ api }) { return () => clearInterval(iv) }, [tabs]) + useEffect(() => { + return () => { + for (const [tabId, entry] of Object.entries(tabsRef.current)) { + entry._markDisposed?.() + if (entry.bufferSaveInterval) clearInterval(entry.bufferSaveInterval) + window.removeEventListener('resize', entry.onResize) + entry.resizeObserver?.disconnect() + entry.ws?.close() + entry.term?.dispose() + } + tabsRef.current = {} + } + }, []) + useEffect(() => { const onKey = (e) => { if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return @@ -506,29 +533,26 @@ export default function Shell({ api }) { const closeTab = (tabId, e) => { if (e) e.stopPropagation() + const entry = tabsRef.current[tabId] + if (entry) { + entry._markDisposed?.() + entry.saveBuffer?.() + if (entry.bufferSaveInterval) clearInterval(entry.bufferSaveInterval) + window.removeEventListener('resize', entry.onResize) + entry.resizeObserver.disconnect() + entry.ws.close() + entry.term.dispose() + delete tabsRef.current[tabId] + } + + try { + const savedBuffers = JSON.parse(sessionStorage.getItem(TERMINAL_BUFFER_KEY) || '{}') + delete savedBuffers[tabId] + sessionStorage.setItem(TERMINAL_BUFFER_KEY, JSON.stringify(savedBuffers)) + } catch (e) { console.warn('[Shell] Buffer cleanup failed:', e) } + setTabs(prev => { if (prev.length <= 1) return prev - const tab = prev.find(t => t.id === tabId) - if (!tab) return prev - - const entry = tabsRef.current[tabId] - if (entry) { - entry.saveBuffer?.() - if (entry.bufferSaveInterval) clearInterval(entry.bufferSaveInterval) - window.removeEventListener('resize', entry.onResize) - entry.resizeObserver.disconnect() - entry.ws.close() - entry.term.dispose() - delete tabsRef.current[tabId] - } - - // Clean buffer from localStorage - try { - const savedBuffers = JSON.parse(localStorage.getItem(TERMINAL_BUFFER_KEY) || '{}') - delete savedBuffers[tabId] - localStorage.setItem(TERMINAL_BUFFER_KEY, JSON.stringify(savedBuffers)) - } catch {} - const next = prev.filter(t => t.id !== tabId) if (activeTab === tabId && next.length > 0) { setActiveTab(next[next.length - 1].id) @@ -593,8 +617,9 @@ export default function Shell({ api }) { }, [activeTab]) const _sendAiMessage = useCallback(async (text, fromEvent = false) => { - if (!text || !text.trim() || aiLoading || aiAtLimit) return + if (!text || !text.trim() || aiLoadingRef.current || aiAtLimit) return const trimmed = text.trim() + aiLoadingRef.current = true if (!fromEvent) { setAiInput('') @@ -608,6 +633,7 @@ export default function Shell({ api }) { setAiTokens(0) setAiAtLimit(false) } catch {} + aiLoadingRef.current = false return } @@ -616,6 +642,7 @@ export default function Shell({ api }) { { role: 'user', content: trimmed }, { role: 'assistant', content: 'Commandes disponibles:\n• /clear — Effacer la conversation\n• /help — Afficher l\'aide\n\nJe ne peux pas exécuter de code. Les blocs de code proposés peuvent être copiés ou envoyés directement au terminal actif.' } ]) + aiLoadingRef.current = false return } @@ -641,13 +668,14 @@ export default function Shell({ api }) { setAiAtLimit(d.at_limit || false) }).catch(() => {}) } catch (err) { - if (err.message.includes('context limit')) { + if (err.message?.includes('context limit')) { setAiAtLimit(true) } setAiMessages(prev => [...prev.filter(m => !m._streaming), { role: 'assistant', content: `Erreur: ${err.message}` }]) } setAiLoading(false) - }, [api, t, aiLoading, aiAtLimit, focusAiTerminal]) + aiLoadingRef.current = false + }, [api, t, aiAtLimit, focusAiTerminal]) const handleAiSend = () => _sendAiMessage(aiInput, false)