fix: wait for VM power tasks before completing controls

This commit is contained in:
VAIO73
2026-10-15 04:29:26 +00:00
parent 017b73fffd
commit 3e806afe8c
14 changed files with 807 additions and 23 deletions
+34
View File
@@ -288,6 +288,40 @@ class CheckOnlyTests(unittest.TestCase):
self.assertTrue(stored['error'])
self.assertFalse(any('apt list' in ' '.join(command) for command in commands))
def test_control_route_uses_same_lock_and_preserves_qemu(self):
app = Flask(__name__)
command = Mock(return_value=subprocess.CompletedProcess(
[], 0, '"UPID:fixture:000A:000B:000C:vzstart:100:root@pam:"\n', ''))
scope = {'app': app, 'jsonify': jsonify, 'request': request,
'require_auth': lambda fn: fn,
'get_cached_pvesh_cluster_resources_vm': lambda: [
{'vmid': 100, 'type': 'lxc', 'node': 'fixture'},
{'vmid': 200, 'type': 'qemu', 'node': 'fixture'}],
'subprocess': SimpleNamespace(run=command), '_pvesh_cache': {},
'_vm_cache_invalidate': Mock()}
node = next(n for n in ast.parse((ROOT / 'AppImage/scripts/flask_server.py').read_text()).body
if isinstance(n, ast.FunctionDef) and n.name == 'api_vm_control')
exec(compile(ast.Module(body=[node], type_ignores=[]), 'route', 'exec'), scope)
client = app.test_client()
reservation = job.acquire(100)
try:
self.assertEqual(client.post('/api/vms/100/control', json={'action': 'stop'}).status_code, 409)
command.assert_not_called()
self.assertEqual(client.post('/api/vms/200/control', json={'action': 'reboot'}).status_code, 200)
finally:
reservation.close()
# The production waiter owns a long-lived reservation. Isolate its
# launch here so it cannot probe through another test's transport.
with patch.object(job.threading, 'Thread') as worker:
response = client.post('/api/vms/100/control', json={'action': 'start'})
worker.return_value.start.assert_called_once_with()
retained = worker.call_args.kwargs['args'][1]
self.addCleanup(retained.close)
job.cancel_control(retained)
self.assertEqual(response.status_code, 200)
self.assertTrue(response.json['success'])
self.assertEqual(response.json['task'], 'UPID:fixture:000A:000B:000C:vzstart:100:root@pam:')
self.assertIn('/nodes/fixture/lxc/100/status/start', command.call_args.args[0])
def test_actual_routes_submit_poll_and_control_lock(self):
app = Flask(__name__)
@@ -0,0 +1,198 @@
"""Actual Flask submission, real flock, isolated Proxmox reads and shell caller."""
import json
import subprocess
import sys
import tempfile
import unittest
from pathlib import Path
from unittest.mock import Mock, patch
import test_vm_control_task as control_fixture
TASK, ROOT = control_fixture.TASK, control_fixture.ROOT
sys.path.insert(0, str(ROOT / 'AppImage/scripts'))
import lxc_check_job as job
class ControlReservationTests(unittest.TestCase):
def setUp(self):
self.temp = tempfile.TemporaryDirectory()
self.addCleanup(self.temp.cleanup)
self.folder = Path(self.temp.name)
self.lock_path = self.folder / 'proxmenux-lxc-update-100.lock'
self.lock_patch = patch.object(job, 'LOCK_DIR', self.folder)
self.lock_patch.start()
self.addCleanup(self.lock_patch.stop)
job._jobs.clear()
job._attempts.clear()
self.route = control_fixture.ControlTaskTests()
self.route.setUp()
self.thread = Mock()
self.factory = patch.object(job.threading, 'Thread', return_value=self.thread)
self.thread_factory = self.factory.start()
self.addCleanup(self.factory.stop)
self.reservations = []
self.addCleanup(lambda: [handle.close() for handle in self.reservations if not handle.closed])
def accepted(self, action='reboot'):
task = TASK.replace('vzreboot', 'vz' + action)
self.route.run.return_value = self.route.result(task)
response = self.route.client.post('/api/vms/100/control', json={'action': action})
self.assertEqual(response.status_code, 200, response.json)
handle = self.thread_factory.call_args.kwargs['args'][1]
self.reservations.append(handle)
self.assertEqual(response.json['task'], task)
return handle
@staticmethod
def result(payload, code=0):
return subprocess.CompletedProcess([], code, json.dumps(payload), '')
def test_every_power_action_keeps_reservation_after_post_and_blocks_second_caller(self):
for action in ('start', 'shutdown', 'reboot', 'stop'):
with self.subTest(action=action):
handle = self.accepted(action)
self.route.run.reset_mock()
response = self.route.client.post('/api/vms/100/control', json={'action': 'start'})
self.assertEqual(response.status_code, 409)
self.route.run.assert_not_called()
with self.assertRaises(BlockingIOError):
job.start(100)
self.assertFalse(handle.closed)
job.cancel_control(handle)
handle.close()
def test_waiter_survives_read_failure_and_running_until_confirmed_terminal_status(self):
handle = self.accepted()
probes = [subprocess.TimeoutExpired('pvesh', 5),
self.result({'status': 'running'}), self.result({'status': 'stopped'}),
self.result({'status': 'stopped', 'exitstatus': 'OK'})]
def still_reserved(_):
with self.assertRaises(BlockingIOError):
job.acquire(100)
self.assertFalse(handle.closed)
with patch.object(job.subprocess, 'run', side_effect=probes) as reads, \
patch.object(job.time, 'sleep', side_effect=still_reserved) as sleeps:
job._wait_control(100, handle)
self.assertEqual(sleeps.call_count, 3)
self.assertTrue(handle.closed)
self.assertEqual(self.lock_path.read_text(), '')
self.assertTrue(all(call.args[0][:2] == ['pvesh', 'get'] for call in reads.call_args_list))
job.acquire(100).close()
def test_restart_releases_fd_but_marker_blocks_until_pve_verifies_completion(self):
handle = self.accepted()
handle.close() # simulate Monitor shutdown, not guest task termination
with patch.object(job.subprocess, 'run', return_value=self.result({'status': 'running'})):
with self.assertRaises(BlockingIOError):
job.acquire(100)
with patch.object(job.subprocess, 'run', return_value=self.result(
{'status': 'stopped', 'exitstatus': 'start failed'})):
job.acquire(100).close()
self.assertEqual(self.lock_path.read_text(), '')
def test_unknown_or_malformed_records_make_no_pve_probe_and_keep_blocked(self):
for content in ['not-json', '{}', json.dumps({'node': 'fixture', 'task': None}),
json.dumps({'node': 'other', 'task': TASK}),
json.dumps({'node': 'fixture', 'task': TASK.replace(':100:', ':200:')}),
'x' * 1025]:
self.lock_path.write_text(content)
with patch.object(job.subprocess, 'run') as probe:
with self.assertRaises(BlockingIOError):
job.acquire(100)
probe.assert_not_called()
self.assertEqual(self.lock_path.read_text(), content)
def test_plain_lock_acquire_has_no_new_probe(self):
with patch.object(job.subprocess, 'run') as probe:
job.acquire(100).close()
probe.assert_not_called()
def test_definite_failure_clears_marker_but_timeout_or_missing_upid_cannot_confirm_completion(self):
self.route.run.return_value = self.result({}, code=1)
self.assertEqual(self.route.client.post('/api/vms/100/control', json={'action': 'start'}).status_code, 500)
self.assertEqual(self.lock_path.read_text(), '')
for response in [subprocess.TimeoutExpired('pvesh', 30), self.result('')]:
self.route.run.side_effect = response if isinstance(response, Exception) else None
self.route.run.return_value = response
self.route.client.post('/api/vms/100/control', json={'action': 'start'})
self.assertIsNone(json.loads(self.lock_path.read_text())['task'])
with self.assertRaises(BlockingIOError):
job.acquire(100)
self.lock_path.write_text('') # fixture reset after explicit assertions
def test_thread_launch_failure_retains_marker_for_recovery(self):
self.thread.start.side_effect = RuntimeError('thread failed')
self.route.run.return_value = self.route.result(TASK)
self.assertEqual(self.route.client.post('/api/vms/100/control', json={'action': 'reboot'}).status_code, 500)
self.assertEqual(json.loads(self.lock_path.read_text())['task'], TASK)
with patch.object(job.subprocess, 'run', return_value=self.result({'status': 'running'})):
with self.assertRaises(BlockingIOError):
job.acquire(100)
def test_failed_task_record_write_keeps_uncertainty_before_any_next_operation(self):
original = job._write_control
calls = 0
def fail_second(handle, record):
nonlocal calls
calls += 1
if calls == 2:
raise OSError('persistence failed')
original(handle, record)
self.route.run.return_value = self.route.result(TASK)
with patch.object(job, '_write_control', side_effect=fail_second):
response = self.route.client.post('/api/vms/100/control', json={'action': 'reboot'})
self.assertEqual(response.status_code, 500)
self.assertIsNone(json.loads(self.lock_path.read_text())['task'])
with patch.object(job.subprocess, 'run') as probe:
with self.assertRaises(BlockingIOError):
job.acquire(100)
probe.assert_not_called()
def test_shell_blocked_by_live_reservation_runs_no_probe_or_guest_action(self):
self.accepted()
import os
env = {**os.environ, 'PROXMENUX_LOCK_DIR': str(self.folder),
'VMID': '100', 'TARGET': 'os', 'BACKUP': '1'}
result = subprocess.run(['/bin/bash', str(ROOT / 'scripts/lxc/apply_updates.sh')],
env=env, capture_output=True, text=True, timeout=10)
self.assertEqual(result.returncode, 7, result.stdout + result.stderr)
self.assertIn('another ProxMenux update', result.stderr)
self.assertEqual(result.stdout, '')
def test_retired_combined_mode_fails_before_lock_or_any_guest_operation(self):
import os
env = {**os.environ, 'PROXMENUX_LOCK_DIR': str(self.folder),
'VMID': '100', 'TARGET': 'os', 'CHECK_APPLY_OS': '1'}
result = subprocess.run(['/bin/bash', str(ROOT / 'scripts/lxc/apply_updates.sh')],
env=env, capture_output=True, text=True, timeout=10)
self.assertEqual(result.returncode, 4, result.stdout + result.stderr)
self.assertFalse(self.lock_path.exists())
self.assertEqual(result.stdout, '')
def test_shell_caller_preserves_pending_record_and_performs_no_guest_operation(self):
# Covers process-restart recovery: no Python thread holds this flock.
self.lock_path.write_text(json.dumps({'node': 'fixture', 'task': TASK}))
binary = self.folder / 'bin'
binary.mkdir()
call_log = self.folder / 'calls'
for name, body in [('pvesh', 'echo \'{"status":"running"}\''),
('pct', f'echo pct >> "{call_log}"; exit 99')]:
script = binary / name
script.write_text('#!/bin/sh\n' + body + '\n')
script.chmod(0o755)
import os
before = self.lock_path.read_text()
env = {**os.environ, 'PATH': str(binary) + ':' + os.environ['PATH'],
'PYTHONPATH': str(ROOT / 'AppImage/scripts'),
'PROXMENUX_LOCK_DIR': str(self.folder), 'VMID': '100', 'TARGET': 'os', 'BACKUP': '1'}
result = subprocess.run(['/bin/bash', str(ROOT / 'scripts/lxc/apply_updates.sh')],
env=env, capture_output=True, text=True, timeout=10)
self.assertEqual(result.returncode, 7, result.stdout + result.stderr)
self.assertFalse(call_log.exists())
self.assertEqual(self.lock_path.read_text(), before)
if __name__ == '__main__':
unittest.main()
+18
View File
@@ -145,7 +145,25 @@ class ObservedStateRoutesTests(unittest.TestCase):
self.assertEqual(response.status_code, 200, response.json)
return response.json[0]
def test_completed_shutdown_cannot_revert_to_stale_cluster_running(self):
self.running = False
self.scope['get_cached_pvesh_cluster_resources_vm'] = lambda: [
dict(type='lxc', node='fixture-pve', vmid=100, name='dev-trixie', status='running')]
for _ in range(3):
row = self.row()
self.assertEqual(row['status'], 'stopped', 'cached summary cannot override live local state')
self.assertEqual(row['ips'], [])
self.ip_probe.assert_not_called()
self.assertEqual(self.commands, [], 'status polling never enters the guest')
def test_completed_start_cannot_revert_to_stale_cluster_stopped(self):
self.running = True
self.scope['get_cached_pvesh_cluster_resources_vm'] = lambda: [
dict(type='lxc', node='fixture-pve', vmid=100, name='dev-trixie', status='stopped')]
row = self.row()
self.assertEqual(row['status'], 'running')
self.assertEqual(row['ip'], self.ip)
self.assertEqual(self.commands, [])
def test_dhcp_change_after_ttl_without_lifecycle_and_api_compatibility(self):
first = self.row()
@@ -473,5 +473,39 @@ context.selectedVMRef.current = { vmid: 100 }
assert.ok(messages[key]?.trim())
assert.match(messages.checkCooldown, /\{seconds\}/)
}
const oldFetch = context.fetchApi
const oldMutate = context.mutate
context.controlRequestRef = { current: false }
context.setControlAction = () => {}
context.waitForVmControlTask = async () => ({ guest_status: 'running' })
context.setSelectedVM = update => {
const previous = context.selectedVM
const next = typeof update === 'function' ? update(previous) : update
assert.equal(next?.vmid, previous?.vmid, 'control preserves the selected guest')
context.selectedVM = next
}
context.fetchApi = async () => ({ config: { onboot: 1 } })
context.mutate = async () => {}
for (const action of ["start", "shutdown", "reboot", "stop"]) {
context.selectedVMRef.current = { vmid: 100 }
context.selectionChanged = undefined
await ui.control(100, action)
assert.equal(context.selectionChanged, undefined, "control must not close or reset the selected modal")
assert.equal(context.controlLoading, false)
}
for (const selection of [null, { vmid: 200 }]) {
context.selectedVMRef.current = { vmid: 100 }
context.mutate = async () => { context.selectedVMRef.current = selection }
details = null
await ui.control(100, "start")
assert.equal(details, null, "closing or switching guests during control must not reopen/overwrite a modal")
}
context.selectedVMRef.current = { vmid: 100 }
context.mutate = async () => { throw new Error("poll failed") }
context.alerted = null
await ui.control(100, "shutdown")
assert.equal(context.alerted, null, "failed revalidation is not failed control")
context.fetchApi = oldFetch
context.mutate = oldMutate
console.log("PASS observed-state UI: freshness, safe actions, per-guest preferences/bulk races, EN/SK")
})().catch(error => { console.error(error); process.exitCode = 1 })
+127
View File
@@ -0,0 +1,127 @@
"""Actual Flask routes: additive task metadata and confined read-only status."""
import ast
import json
import subprocess
import unittest
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import Mock, patch
from flask import Flask, jsonify, request
ROOT = Path(__file__).resolve().parents[2]
TASK = 'UPID:fixture:000A:000B:000C:vzreboot:100:root@pam:'
class ControlTaskTests(unittest.TestCase):
def setUp(self):
self.app = Flask(__name__)
self.run = Mock()
self.cache = {}
self.invalidate = Mock()
scope = dict(app=self.app, jsonify=jsonify, request=request, require_auth=lambda fn: fn,
get_cached_pvesh_cluster_resources_vm=lambda: [
dict(vmid=100, type='lxc', node='fixture'),
dict(vmid=200, type='qemu', node='fixture')],
subprocess=SimpleNamespace(run=self.run, TimeoutExpired=subprocess.TimeoutExpired),
_pvesh_cache=self.cache, _vm_cache_invalidate=self.invalidate)
nodes = [n for n in ast.parse((ROOT / 'AppImage/scripts/flask_server.py').read_text()).body
if isinstance(n, ast.FunctionDef) and n.name in ('api_vm_control_task', 'api_vm_control')]
exec(compile(ast.Module(body=nodes, type_ignores=[]), 'routes', 'exec'), scope)
self.client = self.app.test_client()
def get(self, task=TASK, vmid=100):
return self.client.get(f'/api/vms/{vmid}/control-task/{task}')
def result(self, payload, code=0):
return subprocess.CompletedProcess([], code, json.dumps(payload), '')
def test_actual_control_post_preserves_each_action_and_adds_its_task(self):
lock = Mock()
retained = Mock()
module = SimpleNamespace(acquire=lambda _: lock, begin_control=Mock(),
retain_control=retained, cancel_control=Mock())
with patch.dict('sys.modules', {'lxc_check_job': module}):
for family, vmid in [('vz', 100), ('qm', 200)]:
for action in ['start', 'shutdown', 'reboot', 'stop']:
self.run.reset_mock()
task = TASK.replace('vzreboot:100', f'{family}{action}:{vmid}')
self.run.return_value = self.result(task)
response = self.client.post(f'/api/vms/{vmid}/control', json={'action': action})
self.assertEqual(response.status_code, 200)
self.assertTrue(response.json['success'])
self.assertEqual(response.json['action'], action)
self.assertEqual(response.json['vmid'], vmid)
self.assertEqual(response.json['task'], task)
self.assertEqual(self.run.call_count, 1, 'POST accepts once without polling or extra action')
self.assertEqual(self.run.call_args.args[0],
['pvesh', 'create', f'/nodes/fixture/{"lxc" if family == "vz" else "qemu"}/{vmid}/status/{action}'])
self.assertEqual(retained.call_count, 4)
lock.close.assert_not_called()
def test_legacy_acceptance_without_task_and_invalid_action(self):
self.run.return_value = subprocess.CompletedProcess([], 0, '', '')
response = self.client.post('/api/vms/200/control', json={'action': 'start'})
self.assertEqual(response.status_code, 200)
self.assertTrue(response.json['success'])
self.assertIsNone(response.json['task'])
self.run.reset_mock()
self.assertEqual(self.client.post('/api/vms/100/control', json={'action': 'destroy'}).status_code, 400)
self.run.assert_not_called()
def test_running_task_never_probes_guest_or_mutates(self):
self.run.return_value = self.result(dict(status='running'))
response = self.get()
self.assertEqual(response.status_code, 200)
self.assertEqual(response.json, dict(status='running', exitstatus=None))
self.assertEqual(self.run.call_count, 1)
self.assertEqual(self.run.call_args.args[0][:2], ['pvesh', 'get'])
self.invalidate.assert_not_called()
def test_complete_task_checks_actual_guest_for_all_power_actions_and_qemu(self):
for family, vmid in [('vz', 100), ('qm', 200)]:
for action in ['start', 'shutdown', 'reboot', 'stop']:
with self.subTest(family=family, action=action):
self.run.reset_mock()
expected = 'stopped' if action in ('shutdown', 'stop') else 'running'
self.run.side_effect = [self.result(dict(status='stopped', exitstatus='OK')),
self.result(dict(status=expected))]
task = TASK.replace('vzreboot:100', f'{family}{action}:{vmid}')
response = self.get(task, vmid)
self.assertEqual(response.status_code, 200)
self.assertEqual(response.json['guest_status'], expected)
self.assertEqual(self.run.call_count, 2)
self.assertTrue(all(call.args[0][:2] == ['pvesh', 'get'] for call in self.run.call_args_list))
def test_wrong_guest_node_type_and_path_make_zero_probes(self):
for task in [TASK.replace(':100:', ':200:'), TASK.replace('fixture', 'other'),
TASK.replace('vzreboot', 'qmreboot'), TASK.replace('vzreboot', 'vzdump'),
TASK + '/extra', '../../etc/passwd', 'not-a-task']:
with self.subTest(task=task):
self.run.reset_mock()
self.assertIn(self.get(task).status_code, [400, 404])
self.run.assert_not_called()
self.assertEqual(self.get(vmid=999).status_code, 404)
self.run.assert_not_called()
def test_task_failure_never_probes_guest_or_claims_success(self):
self.run.return_value = self.result(dict(status='stopped', exitstatus='start failed'))
self.assertEqual(self.get().json['exitstatus'], 'start failed')
self.assertEqual(self.run.call_count, 1)
self.invalidate.assert_not_called()
def test_unknown_and_invalid_reads_are_errors_not_success(self):
for value in [[], {}, dict(status='unknown'), dict(status='running')]:
self.run.return_value = self.result(value, code=1 if value == dict(status='running') else 0)
self.assertEqual(self.get().status_code, 502)
self.run.return_value = subprocess.CompletedProcess([], 0, '', '')
self.assertEqual(self.get().status_code, 502)
self.run.side_effect = subprocess.TimeoutExpired('pvesh', 5)
self.assertEqual(self.get().status_code, 502)
def test_invalid_final_guest_never_clears_cache(self):
for value in [{}, [], dict(status='unknown')]:
self.run.side_effect = [self.result(dict(status='stopped', exitstatus='OK')), self.result(value)]
self.assertEqual(self.get().status_code, 502)
self.invalidate.assert_not_called()
if __name__ == '__main__':
unittest.main()
+135
View File
@@ -0,0 +1,135 @@
// Execute the actual handler and footer: acceptance is not task completion.
const assert = require('node:assert/strict')
const fs = require('node:fs')
const path = require('node:path')
const vm = require('node:vm')
const root = path.resolve(__dirname, '../../AppImage')
const ts = require(path.join(root, 'node_modules/typescript'))
const source = fs.readFileSync(path.join(root, 'components/virtual-machines.tsx'), 'utf8')
const tree = ts.createSourceFile('vm.tsx', source, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX)
let handler, footer
function walk(node) {
if (ts.isVariableDeclaration(node) && node.name.getText(tree) === 'handleVMControl') handler = node.getText(tree)
if (ts.isJsxElement(node) && node.openingElement.tagName.getText(tree) === 'div'
&& node.openingElement.attributes.getText(tree).includes('border-t border-border bg-background px-6 py-4 mt-auto'))
footer = node.getText(tree)
ts.forEachChild(node, walk)
}
walk(tree)
function compile(code, context) {
vm.runInNewContext(ts.transpileModule(code, { compilerOptions: {
module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020, jsx: ts.JsxEmit.ReactJSX,
}}).outputText, context)
return context.exports
}
function deferred() { let resolve, reject; const promise = new Promise((yes, no) => { resolve = yes; reject = no }); return { promise, resolve, reject } }
function buttons(node) {
if (!node || typeof node !== 'object') return []
if (Array.isArray(node)) return node.flatMap(buttons)
return [...(node.type === 'Button' ? [node] : []), ...buttons(node.props?.children)]
}
;(async () => {
for (const action of ['start', 'shutdown', 'reboot', 'stop']) {
const completion = deferred(), accepted = deferred()
const context = {
exports: {}, console: { error() {} }, selectedVM: { vmid: 100, status: action === 'start' ? 'stopped' : 'running', type: 'lxc' },
selectedVMRef: { current: { vmid: 100 } }, controlRequestRef: { current: false },
controlLoading: false, controlAction: null, posts: 0, alerts: [],
setControlLoading(value) { context.controlLoading = value },
setControlAction(value) { context.controlAction = value },
setSelectedVM(value) { context.selectedVM = typeof value === 'function' ? value(context.selectedVM) : value },
rows: [{ vmid: 100, status: 'running' }, { vmid: 200, status: 'stopped' }], publications: 0,
setVMDetails() {}, mutate: async (update, options) => {
if (!update) return
assert.equal(options.revalidate, false, 'publish the confirmation before another GET')
context.rows = update(context.rows)
assert.equal(context.rows[1].status, 'stopped', 'only the operated guest changes')
context.publications++
}, invalidateLxcApps() {},
vmModalCacheRef: { current: Object.fromEntries(['details','backups','mountPoints','schedule','firewall'].map(n => [n, new Map()])) },
fetchApi: async (url, options) => {
if (options?.method === 'POST') { context.posts++; return accepted.promise }
return { config: {} }
},
waitForVmControlTask: () => completion.promise,
t: key => key, alert: text => context.alerts.push(text),
require: () => ({ jsx: (type, props) => ({ type, props }), jsxs: (type, props) => ({ type, props }) }),
}
for (const name of ['Button','Terminal','Play','Power','RotateCcw','StopCircle','Loader2']) context[name] = name
const ui = compile(`const ${handler}; exports.control = handleVMControl; exports.footer = () => (${footer});`, context)
const run = ui.control(100, action)
assert.equal(context.controlLoading, true, 'busy starts before the POST')
accepted.resolve({ success: true, task: 'task-id' })
await new Promise(resolve => setImmediate(resolve))
assert.equal(context.controlLoading, true, 'accepted control must stay busy until the Proxmox task completes')
assert.equal(context.publications, 0, 'acceptance never publishes an assumed final status')
assert(buttons(ui.footer()).filter(b => b.props.title).every(b => b.props.disabled), 'all power buttons stay disabled')
await ui.control(100, action)
assert.equal(context.posts, 1, 'a double click never submits a second operation')
completion.resolve({ guest_status: action === 'shutdown' || action === 'stop' ? 'stopped' : 'running' })
await run
assert.equal(context.controlLoading, false)
assert.equal(context.controlRequestRef.current, false)
assert.equal(context.selectedVM.status, action === 'shutdown' || action === 'stop' ? 'stopped' : 'running')
assert.equal(context.alerts.length, 0)
assert.equal(context.controlAction, null)
assert.equal(context.publications, 1)
assert.equal(context.rows[0].status, action === 'shutdown' || action === 'stop' ? 'stopped' : 'running')
const failed = deferred()
context.fetchApi = async () => ({ task: 'task-id' })
context.waitForVmControlTask = () => failed.promise
const failureRun = ui.control(100, action)
failed.reject(new Error('task failed'))
await failureRun
assert.equal(context.controlLoading, false)
assert.equal(context.alerts.length, 1, 'a task error cannot look like completion')
const switched = deferred()
context.waitForVmControlTask = () => switched.promise
const switchRun = ui.control(100, action)
context.selectedVMRef.current = { vmid: 200 }
context.selectedVM = { vmid: 200, status: 'stopped' }
switched.resolve({ guest_status: 'running' })
await switchRun
assert.equal(context.selectedVM.vmid, 200)
assert.equal(context.selectedVM.status, 'stopped', 'completion cannot overwrite another guest')
assert.equal(buttons(ui.footer()).filter(b => b.props.title).some(b => /Pending/.test(b.props.title)), false)
}
const polls = [], sequence = []
let clock = 0, readError = null
const helper = compile(fs.readFileSync(path.join(root, 'lib/vm-control.ts'), 'utf8'), {
exports: {}, Date: { now: () => clock }, AbortSignal: { timeout: () => ({}) },
setTimeout: callback => { clock += 1000; callback() },
require: () => ({ fetchApi: async (url, options) => {
polls.push([url, options]); if (readError) throw readError
return sequence.shift() || { status: 'running' }
}}),
})
// A reboot with "running" status is still pending until its task reports OK.
for (const action of ['start','shutdown','reboot','stop']) {
clock = 0; polls.length = 0
const expected = ['shutdown','stop'].includes(action) ? 'stopped' : 'running'
sequence.push({ status: 'running', guest_status: expected },
{ status: 'stopped', exitstatus: 'OK', guest_status: expected === 'running' ? 'stopped' : 'running' },
{ status: 'stopped', exitstatus: 'OK', guest_status: expected })
assert.equal((await helper.waitForVmControlTask(100, action, 'UPID:test')).guest_status, expected)
assert.equal(polls.length, 3, 'wait for both the task and actual target state')
assert(polls.every(([url, options]) => url === '/api/vms/100/control-task/UPID%3Atest' && !options.method),
'polling only reads the accepted task; no second POST or package check')
}
for (const response of [null, {}, { status:'stopped' }, { status:'stopped', exitstatus:'failed' }]) {
sequence.push(response)
// null must reach the helper, rather than the fake's default running state.
if (response === null) sequence[sequence.length - 1] = { status: null }
await assert.rejects(helper.waitForVmControlTask(100, 'reboot', 'task'), /controlNotConfirmed|failed/)
}
clock = 0; polls.length = 0
await assert.rejects(helper.waitForVmControlTask(100, 'reboot', 'task'), /controlNotConfirmed/)
assert.equal(clock, 180000, 'bounded wait includes the exact deadline')
polls.length = 0
await assert.rejects(helper.waitForVmControlTask(100, 'start', ''), /controlNotConfirmed/)
assert.equal(polls.length, 0, 'a missing task never starts speculative probes')
clock = 0; readError = new Error('unavailable')
await assert.rejects(helper.waitForVmControlTask(100, 'start', 'task'), /controlNotConfirmed/)
assert.equal(polls.length, 3, 'repeated read failures do not report success')
console.log('PASS VM control UI: accepted/pending/completed, all actions, duplicate guard and task failure')
})().catch(error => { console.error(error); process.exitCode = 1 })