fix(terminal): refactor WebSocket cleanup, buffer management, and disposal
All checks were successful
Beta Release / beta (push) Successful in 52s

- 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 <crush@charm.land>
This commit is contained in:
Augustin
2026-04-24 15:41:01 +02:00
parent 40ec493bae
commit 1704b196cf
3 changed files with 92 additions and 65 deletions

View File

@@ -165,8 +165,6 @@ func (s *Server) handleTerminalWS(w http.ResponseWriter, r *http.Request) {
n, err := ptmx.Read(buf) n, err := ptmx.Read(buf)
if err != nil { if err != nil {
cleanup() cleanup()
conn.WriteMessage(websocket.CloseMessage,
websocket.FormatCloseMessage(websocket.CloseNormalClosure, ""))
return return
} }
if err := conn.WriteJSON(wsMessage{ if err := conn.WriteJSON(wsMessage{

View File

@@ -105,7 +105,7 @@ const api = {
}).catch(reject) }).catch(reject)
}) })
}, },
sendShellChat: (message, context = {}, stream = true, onChunk) => { sendShellChat: (message, context = {}, stream = true, onChunk, signal) => {
const payload = { const payload = {
message, message,
cwd: context.cwd || '', cwd: context.cwd || '',
@@ -120,6 +120,7 @@ const api = {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload), body: JSON.stringify(payload),
signal,
}).then(async (res) => { }).then(async (res) => {
if (!res.ok) { if (!res.ok) {
const err = await res.json().catch(() => ({ error: res.statusText })) const err = await res.json().catch(() => ({ error: res.statusText }))

View File

@@ -149,7 +149,7 @@ function createTerminal(container, settings = {}) {
return { term, fitAddon } 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 proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
const ws = new WebSocket(`${proto}//${window.location.host}/api/ws/terminal`) 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) if (onStateChange) onStateChange(true)
}) })
let firstMessage = true
ws.addEventListener('message', (event) => { ws.addEventListener('message', (event) => {
if (firstMessage) {
firstMessage = false
if (onFirstMessage) onFirstMessage()
}
try { try {
const msg = JSON.parse(event.data) const msg = JSON.parse(event.data)
if (msg.type === 'output') { if (msg.type === 'output') {
@@ -185,12 +190,6 @@ function connectWebSocket(term, fitAddon, initPayload, onStateChange) {
if (onStateChange) onStateChange(false) if (onStateChange) onStateChange(false)
}) })
term.onData((data) => {
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: 'input', data }))
}
})
term.onResize(({ rows, cols }) => { term.onResize(({ rows, cols }) => {
if (ws.readyState === WebSocket.OPEN) { if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: 'resize', rows, cols })) ws.send(JSON.stringify({ type: 'resize', rows, cols }))
@@ -256,6 +255,7 @@ export default function Shell({ api }) {
const [analysisContent, setAnalysisContent] = useState('') const [analysisContent, setAnalysisContent] = useState('')
const aiMessagesRef = useRef(null) const aiMessagesRef = useRef(null)
const aiLoadedRef = useRef(false) const aiLoadedRef = useRef(false)
const aiLoadingRef = useRef(false)
useEffect(() => { useEffect(() => {
aiMessagesRef.current?.scrollTo(0, aiMessagesRef.current.scrollHeight) aiMessagesRef.current?.scrollTo(0, aiMessagesRef.current.scrollHeight)
@@ -338,23 +338,7 @@ export default function Shell({ api }) {
} }
} }
const onWsState = (connected) => { let disposed = false
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)
const saveBuffer = () => { const saveBuffer = () => {
try { try {
@@ -364,33 +348,50 @@ export default function Shell({ api }) {
const line = buf.getLine(i) const line = buf.getLine(i)
if (line) lines.push(line.translateToString(true)) 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') savedBuffers[tabId] = lines.join('\n')
localStorage.setItem(TERMINAL_BUFFER_KEY, JSON.stringify(savedBuffers)) sessionStorage.setItem(TERMINAL_BUFFER_KEY, JSON.stringify(savedBuffers))
} catch {} } 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 = () => { const clearBufferOnClear = () => {
try { try {
const buf = term.buffer.active const buf = term.buffer.active
const lineY = buf.viewportY + buf.cursorY const lineY = buf.length - 1
const line = buf.getLine(lineY) const line = buf.getLine(lineY)
if (line) { if (line) {
const text = line.translateToString(true).trim().toLowerCase() const text = line.translateToString(true).trim().toLowerCase()
if (text === 'clear' || text === '$ clear' || text.endsWith(' clear')) { 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] 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) => { term.onData((data) => {
if (data === '\r') { if (data === '\r') clearBufferOnClear()
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) resizeObserver.observe(container)
window.addEventListener('resize', onResize) 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(() => { useEffect(() => {
const tab = tabs.find(t => t.id === activeTab) const tab = tabs.find(t => t.id === activeTab)
if (!tab) return if (!tab) return
let cancelled = false
const pending = []
const tryInit = (attempt) => { const tryInit = (attempt) => {
if (attempt > 20) return if (cancelled || attempt > 20) return
const shellCol = document.querySelector('.shell-terminal-col') const shellCol = document.querySelector('.shell-terminal-col')
if (!shellCol || shellCol.offsetParent === null) { if (!shellCol || shellCol.offsetParent === null) {
setTimeout(() => tryInit(attempt + 1), 150) pending.push(setTimeout(() => tryInit(attempt + 1), 150))
return return
} }
const container = document.getElementById(`terminal-${tab.id}`) const container = document.getElementById(`terminal-${tab.id}`)
if (!container || container.offsetHeight === 0) { if (!container || container.offsetHeight === 0) {
setTimeout(() => tryInit(attempt + 1), 100) pending.push(setTimeout(() => tryInit(attempt + 1), 100))
return return
} }
if (!tabsRef.current[tab.id]) { if (!tabsRef.current[tab.id]) {
initTerminal(tab.id, tab) initTerminal(tab.id, tab)
} }
requestAnimationFrame(() => { requestAnimationFrame(() => {
if (cancelled) return
const entry = tabsRef.current[tab.id] const entry = tabsRef.current[tab.id]
if (entry) entry.fitAddon.fit() if (entry) entry.fitAddon.fit()
}) })
} }
tryInit(0) tryInit(0)
return () => {
cancelled = true
pending.forEach(clearTimeout)
}
}, [activeTab, tabs, initTerminal]) }, [activeTab, tabs, initTerminal])
useEffect(() => { useEffect(() => {
@@ -451,6 +464,20 @@ export default function Shell({ api }) {
return () => clearInterval(iv) return () => clearInterval(iv)
}, [tabs]) }, [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(() => { useEffect(() => {
const onKey = (e) => { const onKey = (e) => {
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return
@@ -506,29 +533,26 @@ export default function Shell({ api }) {
const closeTab = (tabId, e) => { const closeTab = (tabId, e) => {
if (e) e.stopPropagation() 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 => { setTabs(prev => {
if (prev.length <= 1) return 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) const next = prev.filter(t => t.id !== tabId)
if (activeTab === tabId && next.length > 0) { if (activeTab === tabId && next.length > 0) {
setActiveTab(next[next.length - 1].id) setActiveTab(next[next.length - 1].id)
@@ -593,8 +617,9 @@ export default function Shell({ api }) {
}, [activeTab]) }, [activeTab])
const _sendAiMessage = useCallback(async (text, fromEvent = false) => { 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() const trimmed = text.trim()
aiLoadingRef.current = true
if (!fromEvent) { if (!fromEvent) {
setAiInput('') setAiInput('')
@@ -608,6 +633,7 @@ export default function Shell({ api }) {
setAiTokens(0) setAiTokens(0)
setAiAtLimit(false) setAiAtLimit(false)
} catch {} } catch {}
aiLoadingRef.current = false
return return
} }
@@ -616,6 +642,7 @@ export default function Shell({ api }) {
{ role: 'user', content: trimmed }, { 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.' } { 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 return
} }
@@ -641,13 +668,14 @@ export default function Shell({ api }) {
setAiAtLimit(d.at_limit || false) setAiAtLimit(d.at_limit || false)
}).catch(() => {}) }).catch(() => {})
} catch (err) { } catch (err) {
if (err.message.includes('context limit')) { if (err.message?.includes('context limit')) {
setAiAtLimit(true) setAiAtLimit(true)
} }
setAiMessages(prev => [...prev.filter(m => !m._streaming), { role: 'assistant', content: `Erreur: ${err.message}` }]) setAiMessages(prev => [...prev.filter(m => !m._streaming), { role: 'assistant', content: `Erreur: ${err.message}` }])
} }
setAiLoading(false) setAiLoading(false)
}, [api, t, aiLoading, aiAtLimit, focusAiTerminal]) aiLoadingRef.current = false
}, [api, t, aiAtLimit, focusAiTerminal])
const handleAiSend = () => _sendAiMessage(aiInput, false) const handleAiSend = () => _sendAiMessage(aiInput, false)