ui_panel was written for this screen during the loader restyle and then never called -- dead code sitting in boot/common/ui.c waiting for the row that would use it. This is that row: F8 -> System Information, showing firmware vendor and revision, UEFI revision, CPU brand, usable memory, current mode and whether ACPI was found. Everything is read when the screen is drawn rather than captured at startup, because the resolution row above it can have changed the mode, and a panel still reporting what the machine booted with is worse than no panel at all. Two facts are deliberately read through the same code that feeds the kernel -- efi_type_to_e820() for what counts as usable memory, and find_rsdp() for ACPI. A panel with its own idea of either would be a second implementation to keep in step, and the ACPI line exists precisely because a missing RSDP means no MADT, no IOAPIC and no SMP, which has already been debugged once from the far end. The screenshot caught a bug in the screen it was taken to verify: UEFI revision read "21587.2286". EFI_TABLE_HEADER begins with a UINT64 Signature, so Revision is at offset 8 and I had read offset 4 -- the upper half of "IBI SYST", formatted as a version number. It now reads 2.7 under OVMF, which is correct. Verified by driving the menu over the QEMU monitor and reading the pixels: five rows with the hint updated to 1-5, the panel centred, and every value cross-checkable -- EDK II, firmware 1.0, UEFI 2.7, "QEMU Virtual CPU version 2.5+" against -cpu qemu64, 1018 MB of the 1 GB given, 1280x800 matching what fbshot captures, ACPI present. The highlight sits 9 px inside both panel borders, symmetric. F8 has a 1.5 s window, so the capture presses it repeatedly through boot rather than guessing when OVMF finishes -- and only F8, since any other key breaks the wait and boots. Also: every ESP drive now says format=raw. QEMU warned about probing the format on each of the fifteen or so places that attach it, which is new noise on what is now the only boot path. 110 [ok], no failures, no warnings.
683 lines
32 KiB
PowerShell
683 lines
32 KiB
PowerShell
# Headless boot check: run SchOS under QEMU, type commands at the shell, and
|
|
# assert on what actually comes back.
|
|
#
|
|
# Booting to a prompt proves nothing on its own -- a filesystem can mount and
|
|
# still fail on the first real command, and exactly that has happened here
|
|
# before. So this drives the serial console and greps the transcript for the
|
|
# answers those commands should produce.
|
|
#
|
|
# The console is stdio with a chardev logfile: QEMU writes every byte of the
|
|
# session to a file itself, so this script only ever has to *write* to stdin.
|
|
# Reading QEMU's stdout from PowerShell needs a reader that cannot block the
|
|
# writer, and two attempts at that (a background task, then a duplex named
|
|
# pipe) both deadlocked. Letting QEMU do the logging removes the problem.
|
|
#
|
|
# QEMU's serial line has no flow control: a burst of characters gets dropped,
|
|
# and anything typed before the shell is listening is discarded outright. Hence
|
|
# the pacing below -- not superstition, just the difference between a reliable
|
|
# check and a flaky one.
|
|
#
|
|
# tools/bootcheck.ps1
|
|
# tools/bootcheck.ps1 -Tty -Ps
|
|
|
|
param(
|
|
[switch]$Fb,
|
|
# Boot schos.iso from an emulated optical drive with NO hard disk
|
|
# attached, which is the only way to prove the ISO is self-contained.
|
|
# Needs `make iso` first.
|
|
[switch]$Iso,
|
|
# Accepted and ignored. It used to select OVMF over the BIOS loader; there
|
|
# is only the one path now, so it names a choice that no longer exists.
|
|
[switch]$Uefi,
|
|
# Terminal behaviour: pipes, isatty, SIGPIPE, and Ctrl-C. None of these
|
|
# can be checked by looking at a screenshot, and all of them were broken
|
|
# in ways that looked like broken *commands*.
|
|
[switch]$Tty,
|
|
# Attach an xHCI controller with a keyboard, a mouse and a mass storage
|
|
# device on it. Nothing on the default machine has a USB port, so without
|
|
# this the USB driver is never asked to do anything and a broken one looks
|
|
# exactly like a working one.
|
|
[switch]$Usb,
|
|
# Unix domain sockets. Nothing in the default command set opens one, and a
|
|
# socket layer that accepted every call and dropped every byte would look
|
|
# identical from the outside -- socktest has one process write and another
|
|
# read, which is the only check that can tell the difference.
|
|
[switch]$Sock,
|
|
# Raw input events. Needs the QEMU monitor for the same reason the USB run
|
|
# does: a key has to be *pressed*, and characters typed at the serial port
|
|
# never touch the keyboard controller at all.
|
|
[switch]$Evdev,
|
|
# Process enumeration. /proc is a synthetic filesystem now, and the thing
|
|
# it exists for -- one directory per process -- is exactly what nothing
|
|
# else in this suite touches.
|
|
[switch]$Ps,
|
|
[int]$BootSeconds = 20,
|
|
[string]$Qemu
|
|
)
|
|
|
|
# QEMU, on whichever host this is.
|
|
#
|
|
# PowerShell 7 runs on Linux, macOS and the BSDs as well as Windows, so these
|
|
# scripts are usable from all of them -- but the old default was a hardcoded
|
|
# scoop path under $env:USERPROFILE, which exists on exactly one of those. PATH
|
|
# first, because a package-managed qemu is on it everywhere; the scoop location
|
|
# is only a fallback for the Windows install that does not add itself to PATH.
|
|
function Find-Qemu {
|
|
$exe = if ($IsWindows -or $env:OS -eq 'Windows_NT') { 'qemu-system-x86_64.exe' }
|
|
else { 'qemu-system-x86_64' }
|
|
$onPath = Get-Command $exe -ErrorAction SilentlyContinue
|
|
if ($onPath) { return $onPath.Source }
|
|
if ($env:USERPROFILE) {
|
|
$scoop = Join-Path (Join-Path $env:USERPROFILE 'scoop/apps/qemu/current') $exe
|
|
if (Test-Path $scoop) { return $scoop }
|
|
}
|
|
foreach ($p in @('/usr/bin', '/usr/local/bin', '/opt/homebrew/bin')) {
|
|
$c = Join-Path $p $exe
|
|
if (Test-Path $c) { return $c }
|
|
}
|
|
throw "qemu-system-x86_64 not found on PATH. Install QEMU, or pass -Qemu <path>."
|
|
}
|
|
|
|
if (-not $Qemu) { $Qemu = Find-Qemu }
|
|
|
|
$ErrorActionPreference = 'Stop'
|
|
# Repo root is two levels up: this script lives in tools/windows/.
|
|
$root = Split-Path (Split-Path $PSScriptRoot -Parent) -Parent
|
|
# The root filesystem. SchFS is the only root there is, so this is not a
|
|
# choice -- a kernel handed anything else on device 1 panics in forkret().
|
|
$hdb = Join-Path $root 'build/schfs.img'
|
|
$log = Join-Path $root 'build/bootcheck.log'
|
|
# Where the running QEMU's pid is recorded, so the *next* run can clean up after
|
|
# one that never got to its `finally` -- see reap_stale_qemu below.
|
|
$pidfile = Join-Path $root 'build/bootcheck-qemu.pid'
|
|
|
|
Remove-Item -Force $log -ErrorAction SilentlyContinue
|
|
|
|
# Kill a QEMU left over from a previous run.
|
|
#
|
|
# The `finally` at the bottom stops QEMU on every path the script controls, but
|
|
# not when the script itself is killed -- a CI timeout, Ctrl-C at the wrong
|
|
# moment, `pkill pwsh`. QEMU then keeps running with no parent, and because
|
|
# these tests run under TCG rather than KVM, a spare emulator pinning a core
|
|
# makes every *later* run slower. Commands that normally answer in a second
|
|
# drift past Wait-Prompt's timeout, the next command is typed into a busy
|
|
# shell, and the transcript interleaves -- which surfaces as a failed check
|
|
# somewhere unrelated to whatever actually changed. A test environment that
|
|
# silently degrades is worse than one that fails loudly, so each run reaps the
|
|
# last one's leftovers before starting.
|
|
function Reap-StaleQemu {
|
|
if (-not (Test-Path $pidfile)) { return }
|
|
$stale = (Get-Content $pidfile -Raw -ErrorAction SilentlyContinue).Trim()
|
|
Remove-Item -Force $pidfile -ErrorAction SilentlyContinue
|
|
if (-not $stale) { return }
|
|
$proc = Get-Process -Id ([int]$stale) -ErrorAction SilentlyContinue
|
|
# Check the name too: pids are recycled, and killing an unrelated process
|
|
# because it inherited the number would be a far worse bug than the leak.
|
|
if ($proc -and $proc.ProcessName -like 'qemu*') {
|
|
Write-Host " reaping leaked QEMU from a previous run (pid $stale)" -ForegroundColor Yellow
|
|
try { $proc.Kill() } catch { }
|
|
}
|
|
}
|
|
Reap-StaleQemu
|
|
|
|
# The default run exercises the *root* filesystem: create a file, read it back,
|
|
# grow it, list a directory, seek around it. Booting to a prompt proves nothing
|
|
# on its own, and every one of these has broken at some point while the boot
|
|
# still looked perfectly healthy.
|
|
$commands = @(
|
|
'echo hello-from-schos > /tmp/bc.txt'
|
|
'cat /tmp/bc.txt'
|
|
'echo second-line >> /tmp/bc.txt'
|
|
'wc -c /tmp/bc.txt'
|
|
'mkdir /tmp/bcdir'
|
|
'cp /tmp/bc.txt /tmp/bcdir/copy.txt'
|
|
'cat /tmp/bcdir/copy.txt'
|
|
'ls /tmp/bcdir'
|
|
'rm /tmp/bcdir/copy.txt'
|
|
'rmdir /tmp/bcdir'
|
|
'ls /bin | wc -l'
|
|
'cat /proc/pci'
|
|
)
|
|
# Every command above already went to SchFS. What is left is to show that
|
|
# it is SchFS they went to, and that the volume noticed: the generation
|
|
# counter must advance across the writes, which is the one thing a
|
|
# read-only mount or a silently-discarded write could not fake.
|
|
$commands += 'cat /proc/schfs'
|
|
# Mounting the root device a second time has to fail. It is the check that
|
|
# tells "the volume is genuinely mounted as /" apart from "it happens to be
|
|
# readable", and getting EBUSY here is the correct answer, not a defect.
|
|
$commands += 'mkdir /mnt'
|
|
$commands += 'mount -t schfs /dev/hdb /mnt'
|
|
# A deep path built and torn down entirely on the root volume.
|
|
$commands += 'mkdir /tmp/rootdir'
|
|
$commands += 'mkdir /tmp/rootdir/sub'
|
|
$commands += 'echo schfs-is-root > /tmp/rootdir/sub/deep.txt'
|
|
$commands += 'cat /tmp/rootdir/sub/deep.txt'
|
|
$commands += 'ls /tmp/rootdir/sub'
|
|
$commands += 'rm /tmp/rootdir/sub/deep.txt'
|
|
$commands += 'rmdir /tmp/rootdir/sub'
|
|
$commands += 'rmdir /tmp/rootdir'
|
|
# A binary loaded off the root volume, not a shell builtin: exec() reading
|
|
# an ELF through schfs_ops is the path that carried init itself.
|
|
$commands += 'wc -l /etc/motd'
|
|
# The second dump. The generation counter is compared against the first
|
|
# one: on a copy-on-write volume it moves on every committed transaction,
|
|
# so a root that silently discarded its writes would still pass every read
|
|
# above and fail here.
|
|
$commands += 'cat /proc/schfs'
|
|
|
|
# init must survive signals it has no handler for.
|
|
#
|
|
# This is a crash-the-whole-machine bug, not a nicety: Ctrl-C sends SIGINT to
|
|
# the foreground process group, init is pgid 1, and any window where
|
|
# tty_fg_pgrp is 1 lands it on init -- which had no handler, so killed was set,
|
|
# exit() ran, and exit() panics outright on initproc. Spamming Ctrl-C at a
|
|
# frozen program is what finds that window.
|
|
#
|
|
# SIGKILL is in here deliberately: init is not killable from userspace on any
|
|
# Unix, and a guard that covers SIGINT but not SIGKILL is half a fix.
|
|
$commands += 'kill -INT 1'
|
|
$commands += 'kill -KILL 1'
|
|
$commands += 'kill -TERM 1'
|
|
$commands += 'echo INIT-SURVIVED'
|
|
$commands += 'ps | head -3'
|
|
|
|
# KeepAlive. Exiting the shell must bring a new one back -- that is the
|
|
# supervision loop doing its job, and it is the only part of schinit that
|
|
# cannot be checked by reading its startup output.
|
|
$commands += 'echo BEFORE-EXIT'
|
|
$commands += 'exit'
|
|
$commands += 'echo SHELL-RESPAWNED'
|
|
|
|
if ($Fb) {
|
|
# /dev/fb0. The geometry has to come back from the mode the bootloader
|
|
# actually programmed -- three places in the kernel used to assume
|
|
# 0xfd000000 at 800x600x32, and nothing would have said when that stopped
|
|
# being true. `bars` goes through the device's write(), `fill` through
|
|
# mmap, so a failure names which of the two paths broke.
|
|
$commands += 'fbinfo'
|
|
$commands += 'fbinfo bars'
|
|
$commands += 'fbinfo fill'
|
|
}
|
|
if ($Tty) {
|
|
# `ls` piped into `wc -l` reported 48 rather than 240 because ioctl
|
|
# answered TIOCGWINSZ on a *pipe* with success, so ls formatted in
|
|
# columns. `yes` failed outright because writev capped iovcnt at 16 and
|
|
# toybox builds 256 of them. `cat | head` killed the shell, because
|
|
# SIG_IGN is handler value 1 and signal delivery jumped to address 1.
|
|
# Three different-looking bugs, none of them in the commands.
|
|
$commands += 'ls /bin | wc -l'
|
|
$commands += 'yes | head -2'
|
|
# 4 bytes, not 3: an ELF file starts with 0x7F 'E' 'L' 'F', so three bytes
|
|
# stops at "EL" and the invisible leading byte makes that easy to misread.
|
|
$commands += 'cat /bin/toybox | head -c 4; echo'
|
|
$commands += 'printf ''caf\303\251-\303\261-\303\266\n'''
|
|
$commands += 'echo TTY-SURVIVED'
|
|
# Shared memory and file-backed mmap: the two things a display server
|
|
# cannot be written without, and the two whose failure mode is a
|
|
# successful-looking return value.
|
|
$commands += 'shmtest'
|
|
}
|
|
if ($Usb) {
|
|
# /proc/interrupts is how you tell a registered handler from one that
|
|
# merely compiled. A line with a zero count is a device that was found and
|
|
# never heard from.
|
|
$commands += 'cat /proc/interrupts'
|
|
# Read a real block off the USB stick, through the buffer cache. Answering
|
|
# INQUIRY proves a device is there; only this proves it can read.
|
|
$commands += 'echo s > /proc/sysrq'
|
|
}
|
|
if ($Sock) {
|
|
$commands += 'socktest'
|
|
}
|
|
if ($Ps) {
|
|
$commands += 'ls /proc'
|
|
$commands += 'cat /proc/1/stat'
|
|
$commands += 'ps'
|
|
$commands += 'ps -A -o pid,ppid,comm'
|
|
$commands += 'top -b -n 1 | head -6'
|
|
}
|
|
$commands += 'echo MARKER-DONE'
|
|
|
|
# The ISO run attaches no hard disk at all. That is the whole point: a kernel
|
|
# that still quietly needs one would boot happily here otherwise, and only
|
|
# fail on someone else's machine.
|
|
$isoPath = Join-Path $root 'schos.iso'
|
|
if ($Iso -and -not (Test-Path $isoPath)) { throw "no $isoPath -- run 'make iso' first" }
|
|
|
|
# OVMF is not optional any more: the BIOS loader is gone, so every run boots
|
|
# through firmware. -Uefi is still accepted and does nothing, because it named
|
|
# a choice that no longer exists.
|
|
$code = Join-Path $root 'build/OVMF_CODE.fd'
|
|
$vars = Join-Path $root 'build/OVMF_VARS.fd'
|
|
foreach ($f in @($code, $vars)) {
|
|
if (-not (Test-Path $f)) { throw "no $f -- run 'make qemu-uefi' once to fetch OVMF" }
|
|
}
|
|
# The varstore is written by the firmware, so each run gets its own copy.
|
|
# Sharing one would let a run that changed a UEFI variable -- boot order,
|
|
# console mode -- decide how the next run behaves, which is exactly the kind of
|
|
# dependency between runs that makes a suite stop being evidence.
|
|
$runVars = Join-Path $root 'build/bootcheck-OVMF_VARS.fd'
|
|
Copy-Item -Force $vars $runVars
|
|
|
|
$firmware = @("-drive if=pflash,format=raw,unit=0,readonly=on,file=`"$code`""
|
|
"-drive if=pflash,format=raw,unit=1,file=`"$runVars`"")
|
|
|
|
$drives = if ($Iso) {
|
|
# No hard disk at all, so a kernel that still secretly depends on one fails
|
|
# here rather than in someone's optical drive.
|
|
$firmware + @("-cdrom `"$isoPath`"", '-boot d')
|
|
} else {
|
|
$esp = Join-Path $root 'build/esp'
|
|
if (-not (Test-Path (Join-Path $esp 'EFI/BOOT/BOOTX64.EFI'))) {
|
|
throw "no staged ESP at $esp -- run 'make esp' first"
|
|
}
|
|
$firmware + @("-drive file=fat:rw:`"$esp`",index=0,media=disk,format=raw"
|
|
"-drive file=`"$hdb`",format=raw,index=1,media=disk")
|
|
}
|
|
|
|
$psi = New-Object System.Diagnostics.ProcessStartInfo
|
|
$psi.FileName = $Qemu
|
|
|
|
$usbDevices = if ($Usb) {
|
|
@('-device qemu-xhci,id=xhci'
|
|
'-device usb-kbd,bus=xhci.0'
|
|
'-device usb-hub,id=hub,bus=xhci.0,port=2'
|
|
'-device usb-mouse,bus=xhci.0,port=2.1'
|
|
"-drive id=usbstick,file=`"$(Join-Path $root 'build/usbstick.img')`",format=raw,if=none"
|
|
'-device usb-storage,bus=xhci.0,drive=usbstick')
|
|
} else { @() }
|
|
|
|
# A free loopback port, found by binding one and letting go. Hardcoding it made
|
|
# two runs on one machine collide.
|
|
$monPort = if ($Usb -or $Evdev) {
|
|
$l = [System.Net.Sockets.TcpListener]::new([System.Net.IPAddress]::Loopback, 0)
|
|
$l.Start(); $mp = $l.LocalEndpoint.Port; $l.Stop(); $mp
|
|
} else { 0 }
|
|
|
|
$monitor = if ($Usb -or $Evdev) { @("-monitor tcp:127.0.0.1:$monPort,server,nowait") } else { @() }
|
|
|
|
$psi.Arguments = ($drives + $usbDevices + $monitor + @(
|
|
'-m 1G -smp 1 -cpu qemu64,+rdtscp -machine pc-i440fx-9.2'
|
|
'-display none'
|
|
"-chardev stdio,id=c0,logfile=`"$log`""
|
|
'-serial chardev:c0'
|
|
)) -join ' '
|
|
$psi.RedirectStandardInput = $true
|
|
$psi.UseShellExecute = $false
|
|
|
|
$what = if ($Iso) { 'schos.iso, no hard disk' } else { "hdb = $(Split-Path $hdb -Leaf)" }
|
|
Write-Host "==> booting ($what)" -ForegroundColor Cyan
|
|
$p = [System.Diagnostics.Process]::Start($psi)
|
|
# Recorded immediately, so that a run killed one instruction later is still
|
|
# cleaned up by the next one.
|
|
Set-Content -Path $pidfile -Value $p.Id
|
|
|
|
# Commands that never produced a prompt. Collected rather than just printed:
|
|
# once one command overruns, every later check is reading a transcript that may
|
|
# be interleaved, so this has to be visible in the summary and not scrolled past
|
|
# in the middle of a long run.
|
|
$promptMisses = @()
|
|
|
|
try {
|
|
Start-Sleep -Seconds $BootSeconds # wait for the shell prompt
|
|
|
|
if ($Usb) {
|
|
# Type a whole command on the *USB* keyboard. QEMU routes sendkey to
|
|
# the most recently attached keyboard, so with usb-kbd present these
|
|
# keystrokes go through xHCI, the interrupt endpoint, the HID report
|
|
# decoder and the console -- none of which the serial path touches.
|
|
# If any link in that chain is broken the characters simply vanish,
|
|
# which is exactly how the missing interrupt showed up.
|
|
Write-Host ' > (usb keyboard) echo usbworks' -ForegroundColor DarkGray
|
|
$mon = New-Object System.Net.Sockets.TcpClient('127.0.0.1', $monPort)
|
|
$monw = New-Object System.IO.StreamWriter($mon.GetStream())
|
|
$monw.AutoFlush = $true
|
|
Start-Sleep -Milliseconds 400
|
|
foreach ($k in @('e','c','h','o','spc','u','s','b','w','o','r','k','s','ret')) {
|
|
$monw.WriteLine("sendkey $k")
|
|
Start-Sleep -Milliseconds 200
|
|
}
|
|
Start-Sleep -Seconds 2
|
|
$mon.Close()
|
|
}
|
|
|
|
# Wait until the shell has finished the last command and printed a fresh
|
|
# prompt, rather than sleeping a fixed interval and hoping.
|
|
#
|
|
# A flat 4-second wait was wrong in both directions: too long for the
|
|
# dozens of commands that answer instantly, and too short for `ls /bin |
|
|
# wc -l`, which reliably overran it. When it overran, the next command was
|
|
# typed while the shell was still busy -- the tty echoed it but no reader
|
|
# took it, and it ended up concatenated onto the following one. The
|
|
# transcript showed `cat /proc/pcicat /proc/schfs` and three PCI checks
|
|
# failed, which looks exactly like a broken PCI driver and is not one.
|
|
#
|
|
# This cannot read QEMU's stdout -- see the header; two attempts at that
|
|
# deadlocked. It reads the logfile QEMU is writing instead, which is the
|
|
# same bytes with none of the plumbing.
|
|
function Read-Log {
|
|
try { $t = Get-Content $log -Raw -ErrorAction Stop } catch { return $null }
|
|
if ($null -eq $t) { return '' }
|
|
return $t
|
|
}
|
|
|
|
# Wait for a prompt printed *after* $Since characters of transcript.
|
|
#
|
|
# Anchoring is not optional. Between writing a command to stdin and the
|
|
# guest echoing it, the transcript still ends with the *previous* prompt.
|
|
# Matching the whole file sees that stale prompt and returns immediately,
|
|
# so the next command is typed on top of one that has not started yet --
|
|
# a false pass that corrupts everything after it. Only text produced since
|
|
# the command was sent counts.
|
|
#
|
|
# The timeout is 60 s rather than 20 s because these tests run under TCG,
|
|
# not KVM. Measured on the Fedora laptop with the guest's own /proc/uptime,
|
|
# a cold `ls /bin | wc -l` takes 23 s -- it was over the old wall every
|
|
# time, and before the region-based allocator landed it took 51 s. A
|
|
# timeout only costs time when something is genuinely wrong, whereas one
|
|
# that is too short costs a false failure somewhere else entirely.
|
|
function Wait-Prompt([int]$Since, [int]$TimeoutMs = 60000) {
|
|
$deadline = (Get-Date).AddMilliseconds($TimeoutMs)
|
|
while ((Get-Date) -lt $deadline) {
|
|
Start-Sleep -Milliseconds 150
|
|
$t = Read-Log
|
|
if ($null -eq $t -or $t.Length -le $Since) { continue }
|
|
# A prompt at the very end, with nothing typed after it.
|
|
if ($t.Substring($Since) -match '(?s)#[ ]?$') { return $true }
|
|
}
|
|
return $false
|
|
}
|
|
|
|
# Wait for the guest to stop producing output, used only when Wait-Prompt
|
|
# has given up.
|
|
#
|
|
# The old fallback slept two seconds and typed regardless, which is exactly
|
|
# how a slow command becomes a failure attributed to a later one: the tty
|
|
# echoes the new command, no reader takes it, and it arrives concatenated
|
|
# onto the next. Waiting for the transcript to stop growing at least means
|
|
# the shell is idle before anything else is sent.
|
|
function Wait-Quiet([int]$StableMs = 1500, [int]$TimeoutMs = 45000) {
|
|
$deadline = (Get-Date).AddMilliseconds($TimeoutMs)
|
|
$last = -1
|
|
$lastChange = Get-Date
|
|
while ((Get-Date) -lt $deadline) {
|
|
Start-Sleep -Milliseconds 250
|
|
$t = Read-Log
|
|
$len = if ($null -eq $t) { -1 } else { $t.Length }
|
|
if ($len -ne $last) { $last = $len; $lastChange = Get-Date; continue }
|
|
if (((Get-Date) - $lastChange).TotalMilliseconds -ge $StableMs) { return $true }
|
|
}
|
|
return $false
|
|
}
|
|
|
|
foreach ($cmd in $commands) {
|
|
Write-Host " > $cmd" -ForegroundColor DarkGray
|
|
$pre = Read-Log
|
|
$since = if ($null -eq $pre) { 0 } else { $pre.Length }
|
|
foreach ($ch in ($cmd + "`n").ToCharArray()) {
|
|
$p.StandardInput.Write($ch)
|
|
$p.StandardInput.Flush()
|
|
Start-Sleep -Milliseconds 30 # no flow control: pace the input
|
|
}
|
|
if (-not (Wait-Prompt $since)) {
|
|
$promptMisses += $cmd
|
|
Write-Host " (no prompt after '$cmd' -- waiting for the guest to go quiet)" -ForegroundColor Yellow
|
|
Wait-Quiet | Out-Null
|
|
}
|
|
}
|
|
|
|
if ($Evdev) {
|
|
# Start a reader on /dev/input/event0 and then press keys at it. The
|
|
# program blocks in read() until events arrive, so if the keyboard
|
|
# driver is not feeding the input layer this hangs and prints nothing
|
|
# -- which is a failure that says exactly which half is broken.
|
|
Write-Host ' > inputtest -n 6, then press keys' -ForegroundColor DarkGray
|
|
foreach ($ch in "inputtest -n 6`n".ToCharArray()) {
|
|
$p.StandardInput.Write($ch); $p.StandardInput.Flush()
|
|
Start-Sleep -Milliseconds 30
|
|
}
|
|
Start-Sleep -Seconds 2
|
|
|
|
$mon = New-Object System.Net.Sockets.TcpClient('127.0.0.1', $monPort)
|
|
$monw = New-Object System.IO.StreamWriter($mon.GetStream())
|
|
$monw.AutoFlush = $true
|
|
Start-Sleep -Milliseconds 400
|
|
# 'a' is keycode 30 and shift is 42, chosen because both numbers are
|
|
# unmistakable in the output and neither is a scancode by accident.
|
|
# Each sendkey is a press *and* a release, which is the whole point:
|
|
# the console would report one character and nothing else.
|
|
foreach ($k in @('a', 'shift', 'a', 'b')) {
|
|
$monw.WriteLine("sendkey $k")
|
|
Start-Sleep -Milliseconds 400
|
|
}
|
|
$mon.Close()
|
|
Start-Sleep -Seconds 3
|
|
|
|
# Those keypresses reached the console as well as the input device --
|
|
# that is the design, not a leak -- so the shell's input line now holds
|
|
# "aab". Clear it before typing the next command, or the shell tries to
|
|
# run `aabecho`.
|
|
$p.StandardInput.Write("`n"); $p.StandardInput.Flush()
|
|
Start-Sleep -Milliseconds 500
|
|
|
|
foreach ($ch in "echo INPUT-DONE`n".ToCharArray()) {
|
|
$p.StandardInput.Write($ch); $p.StandardInput.Flush()
|
|
Start-Sleep -Milliseconds 30
|
|
}
|
|
Start-Sleep -Seconds 3
|
|
}
|
|
|
|
if ($Tty) {
|
|
# Ctrl-C has to reach the *foreground process group*. The old handler
|
|
# set killed=1 on whatever process happened to be running when the
|
|
# keyboard interrupt fired, which is almost never the one on screen.
|
|
Write-Host ' > sleep 30, then Ctrl-C' -ForegroundColor DarkGray
|
|
foreach ($ch in "sleep 30`n".ToCharArray()) {
|
|
$p.StandardInput.Write($ch); $p.StandardInput.Flush()
|
|
Start-Sleep -Milliseconds 30
|
|
}
|
|
Start-Sleep -Seconds 2
|
|
$p.StandardInput.Write([char]3) # ^C
|
|
$p.StandardInput.Flush()
|
|
Start-Sleep -Seconds 2
|
|
foreach ($ch in "echo INTERRUPTED-OK`n".ToCharArray()) {
|
|
$p.StandardInput.Write($ch); $p.StandardInput.Flush()
|
|
Start-Sleep -Milliseconds 30
|
|
}
|
|
Start-Sleep -Seconds 3
|
|
}
|
|
} finally {
|
|
if (-not $p.HasExited) { $p.Kill() }
|
|
Start-Sleep -Milliseconds 800
|
|
Remove-Item -Force $pidfile -ErrorAction SilentlyContinue
|
|
}
|
|
|
|
if (-not (Test-Path $log)) { throw "no serial log produced at $log" }
|
|
$text = Get-Content $log -Raw
|
|
if ($null -eq $text) { $text = '' }
|
|
|
|
Write-Host "==> serial transcript ($($text.Length) bytes) -> $log" -ForegroundColor Cyan
|
|
Write-Host $text
|
|
|
|
$script:fail = $false
|
|
function Expect($pattern, $what) {
|
|
if ($script:text -match $pattern) {
|
|
Write-Host " [ok] $what" -ForegroundColor Green
|
|
} else {
|
|
Write-Host " FAIL: $what (no match for /$pattern/)" -ForegroundColor Red
|
|
$script:fail = $true
|
|
}
|
|
}
|
|
|
|
# Assert something the transcript does not state literally. Expect can only ask
|
|
# whether a string is present, which is why the framebuffer checks below used to
|
|
# hardcode 800x600: it was the only mode the BIOS loader ever set, so a literal
|
|
# was indistinguishable from an invariant. Under UEFI the firmware chooses, so
|
|
# the literal was testing the firmware's taste rather than our code.
|
|
function ExpectThat($cond, $what, $detail) {
|
|
if ($cond) {
|
|
Write-Host " [ok] $what" -ForegroundColor Green
|
|
} else {
|
|
Write-Host " FAIL: $what ($detail)" -ForegroundColor Red
|
|
$script:fail = $true
|
|
}
|
|
}
|
|
|
|
# Pull a single "name value" field out of the fbset-style dump.
|
|
function FbField($name) {
|
|
if ($script:text -match "(?m)^$name\s+(\d+)") { return [int]$Matches[1] }
|
|
return -1
|
|
}
|
|
|
|
Expect 'MARKER-DONE' 'shell stayed alive through every command'
|
|
Expect 'hello-from-schos' 'a file written on the root filesystem reads back'
|
|
Expect 'second-line' 'appending to that file works'
|
|
# 17 bytes of "hello-from-schos\n" plus 12 of "second-line\n". The exact
|
|
# number is the point: an append that silently truncated still produced a file,
|
|
# and only the byte count said which one.
|
|
#
|
|
# It follows the string above, so changing that changes this -- the SchOS
|
|
# rename shortened "hello-from-schunix" by two characters and this check went
|
|
# red on a filesystem that was working perfectly.
|
|
Expect '(?m)^\s*29\s' 'append extended the file rather than replacing it'
|
|
Expect '(?m)^copy\.txt' 'a copied file appears in a new directory'
|
|
Expect '(?ms)copy\.txt.*rmdir' 'rmdir removed the directory without an error'
|
|
|
|
# PCI. The point of these is that the numbers come off the bus rather than out
|
|
# of a header: the host bridge identifies the chipset QEMU is emulating, and
|
|
# the display controller's aperture is the same address VBE reported, checked
|
|
# by two completely independent paths.
|
|
Expect 'pci: \d+ devices' 'the PCI bus enumerated'
|
|
Expect '00:00\.0 8086:1237' 'the host bridge is where a host bridge belongs'
|
|
Expect '00:01\.1 8086:7010 IDE' 'the IDE controller was identified by class'
|
|
Expect '\[vga\]' 'a driver bound itself to the display controller'
|
|
Expect 'vga: display controller' 'the display driver found its aperture'
|
|
|
|
# If init had died the machine would have panicked and nothing below this
|
|
# point would appear at all -- but check explicitly, so a failure names the
|
|
# cause instead of showing up as "the shell stopped answering".
|
|
Expect 'INIT-SURVIVED' 'init survives SIGINT, SIGKILL and SIGTERM'
|
|
Expect 'schinit: ready \(pid 1\)' 'schinit came up as process 1'
|
|
Expect 'job.? loaded' 'schinit read its manifests from /etc/schinit'
|
|
Expect 'starting os\.schos\.' 'a job started by Label, not by hardcoded path'
|
|
Expect 'SHELL-RESPAWNED' 'KeepAlive restarted the shell after it exited'
|
|
Expect '(?m)^\s*1\s' 'pid 1 is still in the process table afterwards'
|
|
|
|
Expect 'schfs: mounted' 'the kernel mounted SchFS as the root filesystem'
|
|
Expect 'init: ready' 'init came up on a SchFS root'
|
|
Expect 'label:\s*schos' '/proc/schfs reports the volume label'
|
|
Expect 'root_objectid:\s*16' '/proc/schfs reports the root object id'
|
|
Expect 'dir\s+bin' 'the root directory really contains bin/'
|
|
# / is the mounted volume, so mounting that same device again must be
|
|
# refused. Without this a kernel that had quietly fallen back to some other
|
|
# root would still pass everything else here.
|
|
Expect 'Resource busy' 'mounting the root device twice is refused'
|
|
Expect 'schfs-is-root' 'a file three directories deep reads back'
|
|
Expect '(?m)^deep\.txt' 'that file is listed by its full name'
|
|
Expect '(?m)^\s*1\s+/etc/motd' 'a binary loaded off the SchFS root runs'
|
|
# Two /proc/schfs dumps, before and after the writes. Same volume, so the
|
|
# generation must have moved -- if it has not, nothing was committed.
|
|
$gens = [regex]::Matches($text, 'generation:\s*(\d+)')
|
|
if ($gens.Count -ge 2 -and
|
|
[int]$gens[$gens.Count-1].Groups[1].Value -gt [int]$gens[0].Groups[1].Value) {
|
|
Write-Host " [ok] the volume generation advanced across the writes" -ForegroundColor Green
|
|
} else {
|
|
Write-Host " FAIL: the volume generation did not advance -- writes were not committed" -ForegroundColor Red
|
|
$script:fail = $true
|
|
}
|
|
|
|
if ($Fb) {
|
|
Expect 'id\s+schosfb' 'FBIOGET_FSCREENINFO identifies the device'
|
|
# The bug this guards against is an ioctl that reports success having
|
|
# touched nothing -- a 0x0 screen. What proves the numbers are real is not
|
|
# any particular pair of them but that they agree with each other and with
|
|
# a mode the hardware could actually scan out.
|
|
$xres = FbField 'xres'
|
|
$yres = FbField 'yres'
|
|
$line = FbField 'line_length'
|
|
ExpectThat ($xres -ge 640) 'the reported width is a mode actually set' "xres = $xres"
|
|
ExpectThat ($yres -ge 480) 'the reported height is a mode actually set' "yres = $yres"
|
|
# A width that is not a multiple of 8 is the mode that sheared under OVMF:
|
|
# the firmware advertised 1366 and the hardware scanned out 1360.
|
|
ExpectThat (($xres % 8) -eq 0) 'the width is a whole number of scanout units' "xres = $xres"
|
|
Expect '(?m)^bits_per_pixel\s+32' 'the reported depth is the mode actually set'
|
|
Expect '(?m)^smem_start\s+0x' 'the framebuffer reports a physical address'
|
|
# The stride must come from the mode information, not be recomputed as
|
|
# xres*bpp -- on a mode whose scanline is padded those two differ, and every
|
|
# row after the first lands in the wrong place.
|
|
ExpectThat ($line -ge $xres * 4) 'the stride covers a full row of pixels' "line_length = $line, xres = $xres"
|
|
ExpectThat (($line % 4) -eq 0) 'the stride is a whole number of pixels' "line_length = $line"
|
|
Expect 'transp.*absent' 'the absent alpha channel is reported as length 0'
|
|
Expect 'colour bars drawn' 'writing pixels through write() reaches the screen'
|
|
Expect 'gradient drawn' 'mmap of /dev/fb0 maps the real framebuffer'
|
|
}
|
|
|
|
if ($Tty) {
|
|
# Each of these is a bug that once looked like a broken *command*.
|
|
# `ls | wc -l` reported 48 rather than the real count because ioctl
|
|
# answered TIOCGWINSZ on a pipe with success, so ls formatted in columns.
|
|
# Anything in the hundreds means it printed one name per line.
|
|
Expect '(?m)^\s*[2-9][0-9]{2}\s*$' 'ls into a pipe prints one name per line'
|
|
# writev capped iovcnt at 16; toybox builds 256 of them, so `yes` failed
|
|
# outright.
|
|
Expect '(?ms)yes \| head -2\s*\n\s*y\s*\n\s*y' 'yes writes through a pipe'
|
|
# 0x7F E L F. Four bytes, not three -- the leading byte is invisible.
|
|
Expect 'ELF' 'a binary reads back through a pipe intact'
|
|
Expect 'caf.*-.*-' 'UTF-8 survives the terminal unmangled'
|
|
# SIG_IGN is handler value 1, and signal delivery once jumped to address 1,
|
|
# which killed the shell rather than the pipeline.
|
|
Expect 'TTY-SURVIVED' 'the shell is still alive after all of that'
|
|
Expect 'shmtest: all checks passed' 'shared memory and file-backed mmap work'
|
|
}
|
|
|
|
if ($Usb) {
|
|
# A registered handler that never fires looks exactly like a working one
|
|
# from userspace. A non-zero count on the xhci line is the difference.
|
|
# Not anchored to the start of the line: xHCI shares IRQ 11 with the e1000
|
|
# here, so the line reads "11: 28 e1000, 28 xhci level" and the xhci
|
|
# entry is the second one on it.
|
|
Expect '[1-9]\d*\s+xhci' 'the xHCI interrupt is actually delivered'
|
|
# build/usbstick.img is 16 MB = 32768 sectors of 512 bytes. READ CAPACITY
|
|
# returning the true geometry is what separates a driver that parsed the
|
|
# reply from one that assumed a size.
|
|
Expect 'usb-storage: 32768 sectors of 512 bytes' 'READ CAPACITY returned the true size'
|
|
# And then an actual transfer: block 1 of a v1 image is its superblock.
|
|
Expect 'block 1 magic 10203040' 'a real block came back off the USB disk'
|
|
Expect 'size 4096' 'the superblock read over USB is the right one'
|
|
}
|
|
|
|
if ($Sock) {
|
|
Expect 'socktest: ALL CHECKS PASSED' 'every socket check passed'
|
|
}
|
|
|
|
if ($Ps) {
|
|
# /proc is synthetic, so a per-process directory is the thing to prove.
|
|
# `ls /proc` prints in columns, so pid 1 is a token on a line rather than a
|
|
# line of its own.
|
|
Expect '(?m)^1\s+\d' '/proc has a directory for pid 1'
|
|
Expect '(?m)^1 \(' '/proc/1/stat starts with the pid and comm'
|
|
Expect 'sinit|init' 'ps names pid 1'
|
|
Expect '(?m)PID.*PPID.*COMM' 'ps honours -o with the requested columns'
|
|
Expect 'load average|Mem|CPU' 'top produces a report rather than an error'
|
|
}
|
|
|
|
if ($Evdev) {
|
|
# A key has to be *pressed* for this to say anything: characters typed at
|
|
# the serial port never reach the keyboard controller, which is why this
|
|
# run drives the QEMU monitor's sendkey instead.
|
|
Expect 'inputtest: done|event limit reached' 'the input layer delivered events'
|
|
Expect 'value' 'events carry a value, not just a type'
|
|
}
|
|
|
|
# A command that never produced a prompt does not fail on its own, but every
|
|
# check after it read a transcript that may be interleaved -- so say so next to
|
|
# the result rather than only in the scrollback. A FAIL below this line is
|
|
# suspect until the transcript has been read.
|
|
if ($promptMisses.Count) {
|
|
Write-Host "==> $($promptMisses.Count) command(s) never produced a prompt; later output may be interleaved:" -ForegroundColor Yellow
|
|
$promptMisses | ForEach-Object { Write-Host " $_" -ForegroundColor Yellow }
|
|
}
|
|
|
|
if ($script:fail) { exit 1 }
|
|
Write-Host 'boot check passed' -ForegroundColor Green
|