From fd74b3cacca1238c041286fa1ebe6a649db5aa9b Mon Sep 17 00:00:00 2001 From: VAIO73 <50487331+Vaso73@users.noreply.github.com> Date: Mon, 26 Oct 2026 05:53:05 +0000 Subject: [PATCH] fix: detach closed update viewers and preserve other guest state --- AppImage/components/script-terminal-modal.tsx | 30 ++++++++++++++-- AppImage/components/virtual-machines.tsx | 6 ++++ tests/test_lxc_update_jobs.cjs | 36 +++++++++++++++++++ tests/test_oci_update_cancellation.cjs | 2 +- 4 files changed, 71 insertions(+), 3 deletions(-) diff --git a/AppImage/components/script-terminal-modal.tsx b/AppImage/components/script-terminal-modal.tsx index a8fd5ed7..aa3b8ec1 100644 --- a/AppImage/components/script-terminal-modal.tsx +++ b/AppImage/components/script-terminal-modal.tsx @@ -84,6 +84,7 @@ export function ScriptTerminalModal({ // after dynamic imports resolve — captures the latest value without // re-binding the closure. const isOpenRef = useRef(false) + const openGenerationRef = useRef(0) const fitAddonRef = useRef(null) const sessionIdRef = useRef(Math.random().toString(36).substring(2, 8)) @@ -158,8 +159,9 @@ export function ScriptTerminalModal({ const wsUrl = getScriptWebSocketUrl(sessionIdRef.current) // Single-use auth ticket appended as ?ticket=... — see lib/terminal-ws.ts. + const generation = openGenerationRef.current const ticketedUrl = await getTicketedWsUrl(wsUrl) - if (!isOpenRef.current || !termRef.current) return + if (!isOpenRef.current || !termRef.current || generation !== openGenerationRef.current) return const ws = new WebSocket(ticketedUrl) wsRef.current = ws @@ -326,12 +328,13 @@ export function ScriptTerminalModal({ // otherwise we attach a Terminal to a stale ref and open a WS that // nobody reads. const wasOpenAtCall = isOpenRef.current + const generation = ++openGenerationRef.current const [TerminalClass, FitAddonClass] = await Promise.all([ import("xterm").then((mod) => mod.Terminal), import("xterm-addon-fit").then((mod) => mod.FitAddon), import("xterm/css/xterm.css"), ]) - if (!wasOpenAtCall || !isOpenRef.current) return + if (!wasOpenAtCall || !isOpenRef.current || generation !== openGenerationRef.current) return const fontSize = window.innerWidth < 768 ? 12 : 16 @@ -601,6 +604,7 @@ export function ScriptTerminalModal({ if (isOpen) { initializeTerminal() } else { + openGenerationRef.current++ if (checkConnectionInterval.current) { clearInterval(checkConnectionInterval.current) } @@ -636,6 +640,28 @@ export function ScriptTerminalModal({ } }, [isOpen]) + useEffect(() => () => { + if (!backgroundUpdate) return // Transport mode is fixed for this instance. + // Conditional parents can unmount an open modal. Detach that viewer too; + // only the background job owns its process and completion result. + isOpenRef.current = false + openGenerationRef.current++ + for (const ref of [checkConnectionInterval, keepAliveIntervalRef]) { + if (ref.current) clearInterval(ref.current) + } + for (const ref of [waitingTimeoutRef, reconnectTimeoutRef]) { + if (ref.current) clearTimeout(ref.current) + } + if (wsRef.current) { + wsRef.current.onclose = null + wsRef.current.onmessage = null + wsRef.current.close() + wsRef.current = null + } + termRef.current?.dispose() + termRef.current = null + }, []) + useEffect(() => { const updateDeviceType = () => { const width = window.innerWidth diff --git a/AppImage/components/virtual-machines.tsx b/AppImage/components/virtual-machines.tsx index 612af509..18654320 100644 --- a/AppImage/components/virtual-machines.tsx +++ b/AppImage/components/virtual-machines.tsx @@ -2711,6 +2711,12 @@ export function VirtualMachines() { // performs the requests (the data map is only a render seed), so no // invalidation is needed and there is no "Loading applications" flash. void fetchLxcApps(applyVmid) + // Recovery can be opened from the job banner while another CT is selected. + // Refresh the operated guest without changing that other guest's result UI. + if (selectedVMRef.current?.vmid !== applyVmid) { + void mutate() + return + } // Enter the "Comprobando resultado…" state on the Updates tab. // Baseline snapshot lets a downstream useEffect detect when the // SWR poll actually delivers the post-apply counts (as opposed diff --git a/tests/test_lxc_update_jobs.cjs b/tests/test_lxc_update_jobs.cjs index d630a270..84524056 100644 --- a/tests/test_lxc_update_jobs.cjs +++ b/tests/test_lxc_update_jobs.cjs @@ -7,9 +7,13 @@ const ts = require(path.join(root, 'node_modules/typescript')) const source = fs.readFileSync(path.join(root, 'components/script-terminal-modal.tsx'), 'utf8') const tree = ts.createSourceFile('modal.tsx', source, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX) const handlers = {onmessage: [], onclose: []} +let unmount function walk(node) { if (ts.isBinaryExpression(node) && /^ws\.(onmessage|onclose)$/.test(node.left.getText(tree))) handlers[node.left.name.text].push(node.right.getText(tree)) + if (ts.isCallExpression(node) && node.expression.getText(tree) === 'useEffect' + && node.arguments[0]?.getText(tree).includes('Transport mode is fixed')) + unmount = node.arguments[0].getText(tree) ts.forEachChild(node, walk) } walk(tree) @@ -69,6 +73,37 @@ for (const handler of handlers.onclose) { compile(handler, closed.context)({}) assert.equal(closed.reconnects.length, 0, 'closing the view cannot reopen a socket') } +assert.ok(unmount) +for (const backgroundUpdate of [undefined, {runId:'existing'}]) { + const f=fixture(backgroundUpdate), closes=[], sends=[], disposals=[] + const ref={current:{close:()=>closes.push(true),send:()=>sends.push(true)}} + Object.assign(f.context,{wsRef:ref,openGenerationRef:{current:1}, + checkConnectionInterval:{current:null},reconnectTimeoutRef:{current:null}}) + f.context.termRef.current.dispose=()=>disposals.push(true) + compile(unmount,f.context)()() + assert.equal(closes.length,backgroundUpdate?1:0) + assert.equal(sends.length,0,'unmount cannot send cancellation or another start') + assert.equal(f.finished.length,0) + assert.equal(disposals.length,backgroundUpdate?1:0) +} +const vms=fs.readFileSync(path.join(root,'components/virtual-machines.tsx'),'utf8') +const vmTree=ts.createSourceFile('vms.tsx',vms,ts.ScriptTarget.Latest,true,ts.ScriptKind.TSX) +let complete +function visit(n){if(ts.isVariableDeclaration(n)&&n.name.getText(vmTree)==='handleApplyComplete')complete=n.initializer.getText(vmTree);ts.forEachChild(n,visit)} +visit(vmTree) +async function checkCompletion(){ + for(const selected of [100,200]){ + const posts=[],states=[],refresh=[] + const context={applyVmid:100,applyStartedAt:Date.now(),selectedVM:{vmid:selected},selectedVMRef:{current:{vmid:selected}}, + applyRunId:'existing',applyTarget:'app',applyTargetIds:['app:test'],applyTargetLabels:['Test'],applyDockerRefresh:false, + vmModalCacheRef:{current:{details:new Map(),schedule:new Map()}},fetchApi:async(...args)=>posts.push(args), + fetchLxcApps:id=>refresh.push(id),mutate:()=>{},setUpdatesBaselineCount:v=>states.push(v),setUpdatesResult:v=>states.push(v),setUpdatesRefreshing:v=>states.push(v)} + await compile(complete,context)(0,false) + assert.equal(posts.length,0,'background completion never sends a second finalization POST') + assert.deepEqual(refresh,[100]) + assert.equal(states.length,selected===100?3:0,'another guest result UI is preserved') + } +} const local = new Map(), events = [] const storage = {getItem:k=>local.get(k), setItem:(k,v)=>local.set(k,v)} const jobs = loadTypeScript('lib/lxc-update-jobs.ts', {localStorage:storage, @@ -80,3 +115,4 @@ assert.equal(jobs.viewedUpdateJobs().length, 1) assert.equal(events.length, 2) assert.equal([...local.values()].join(''), '["opaque-run"]', 'storage has IDs, no commands or credentials') console.log('PASS actual initial/reconnect handlers, auth-independent recovery metadata and legacy completion') +checkCompletion().catch(e=>{console.error(e);process.exitCode=1}) diff --git a/tests/test_oci_update_cancellation.cjs b/tests/test_oci_update_cancellation.cjs index 5806c320..3113915f 100644 --- a/tests/test_oci_update_cancellation.cjs +++ b/tests/test_oci_update_cancellation.cjs @@ -23,7 +23,7 @@ function executable(node, file, scope) { function fixture(handler, locale, optedIn=true) { const writes=[], callbacks=[], state={closed:0}; const t=translator(locale), terminal={write:s=>writes.push(s),writeln:s=>writes.push(s)}; - const shared={...completion, backgroundUpdate:undefined, isOpenRef:{current:true}, cancelledExitCode:optedIn?6:undefined,t, + const shared={...completion, backgroundUpdate:undefined, isOpenRef:{current:true}, openGenerationRef:{current:1}, cancelledExitCode:optedIn?6:undefined,t, completedSuccessfullyMessage:undefined,completedWithErrorMessage:undefined}; const getCompletionMessage=executable(messageCallback,source,shared); const scope={...shared,getCompletionMessage,term:terminal,termRef:{current:terminal},