Apple's built-in QEMU guest agent (AppleQEMUGuestAgent, launched by launchd when a virtio console port org.qemu.guest_agent.0 appears; guest-exec as root) is attached by vmix run --macos and the NixOS module. AppleVirtIO.kext on x86 Tahoe drives virtio-fs, block, console, input, net — verified in QEMU. - customizeImage: `bootScript` — online step through the guest agent (driver mode qga): boot the image, run the script as root with the VMIX volume, shut down through the agent. `as_user` runs commands in the logged-in session. - templates.software: pkg/app (offline in the PE), script/homebrew (online). - templates.profile.settings: widgets, wallpaper (pinned desktoppr — Apple Events need TCC consent that a headless session cannot give), dock apps, autohide, dark mode, hidden files. - generalize: persistHome (fstab LABEL=vmix-home /Users), hideWidgets offline. - formatVolume: formats a blank disk image as APFS by booting the PE (~35 s); idempotent. - NixOS module: macos.guestAgent (/run/vmix/qga-<name>.sock), shares via virtiofsd + vhost-user-fs (Apple automount tag for the first share, others mounted through the agent), macos.homeDisk (created + formatted on first start, virtio-blk), SPICE keeps -vga vmware for macOS. - CLI: vmix run --macos --share DIR --home FILE --qga PATH. - qemu.nix helpers; README section. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XsESshRCoBoUVWV9qKURUF
588 lines
21 KiB
Python
588 lines
21 KiB
Python
#!/usr/bin/env python3
|
|
"""vmix macOS VM driver: runs QEMU and decides when a build boot is finished.
|
|
|
|
Modes
|
|
pe the recovery PE runs /Volumes/VMIX/run.sh and powers off. Success is
|
|
QEMU exiting on its own; the caller checks vmix-run.status.
|
|
install the PE starts the macOS installer, which reboots through its phases
|
|
into the installed system. Finished when that system reaches the
|
|
(bright) loginwindow / Setup Assistant — the driver powers it down —
|
|
or halts on its own.
|
|
boot boot an installed image and wait for it to halt or reach the loginwindow.
|
|
qga boot an installed image with Apple's QEMU guest agent attached
|
|
(--qga-sock), wait for it, run --qga-command as root through it
|
|
(guest-exec), then shut the guest down. Used for online templates.
|
|
|
|
Observation is passive: the serial console (boot-args serial=3: the PE's
|
|
"VMIX-*" markers, kernel boots, panics) and screenshots over QMP (mean
|
|
brightness + a coarse change fingerprint). No OCR, no keystrokes. A boot hang
|
|
(dark, frozen screen, disk and serial idle) is retried with a system_reset.
|
|
Screenshots, the driver log and the serial log are kept in --debug-dir.
|
|
"""
|
|
import argparse
|
|
import hashlib
|
|
import io
|
|
import json
|
|
import os
|
|
import shutil
|
|
import socket
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
import time
|
|
|
|
try:
|
|
from PIL import Image
|
|
except ImportError: # screenshots then only serve as debug files
|
|
Image = None
|
|
|
|
PANIC_MARKS = ('panic(cpu', 'Kernel Extensions in backtrace', 'Debugger called: <panic>', 'Nested panic detected', 'panic string:')
|
|
REBOOT_MARK = 'MACH Reboot'
|
|
|
|
|
|
class Log:
|
|
def __init__(self, path):
|
|
self.f = open(path, 'a') if path else None
|
|
self.t0 = time.time()
|
|
|
|
def __call__(self, msg):
|
|
line = f'[{time.time() - self.t0:7.1f}s] {msg}'
|
|
print(f'vmix driver: {line}', flush=True)
|
|
if self.f:
|
|
self.f.write(line + '\n')
|
|
self.f.flush()
|
|
|
|
|
|
class QMP:
|
|
def __init__(self, path):
|
|
self.path = path
|
|
self.s = None
|
|
self.buf = b''
|
|
|
|
def connect(self, timeout=90):
|
|
t0 = time.time()
|
|
while True:
|
|
try:
|
|
s = socket.socket(socket.AF_UNIX)
|
|
s.settimeout(60)
|
|
s.connect(self.path)
|
|
self.s = s
|
|
self.buf = b''
|
|
self._read() # greeting
|
|
self.cmd('qmp_capabilities')
|
|
return
|
|
except (OSError, ValueError):
|
|
if time.time() - t0 > timeout:
|
|
raise
|
|
time.sleep(1)
|
|
|
|
def _read(self):
|
|
while b'\n' not in self.buf:
|
|
d = self.s.recv(65536)
|
|
if not d:
|
|
raise OSError('QMP socket closed')
|
|
self.buf += d
|
|
line, self.buf = self.buf.split(b'\n', 1)
|
|
return json.loads(line)
|
|
|
|
def cmd(self, name, **args):
|
|
self.s.sendall(json.dumps({'execute': name, 'arguments': args}).encode() + b'\n')
|
|
while True:
|
|
r = self._read()
|
|
if 'return' in r:
|
|
return r['return']
|
|
if 'error' in r:
|
|
raise RuntimeError(r['error'])
|
|
|
|
def screendump(self, path):
|
|
self.cmd('screendump', filename=path)
|
|
|
|
def system_reset(self):
|
|
self.cmd('system_reset')
|
|
|
|
def system_powerdown(self):
|
|
self.cmd('system_powerdown')
|
|
|
|
|
|
class QGA:
|
|
"""Minimal QEMU guest agent client over the unix socket QEMU serves."""
|
|
|
|
def __init__(self, path):
|
|
self.path = path
|
|
|
|
def cmd(self, name, timeout=30, **args):
|
|
s = socket.socket(socket.AF_UNIX)
|
|
s.settimeout(timeout)
|
|
try:
|
|
s.connect(self.path)
|
|
s.sendall((json.dumps({'execute': name, 'arguments': args}) + '\n').encode())
|
|
buf = b''
|
|
while b'\n' not in buf:
|
|
d = s.recv(65536)
|
|
if not d:
|
|
raise OSError('guest agent closed the connection')
|
|
buf += d
|
|
finally:
|
|
s.close()
|
|
r = json.loads(buf.split(b'\n', 1)[0])
|
|
if 'error' in r:
|
|
raise RuntimeError(r['error'])
|
|
return r.get('return')
|
|
|
|
def ping(self):
|
|
try:
|
|
return self.cmd('guest-ping', timeout=5) == {}
|
|
except Exception: # noqa: BLE001
|
|
return False
|
|
|
|
def exec_start(self, command):
|
|
return self.cmd('guest-exec', path='/bin/bash', arg=['-c', command], **{'capture-output': True})['pid']
|
|
|
|
def exec_status(self, pid):
|
|
return self.cmd('guest-exec-status', pid=pid)
|
|
|
|
|
|
class Screen:
|
|
"""Screenshots over QMP with a coarse change fingerprint (cursor-insensitive)."""
|
|
|
|
def __init__(self, qmp, debug_dir, log):
|
|
self.qmp = qmp
|
|
self.dir = debug_dir
|
|
self.log = log
|
|
self.tmp = os.path.join(debug_dir, f'.grab-{os.getpid()}.ppm')
|
|
self.img = None
|
|
self.last_fp = None
|
|
self.stable_since = time.time()
|
|
self.n = 0
|
|
|
|
def grab(self):
|
|
self.qmp.screendump(self.tmp)
|
|
with open(self.tmp, 'rb') as f:
|
|
data = f.read()
|
|
if Image is None:
|
|
fp = hashlib.sha256(data).hexdigest()
|
|
else:
|
|
self.img = Image.open(io.BytesIO(data))
|
|
small = self.img.convert('L').resize((48, 36))
|
|
fp = hashlib.sha256(bytes(b & 0xF0 for b in small.tobytes())).hexdigest()
|
|
if fp != self.last_fp:
|
|
self.last_fp = fp
|
|
self.stable_since = time.time()
|
|
return self.img
|
|
|
|
def stable_for(self):
|
|
return time.time() - self.stable_since
|
|
|
|
def mean(self):
|
|
if self.img is None:
|
|
return 0
|
|
g = self.img.convert('L').resize((64, 48))
|
|
px = g.tobytes()
|
|
return sum(px) / len(px)
|
|
|
|
def is_blank(self):
|
|
return self.img is not None and self.mean() < 3
|
|
|
|
def save(self, tag):
|
|
self.n += 1
|
|
path = os.path.join(self.dir, f'{self.n:03d}-{tag}.png')
|
|
try:
|
|
if self.img is not None:
|
|
self.img.save(path)
|
|
else:
|
|
shutil.copy(self.tmp, path.replace('.png', '.ppm'))
|
|
except Exception as e: # noqa: BLE001
|
|
self.log(f'could not save screenshot: {e}')
|
|
return path
|
|
|
|
|
|
class Serial:
|
|
"""Tail the serial console file QEMU writes (-serial file:...)."""
|
|
|
|
def __init__(self, path):
|
|
self.path = path
|
|
self.pos = 0
|
|
self.last_activity = time.time()
|
|
self.boots = 0
|
|
self.reboot_at = None
|
|
|
|
def poll(self):
|
|
if not self.path or not os.path.exists(self.path):
|
|
return []
|
|
with open(self.path, 'rb') as f:
|
|
f.seek(self.pos)
|
|
data = f.read()
|
|
self.pos = f.tell()
|
|
if not data:
|
|
return []
|
|
self.last_activity = time.time()
|
|
lines = data.decode('utf-8', 'replace').replace('\r', '').split('\n')
|
|
for l in lines:
|
|
if l.startswith('Darwin Kernel Version'):
|
|
self.boots += 1
|
|
self.reboot_at = None
|
|
elif REBOOT_MARK in l:
|
|
self.reboot_at = time.time()
|
|
return lines
|
|
|
|
def idle_for(self):
|
|
return time.time() - self.last_activity
|
|
|
|
|
|
def prepare_debug_dir(path):
|
|
"""Create the debug dir; builds run as different nixbld users, so the parent
|
|
is made world-writable and a temp dir is used if the path is not writable."""
|
|
parent = os.path.dirname(path)
|
|
try:
|
|
if not os.path.isdir(parent):
|
|
os.makedirs(parent, exist_ok=True)
|
|
os.chmod(parent, 0o777)
|
|
os.makedirs(path, exist_ok=True)
|
|
os.chmod(path, 0o777)
|
|
except OSError:
|
|
path = tempfile.mkdtemp(prefix=os.path.basename(path) + '-', dir='/tmp')
|
|
os.chmod(path, 0o777)
|
|
for f in os.listdir(path):
|
|
if f.endswith(('.png', '.ppm', '.log')) or f.startswith('.grab-') or f == 'qmp.sock':
|
|
try:
|
|
os.remove(os.path.join(path, f))
|
|
except OSError:
|
|
pass
|
|
return path
|
|
|
|
|
|
def launch(qemu_args, qmp_sock, log):
|
|
if os.path.exists(qmp_sock):
|
|
os.remove(qmp_sock)
|
|
cmd = list(qemu_args) + ['-qmp', f'unix:{qmp_sock},server,nowait']
|
|
log('launching: ' + ' '.join(cmd))
|
|
return subprocess.Popen(cmd)
|
|
|
|
|
|
def disk_idle(args):
|
|
if not args.progress_file:
|
|
return True
|
|
try:
|
|
return (time.time() - os.path.getmtime(args.progress_file)) > args.disk_idle
|
|
except OSError:
|
|
return True
|
|
|
|
|
|
def run_qga(args, proc, qmp, screen, serial, log):
|
|
"""Online template: wait for the guest agent, run the command, shut down."""
|
|
import base64
|
|
qga = QGA(args.qga_sock)
|
|
start = time.time()
|
|
last_periodic = 0
|
|
while not qga.ping():
|
|
rc = proc.poll()
|
|
if rc is not None:
|
|
log(f'QEMU exited with {rc} before the guest agent came up')
|
|
return rc or 3
|
|
now = time.time()
|
|
if now - start > args.start_timeout:
|
|
try:
|
|
screen.grab()
|
|
screen.save('no-agent')
|
|
except Exception: # noqa: BLE001
|
|
pass
|
|
log(f'guest agent not reachable within {args.start_timeout:.0f}s')
|
|
proc.kill()
|
|
return 3
|
|
for line in serial.poll():
|
|
if any(m in line for m in PANIC_MARKS):
|
|
log('serial: ' + line.strip()[:200])
|
|
if now - last_periodic > args.periodic:
|
|
last_periodic = now
|
|
try:
|
|
screen.grab()
|
|
screen.save('periodic')
|
|
except Exception: # noqa: BLE001
|
|
pass
|
|
time.sleep(3)
|
|
log(f'guest agent up after {time.time() - start:.0f}s: {qga.cmd("guest-info").get("version")}')
|
|
time.sleep(args.qga_settle) # let the login session / volumes settle
|
|
try:
|
|
pid = qga.exec_start(args.qga_command)
|
|
except Exception as e: # noqa: BLE001
|
|
log(f'guest-exec failed: {e}')
|
|
proc.kill()
|
|
return 3
|
|
log(f'running command as root (pid {pid}): {args.qga_command[:160]}')
|
|
t0 = time.time()
|
|
while True:
|
|
rc = proc.poll()
|
|
if rc is not None:
|
|
log(f'QEMU exited with {rc} while the command was running')
|
|
return rc or 3
|
|
if time.time() - start > args.timeout:
|
|
log('timeout reached, killing QEMU')
|
|
proc.kill()
|
|
return 124
|
|
try:
|
|
st = qga.exec_status(pid)
|
|
except Exception as e: # noqa: BLE001
|
|
log(f'guest-exec-status failed: {e}')
|
|
time.sleep(5)
|
|
continue
|
|
if st.get('exited'):
|
|
break
|
|
now = time.time()
|
|
if now - last_periodic > args.periodic:
|
|
last_periodic = now
|
|
try:
|
|
screen.grab()
|
|
screen.save('periodic')
|
|
except Exception: # noqa: BLE001
|
|
pass
|
|
time.sleep(3)
|
|
out = base64.b64decode(st.get('out-data', '')).decode('utf-8', 'replace')
|
|
err = base64.b64decode(st.get('err-data', '')).decode('utf-8', 'replace')
|
|
code = st.get('exitcode', st.get('signal'))
|
|
log(f'command finished in {time.time() - t0:.0f}s, exit {code}')
|
|
for line in (out + err).splitlines()[-200:]:
|
|
log('guest: ' + line[:220])
|
|
try:
|
|
screen.grab()
|
|
screen.save('after-command')
|
|
except Exception: # noqa: BLE001
|
|
pass
|
|
log('shutting the guest down')
|
|
try:
|
|
qga.exec_start('sync; /sbin/shutdown -h now')
|
|
except Exception as e: # noqa: BLE001
|
|
log(f'guest shutdown failed ({e}), ACPI powerdown')
|
|
try:
|
|
qmp.system_powerdown()
|
|
except Exception: # noqa: BLE001
|
|
pass
|
|
for _ in range(180):
|
|
if proc.poll() is not None:
|
|
log('QEMU exited')
|
|
return 0 if code == 0 else 4
|
|
time.sleep(1)
|
|
log('guest did not power off, killing QEMU')
|
|
proc.kill()
|
|
return 0 if code == 0 else 4
|
|
|
|
|
|
def drive(args, proc, qmp, screen, serial, log):
|
|
start = time.time()
|
|
last_periodic = 0
|
|
resets = 0
|
|
panics = 0
|
|
started = args.mode == 'boot' # pe/install: wait for the PE marker first
|
|
blank_since = None
|
|
login_since = None
|
|
while True:
|
|
rc = proc.poll()
|
|
if rc is not None:
|
|
log(f'QEMU exited with {rc}')
|
|
return rc
|
|
now = time.time()
|
|
if now - start > args.timeout:
|
|
try:
|
|
screen.grab()
|
|
screen.save('timeout')
|
|
except Exception: # noqa: BLE001
|
|
pass
|
|
log('timeout reached, killing QEMU')
|
|
proc.kill()
|
|
return 124
|
|
time.sleep(args.interval)
|
|
|
|
panic = False
|
|
for line in serial.poll():
|
|
if 'VMIX' in line:
|
|
log('serial: ' + line.strip()[:220])
|
|
if 'VMIX-PE: running run.sh' in line and not started:
|
|
started = True
|
|
log('PE started run.sh')
|
|
if 'VMIX-PE: no VMIX volume' in line and args.mode != 'boot':
|
|
log('PE did not find the VMIX volume')
|
|
proc.kill()
|
|
return 3
|
|
if line.startswith('Darwin Kernel Version'):
|
|
log(f'guest kernel boot #{serial.boots}')
|
|
if any(m in line for m in PANIC_MARKS):
|
|
panic = True
|
|
log('serial: ' + line.strip()[:220])
|
|
if panic:
|
|
panics += 1
|
|
try:
|
|
screen.grab()
|
|
screen.save('panic')
|
|
except Exception: # noqa: BLE001
|
|
pass
|
|
if panics > args.max_resets:
|
|
log(f'kernel panic #{panics}, giving up')
|
|
proc.kill()
|
|
return 3
|
|
# XNU reboots by itself after a panic; only reset if no kernel comes back.
|
|
# (In pe mode the PE then runs run.sh again — templates are idempotent.)
|
|
log(f'kernel panic #{panics}, waiting for the guest to reboot')
|
|
serial.reboot_at = now
|
|
continue
|
|
|
|
# The guest asked for a reboot but no kernel came back: macOS' restart
|
|
# path panics in QEMU (AppleSMC watchdog keys, see README); reset now
|
|
# instead of waiting for the frozen-screen watchdog.
|
|
if serial.reboot_at and now - serial.reboot_at > args.reboot_timeout:
|
|
resets += 1
|
|
try:
|
|
screen.grab()
|
|
screen.save('reboot-dead')
|
|
except Exception: # noqa: BLE001
|
|
pass
|
|
log(f'guest requested a reboot {now - serial.reboot_at:.0f}s ago and died, system_reset #{resets}')
|
|
serial.reboot_at = None
|
|
if resets > args.max_resets:
|
|
proc.kill()
|
|
return 3
|
|
qmp.system_reset()
|
|
screen.stable_since = time.time()
|
|
continue
|
|
|
|
if not started and now - start > args.start_timeout:
|
|
try:
|
|
screen.grab()
|
|
screen.save('no-start')
|
|
except Exception: # noqa: BLE001
|
|
pass
|
|
log(f'PE did not start run.sh within {args.start_timeout:.0f}s')
|
|
proc.kill()
|
|
return 3
|
|
|
|
try:
|
|
screen.grab()
|
|
except Exception as e: # noqa: BLE001
|
|
log(f'screendump failed ({e})')
|
|
time.sleep(2)
|
|
continue
|
|
if now - last_periodic > args.periodic:
|
|
last_periodic = now
|
|
screen.save('periodic')
|
|
|
|
if args.mode == 'pe':
|
|
continue # the PE powers off by itself; nothing to decide
|
|
# install: the PE phase (kernel boot #1) is protected by the guest's own
|
|
# retries; the checks below apply once the installer has rebooted.
|
|
in_os = args.mode == 'boot' or serial.boots >= 2
|
|
if not in_os or screen.stable_for() < args.settle:
|
|
continue
|
|
idle = disk_idle(args) and serial.idle_for() > args.disk_idle
|
|
|
|
if screen.is_blank():
|
|
blank_since = blank_since or now
|
|
# macOS `shutdown -h` halts to a black screen without an ACPI power-off
|
|
if now - blank_since > args.halt_timeout and idle:
|
|
screen.save('halt')
|
|
log(f'guest halted (black {now - blank_since:.0f}s, idle); killing QEMU')
|
|
proc.kill()
|
|
return 0
|
|
continue
|
|
blank_since = None
|
|
|
|
if screen.mean() > args.bright:
|
|
# loginwindow / Setup Assistant: the OS is installed and booted
|
|
if login_since is None:
|
|
login_since = now
|
|
log(f'bright screen (mean {screen.mean():.0f}): loginwindow/desktop, grace {args.login_grace:.0f}s')
|
|
elif now - login_since > args.login_grace:
|
|
screen.save('loginwindow')
|
|
log('powering down (boot complete)')
|
|
try:
|
|
qmp.system_powerdown()
|
|
except Exception as e: # noqa: BLE001
|
|
log(f'powerdown failed: {e}')
|
|
for _ in range(120):
|
|
if proc.poll() is not None:
|
|
log('QEMU exited after powerdown')
|
|
return 0
|
|
time.sleep(1)
|
|
# macOS ignores the power button at the Setup Assistant; the
|
|
# volumes are journaled (APFS) and the PE mounts them cleanly next
|
|
log('guest ignores the ACPI power button here (Setup Assistant); stopping QEMU')
|
|
proc.kill()
|
|
return 0
|
|
continue
|
|
login_since = None
|
|
|
|
# dark, frozen, nothing happening: a boot hang (seen at the Apple logo)
|
|
if screen.stable_for() > args.stall_reset and idle and resets < args.max_resets:
|
|
resets += 1
|
|
screen.save('stall-reset')
|
|
log(f'boot hung ({screen.stable_for():.0f}s frozen, dark, idle), system_reset #{resets}')
|
|
try:
|
|
qmp.system_reset()
|
|
except Exception as e: # noqa: BLE001
|
|
log(f'system_reset failed: {e}')
|
|
screen.stable_since = time.time()
|
|
|
|
|
|
def main():
|
|
p = argparse.ArgumentParser()
|
|
p.add_argument('--mode', choices=['pe', 'install', 'boot', 'qga'], required=True)
|
|
p.add_argument('--qga-sock', default=None, help='guest agent unix socket (mode qga)')
|
|
p.add_argument('--qga-command', default=None, help='bash command to run as root through the guest agent (mode qga)')
|
|
p.add_argument('--qga-settle', type=float, default=20.0, help='seconds to wait after the agent answers before running the command')
|
|
p.add_argument('--name', default='macos')
|
|
p.add_argument('--debug-dir', default=None)
|
|
p.add_argument('--serial-log', default=None, help='file QEMU writes the serial console to')
|
|
p.add_argument('--timeout', type=int, default=4 * 3600, help='seconds before QEMU is killed')
|
|
p.add_argument('--start-timeout', type=float, default=600.0, help='seconds for the PE to start run.sh')
|
|
p.add_argument('--interval', type=float, default=5.0, help='seconds between screenshots')
|
|
p.add_argument('--periodic', type=float, default=120.0, help='seconds between saved debug screenshots')
|
|
p.add_argument('--settle', type=float, default=12.0, help='seconds a screen must be unchanged to act on it')
|
|
p.add_argument('--stall-reset', type=float, default=360.0, help='reset the VM if a dark screen is frozen this long while disk and serial are idle')
|
|
p.add_argument('--max-resets', type=int, default=6)
|
|
p.add_argument('--reboot-timeout', type=float, default=60.0, help='seconds after a guest reboot request without a new kernel boot before the VM is reset')
|
|
p.add_argument('--halt-timeout', type=float, default=150.0, help='a pure-black, idle screen this long means the guest halted')
|
|
p.add_argument('--progress-file', default=None, help='the system disk; its mtime shows guest disk activity')
|
|
p.add_argument('--disk-idle', type=float, default=90.0, help='seconds without disk/serial activity that count as idle')
|
|
p.add_argument('--bright', type=float, default=80.0, help='mean brightness above which a screen is the loginwindow/desktop')
|
|
p.add_argument('--login-grace', type=float, default=180.0, help='seconds a bright screen must persist before powering down')
|
|
p.add_argument('qemu', nargs=argparse.REMAINDER)
|
|
args = p.parse_args()
|
|
qemu_args = [a for a in args.qemu if a != '--']
|
|
if not qemu_args:
|
|
p.error('QEMU command line required after --')
|
|
|
|
debug_dir = args.debug_dir or f'/tmp/vmix-macos/{args.name}'
|
|
debug_dir = prepare_debug_dir(debug_dir)
|
|
log = Log(os.path.join(debug_dir, 'driver.log'))
|
|
log(f'mode={args.mode} debug-dir={debug_dir} serial={args.serial_log}')
|
|
|
|
qmp_sock = os.path.join(debug_dir, 'qmp.sock')
|
|
proc = launch(qemu_args, qmp_sock, log)
|
|
qmp = QMP(qmp_sock)
|
|
try:
|
|
qmp.connect()
|
|
except Exception as e: # noqa: BLE001
|
|
log(f'QMP connect failed: {e}')
|
|
proc.kill()
|
|
return 2
|
|
screen = Screen(qmp, debug_dir, log)
|
|
serial = Serial(args.serial_log)
|
|
try:
|
|
if args.mode == 'qga':
|
|
rc = run_qga(args, proc, qmp, screen, serial, log)
|
|
else:
|
|
rc = drive(args, proc, qmp, screen, serial, log)
|
|
finally:
|
|
if args.serial_log and os.path.exists(args.serial_log):
|
|
try:
|
|
shutil.copy(args.serial_log, os.path.join(debug_dir, 'serial.log'))
|
|
except OSError:
|
|
pass
|
|
try:
|
|
if proc.poll() is None:
|
|
proc.kill()
|
|
except Exception: # noqa: BLE001
|
|
pass
|
|
log(f'done rc={rc}')
|
|
return rc
|
|
|
|
|
|
if __name__ == '__main__':
|
|
sys.exit(main())
|