fix(terminal): refactor WebSocket cleanup, buffer management, and disposal
All checks were successful
Beta Release / beta (push) Successful in 52s
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:
@@ -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{
|
||||||
|
|||||||
@@ -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 }))
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user