fix: detach closed update viewers and preserve other guest state

This commit is contained in:
VAIO73
2026-10-26 07:57:20 +00:00
parent 11326badb2
commit fd74b3cacc
4 changed files with 71 additions and 3 deletions
+28 -2
View File
@@ -84,6 +84,7 @@ export function ScriptTerminalModal({
// after dynamic imports resolve — captures the latest value without
// re-binding the closure.
const isOpenRef = useRef<boolean>(false)
const openGenerationRef = useRef(0)
const fitAddonRef = useRef<any>(null)
const sessionIdRef = useRef<string>(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
+6
View File
@@ -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
+36
View File
@@ -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})
+1 -1
View File
@@ -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},