#!/usr/bin/env python3 """Twelve real virtual USB disks: boot, concurrent I/O, hotplug and native power.""" import hashlib import io import itertools import json from pathlib import Path import re import shlex import statistics import subprocess import sys import tarfile import tempfile import time project = Path(__file__).resolve().parents[2] sys.path.insert(0, str(project/'tools')) from vm_test import VM from image_formats import gpt, digest, LINUX_FILESYSTEM work = Path(tempfile.mkdtemp(prefix='m11-vm.', dir=project/'out')) inputs=[Path(__file__),project/'tools/vm_test.py',project/'out/rootfs-cli.tar', project/'out/kernel/boot/kernel_2712.img',project/'out/fds-initramfs.img'] (work/'inputs.sha256').write_text(''.join(f'{digest(path)} {path.relative_to(project)}\n' for path in inputs)) (work/'qemu-version.txt').write_bytes(subprocess.check_output([str(project/'tools/in-void'),'qemu-system-aarch64','--version'])) controller = 'qemu-xhci,id=xhci,addr=05.0,p2=12,p3=12' sequence = itertools.count() def query(vm, args): return json.loads(vm.capture('fds --json '+shlex.join(args))) def wait(vm, read, predicate, timeout=60): deadline = time.monotonic()+timeout while True: value = read() if predicate(value): return value assert time.monotonic()/tmp/m11-bays.err; true') if text:return json.loads(text) error=vm.capture('cat /tmp/m11-bays.err') assert 'Cartridge service unavailable' in error,error return None report = wait(vm, inventory, lambda r:bool(r) and all( b['state']==('mounted_read_write' if placement.get(b['bay'])==1 else 'mounted_read_only' if b['bay'] in placement else 'empty') for b in r['bays'])) assert len(report['bays'])==12 for b in report['bays']: if b['bay'] not in placement: continue number=placement[b['bay']] assert b['manifest']['cartridge']['id']==f'fds.m11.{number:02}',b assert len(b['devices'])==1 and b['devices'][0]['serial']==f'M11-{number:02}',b assert b['devices'][0]['topology'].endswith('/'+str(b['bay'])),b return report def finish(vm, reboot=False): vm.send('fds '+('reboot' if reboot else 'poweroff')) report=json.loads(vm.expect(rb'^FDS_SHUTDOWN_FINAL (\{[^\r\n]*\})\r?\n',timeout=90).group(1)) assert [e['phase'] for e in report['events']]==[ 'frozen','stopping_desktop','stopping_programs','stopping_network', 'syncing_and_unmounting_data','unmounting_cartridges','prepared','services_stopped'],report ending=vm.expect(rb'\[\s*([0-9]+\.[0-9]+)\] reboot: (Power down|Restarting system)') assert (ending.group(2)==b'Restarting system')==reboot assert vm.child.wait(timeout=20)==0 report['virtual_total_ms']=float(ending.group(1))*1000-report['events'][0]['at_ns']/1e6 (work/f'{vm.name}-shutdown.json').write_text(json.dumps(report,indent=2)+'\n') # Calibrate from live descriptors; never infer bay identity from /dev/sdX. with VM(work,'probe',project/'out/fds-system-cli.img',extra=['-device',controller,'-device','usb-kbd,bus=xhci.0,port=12']) as vm: vm.expect(rb'FDS> ') report=wait(vm,lambda:query(vm,['topology']),lambda r:any('03' in d['interfaces'] for d in r['unmapped'])) device=next(d for d in report['unmapped'] if '03' in d['interfaces']) assert device['topology'].endswith('/12'),device hub=device['topology'].rsplit('/',1)[0] (work/'probe-topology.json').write_text(json.dumps(report,indent=2)+'\n') finish(vm) config='' for name,identity in [('usb2',hub),('usb3',hub.replace(':usb2',':usb3'))]: config+=f'[{name}]\nhub={json.dumps(identity)}\n[{name}.ports]\n'+''.join(f'{n}={n}\n' for n in range(1,13)) def fixture(name, root_console=False, gated=False): replacements={'etc/fds/bays.toml':config.encode()} if root_console: replacements['usr/libexec/fds/console-session']=b'#!/bin/bash\n# Isolated test image only.\nexec env HOME=/root bash --login\n' additions={'usr/libexec/fds/m11-stress':(project/'out/m11-stress').read_bytes(), 'usr/libexec/fds/m11-writer':(project/'out/m7-writer').read_bytes(), 'usr/libexec/fds/m11-capture-hardware':(project/'tools/capture-hardware').read_bytes()} if gated: with tarfile.open(project/'out/rootfs-cli.tar') as original: additions['usr/libexec/fds/m11-daemon-real']=original.extractfile('usr/bin/fds-cartridged').read() replacements['usr/bin/fds-cartridged']=b'#!/bin/bash\nset -euo pipefail\nmkfifo -m 0666 /run/m11-gate\nprintf "M11_DAEMON_WAIT\\n" >/dev/console\nIFS= read -r release ') assert vm.capture('id -u')=='1000' trace=query(vm,['boot-profile']);reports[name].append(trace) (work/f'{vm.name}-boot.json').write_text(json.dumps(trace,indent=2)+'\n') (work/f'{vm.name}-bays.json').write_text(json.dumps(ready(vm,placement),indent=2)+'\n') finish(vm) print(f'PASS: cold boot pair {iteration+1}: empty and twelve USB disks, stable identities and native shutdown',flush=True) comparison={name:{'samples_ms':[r['durations_ns']['kernel-to-console']/1e6 for r in rows], 'median_ms':statistics.median(r['durations_ns']['kernel-to-console']/1e6 for r in rows)} for name,rows in reports.items()} comparison['change_ms']=comparison['twelve']['median_ms']-comparison['empty']['median_ms'] (work/'boot-comparison.json').write_text(json.dumps(comparison,indent=2)+'\n') print(f'OBSERVED: twelve-device VM median console change {comparison["change_ms"]:+.3f} ms; physical timing deferred',flush=True) # A real readiness gate proves that full cartridge enumeration is not a # prerequisite of the ordinary user's prompt, even with all bays populated. gated=fixture('gated',gated=True) with VM(work,'blocked-cartridged-twelve',gated,extra=cold_args(identity)) as vm: vm.expect(rb'FDS> ') if b'M11_DAEMON_WAIT' not in vm.data:vm.expect(rb'M11_DAEMON_WAIT') vm.capture('test "$(id -u)" = 1000 && test ! -e /run/fds/control.sock') (work/'gated-boot.json').write_text(json.dumps(query(vm,['boot-profile']),indent=2)+'\n') vm.capture('printf "release\\n" >/run/m11-gate') ready(vm,identity);finish(vm) print('PASS: ordinary-user prompt is independent of blocked cartridge readiness with twelve devices present',flush=True) # The same packaged image and controller are compared under different device # loads. Keep the actual median sample reports and the required explanation. explanation=('Controlled twelve-device load adds concurrent kernel USB work and cartridge scanning to ' 'the same two-vCPU VM. Stage0 discovery and the service graph are unchanged; the blocked ' 'twelve-device cartridge test proves console independence. This is measured device-load ' 'overhead, not a Pi timing claim. All raw samples, including variability, are retained.') for name,rows in reports.items(): median=sorted(rows,key=lambda r:r['durations_ns']['kernel-to-console'])[len(rows)//2] (work/f'{name}-median.json').write_text(json.dumps(median,indent=2)+'\n') with (work/'boot-review.log').open('wb') as log: subprocess.run([str(project/'tools/in-void'),'qemu-aarch64',str(project/'out/fds-boottrace'),'compare', str(work/'empty-median.json'),str(work/'twelve-median.json'), *(['--explain',explanation] if comparison['change_ms']>100 else [])],check=True,stdout=log,stderr=subprocess.STDOUT) with VM(work,'hotplug-io-reboot',admin,extra=['-device',controller]) as vm: vm.expect(rb'FDS# ') for iteration in range(3): placement={port:((port+iteration-1)%12)+1 for port in reversed(range(1,13))} insert(vm,placement) report=ready(vm,placement) (work/f'hot-{iteration}-bays.json').write_text(json.dumps(report,indent=2)+'\n') data_bay=next(port for port,n in placement.items() if n==1) # SAFE must not follow a port's previous occupant when devices rotate. assert all(b['state']!='safe' for b in report['bays']) query(vm,['eject',str(data_bay)]) remove_all(vm) print(f'PASS: simultaneous hotplug cycle {iteration+1}; reverse insertion order and rotated cartridge identities',flush=True) insert(vm,identity);ready(vm,identity) before=vm.qmp('query-blockstats') vm.capture('echo 3 >/proc/sys/vm/drop_caches') paths=[f'/run/fds/apps/fds.m11.{n:02}/payload.bin' for n in range(2,13)] rows=[json.loads(line) for line in vm.capture('s6-setuidgid fds /usr/libexec/fds/m11-stress '+shlex.join(paths)).splitlines()] assert len(rows)==12 and {r['bay'] for r in rows}==set(range(1,13)),rows assert max(r['start_ns'] for r in rows)=minimum,(port,metric,old[key],new[key]) kernel_check(vm,'concurrent') vm.capture('/bin/bash /usr/libexec/fds/m11-capture-hardware /tmp/m11-capture && cd /tmp/m11-capture && sha256sum -c SHA256SUMS && test "$(stat -c %a .)" = 700') (work/'hardware-capture-status.tsv').write_text(vm.capture('cat /tmp/m11-capture/status.tsv')+'\n') print('PASS: all twelve I/O intervals overlap; eleven direct readers verify payloads while DATA writes and flushes 64 MiB',flush=True) query(vm,['run','1','--','/usr/libexec/fds/m11-writer']) wait(vm,lambda:vm.capture('cat /data/progress 2>/dev/null || printf 0'),lambda s:s.isdigit() and int(s)>=128) finish(vm,reboot=True) # Verify DATA independently, then boot the exact disks in another device order. part=layouts[1]['partitions'][0];payload=work/'data-after.ext4' with media[1].open('rb') as src,payload.open('wb') as dst: src.seek(part['start']*512);left=part['payload_bytes'] while left: chunk=src.read(min(left,1024*1024));assert chunk;dst.write(chunk);left-=len(chunk) with (work/'data-fsck.log').open('wb') as log:subprocess.run(['e2fsck','-fn',str(payload)],check=True,stdout=log,stderr=subprocess.STDOUT) written=work/'stress-after.bin' subprocess.run(['debugfs','-R',f'dump /stress.bin {written}',str(payload)],check=True,stdout=subprocess.DEVNULL) assert written.stat().st_size==64*1024*1024 assert digest(written)==hashlib.sha256(bytes(range(256))*(64*1024*1024//256)).hexdigest() with VM(work,'reboot-followup',admin,extra=cold_args(dict(reversed(list(identity.items()))))) as vm: vm.expect(rb'FDS# ');ready(vm,identity) assert vm.capture('stat -c %s /data/stress.bin')==str(64*1024*1024) kernel_check(vm,'reboot-followup') finish(vm) for path,checksum in unchanged.items():assert digest(path)==checksum,path link=project/'out/m11-vm-latest';link.unlink(missing_ok=True);link.symlink_to(work.name) print(f'PASS: M11 twelve-device virtual stress: {work}',flush=True) print('SKIP: physical hub wiring, USB voltage/current/reset behavior, RP1, battery and Pi timing')