test-xv6.py
About this file
A Python 3 script that tests xv6 automatically, with no person at the keyboard. It runs
make qemu as a child process, connected through pipes: whatever it writes to QEMU’s
standard input arrives at xv6’s console as if typed, and everything xv6 prints comes
back on QEMU’s standard output for the script to search. (With -nographic, QEMU connects
the emulated UART to its own standard input and output.)
It knows two kinds of test:
usertests: boot xv6, typeusertestsinto the shell, and wait up to ten minutes for the lineALL TESTS PASSED(user/usertests.c).- crash tests: start a program that is in the middle of changing the file system,
kill QEMU abruptly (like pulling the power cord), boot again from the same
fs.img, and check that the kernel’s recovery code ran: replaying the log (recover_from_log) and freeing orphaned inodes (ireclaim).
The CI job runs ./test-xv6.py usertests and ./test-xv6.py crash
(.github/workflows/test.yml:11); you can run the same on your machine (Linux; the
crash tests use a Linux-only ps option). A failure prints FAIL and saves the whole
console output in test-xv6.out.
How to run it
The first line (a “shebang”) lets you run the file directly as ./test-xv6.py:
the system finds python3 on your PATH through env. The comments list the usual
commands. The argument is a regular expression matched against the names of the
test_ functions below; anything that matches none of them is treated as the name of
a single usertests test (see line 210), so ./test-xv6.py sbrkbasic runs only that
test.
Standard-library modules
Only Python’s standard library is used: argparse for the command line,
subprocess to start make, os for low-level reads and kill, re for regular
expressions, inspect to find the test functions, signal, sys and time.
Print each line at once
When standard output is not a terminal (in CI it is a pipe to the runner’s log), Python normally collects output in a buffer and writes it out in large pieces. Line buffering flushes after every line, so progress messages appear in the CI log as they happen, and are not lost if the job is killed for taking too long.
Parse the command line
One required argument, testrex (a test name or regular expression), and an
optional -q flag that passes -q on to usertests, which then runs only its quick
tests. args is a global used by main and test_usertests.
Start xv6 in QEMU
A QEMU object stands for one running xv6. Creating it with reset=True first
builds the kernel and makes a fresh fs.img; without it, the existing image is reused,
which is how the crash tests boot a disk that was left mid-update.
Popen starts make qemu without waiting for it. Its standard input and output
become pipes the script holds (stdin=PIPE, stdout=PIPE), and error output is
merged into the same stream (stderr=STDOUT), so the script sees make’s messages
too. make runs QEMU as its child, and QEMU inherits these pipes.
Line 31 makes reading the output pipe non-blocking: a read returns what is
available, or fails at once if nothing is, instead of waiting. That lets read
collect output without ever hanging. output is all console text so far,
outbytes the same as raw bytes, reported how much of it progress has already
shown. The one-second pause gives make and QEMU time to start.
make kernel/kernel: rebuild the kernel if any source changed.
Delete and rebuild fs.img, so the test starts from a clean disk.
The command to run: the same make qemu you type yourself.
Reads from QEMU’s output must never wait; see the block note.
Rebuild the kernel and a fresh disk image
run(..., check=True) runs a command, waits for it, and raises
CalledProcessError if it exits with a non-zero status. Deleting fs.img before
make fs.img forces mkfs to build a clean image, wiping any files a
previous run created or left half-written. A failed build is only reported here;
the test then fails later, when the expected output never appears.
Save the console output for inspection
Writes everything xv6 printed into test-xv6.out in the current directory, so
that after a failure you can see what happened. (The explicit close() is
unnecessary: the with statement closes the file anyway. The indentation of this
method is 6 spaces instead of 4, which Python accepts as long as it is consistent
within the block.)
Type into xv6
Sends text to QEMU’s standard input; it reaches xv6’s UART as keystrokes, and the
console driver collects them into a line for the shell. Strings are converted
to UTF-8 bytes because pipes carry bytes. flush() pushes the bytes out immediately
instead of letting them sit in Python’s buffer. Commands end in \n, the Enter key.
Pull the plug
Simulates a power failure. self.proc is the make process; the process to kill is
its child, QEMU itself (make starts a simple command like this one directly, without
a shell in between). ps -o pid --no-headers --ppid PID lists the process IDs whose
parent is make; these options are the Linux ps, and macOS’s ps rejects them.
SIGKILL cannot be caught, so QEMU stops instantly, in the middle of whatever
xv6 was doing. Whatever disk writes QEMU had already made to fs.img stay there, and
nothing more is written: exactly the situation the
crash recovery code must handle on the next boot.
List the children of the make process. Linux ps syntax.
Kill QEMU at once, with no chance to finish anything.
Stop xv6 normally
Sends SIGTERM to make. GNU make passes the signal on to the command it is running,
so QEMU exits too. After crash make has usually exited already, and this does
nothing harmful.
Collect whatever xv6 has printed
Reads the output pipe in chunks of up to 4096 bytes until no more data is waiting
(the non-blocking read raises BlockingIOError) or the pipe has closed (a read of
zero bytes means QEMU and make have exited). The bytes are kept, and the whole
output is decoded again each time, so a multi-byte UTF-8 character split between two
chunks still decodes correctly. "replace" turns any invalid byte into a placeholder
instead of raising an error.
Read up to 4096 bytes without waiting.
The output as lines
Splits the output so far into lines, for match.
Fail the whole run
Called when the expected output did not appear: print which patterns failed, save
the output to test-xv6.out, stop QEMU, and exit with status 1. That status is what
makes the CI step, and the CI job, fail.
Look for expected lines
Checks every line of output against the regular expressions. re.match only matches
at the start of a line, so '^ALL TESTS PASSED' and 'f5' both mean “a line
beginning with this”. Matching lines are printed. With exit=True (the default) a
missing match ends the run through error; with exit=False the caller gets
True or False and decides.
Show progress lines once each
During a long run such as usertests, prints each newly completed line that matches
regexp (usertests prints test NAME: OK for each test). reported remembers how far
the output has been shown. Only text up to the last newline is considered, because
usertests prints test NAME: first and OK only when the test finishes; printing
the partial line would show it twice, or cut in half.
Wait for an expected line, with a deadline
The script’s main loop while xv6 runs. Once per second it reads new output, shows
progress, and returns as soon as one of the patterns appears anywhere in the output.
If timeout seconds pass first, error fails the run. Polling once a second is
crude but enough: the tests take minutes, and a second’s delay does not matter.
Crash during logging, then check log recovery
crash_log boots a fresh image and runs logstress with six file names: it forks six processes that each try to write 500,000 bytes to their own file (more than the 274,432-byte maximum file size, so each writer eventually gets an error; the test only needs them busy when QEMU is killed), keeping the log constantly busy committing transactions. After two seconds QEMU is killed.
recover_log boots the same image without resetting it. If the kill happened
after a transaction was committed to the log but before it was fully installed, the
kernel replays it at boot and prints recovering tail N dst B for each block
(kernel/log.c:74). The test then runs ls and waits for a line starting with
f5, a basic check that the file system is still usable. If no
recovery message appears, the kill simply landed at a moment when no committed
transaction was pending, and the function returns False so that the caller tries
again.
Six concurrent writers, one file each: f0 to f5.
Let them run for two seconds, long enough to be in the middle of writing.
Did the kernel replay a committed transaction at boot?
The last file must be listed by ls within 30 seconds.
Crash with an orphaned inode, then check it is reclaimed
An orphaned inode is one with no directory entry left (link count (nlink) 0) that is still in use, so the kernel cannot free it yet. If the machine crashes at that moment, nothing will ever free it, and its blocks are lost, unless the kernel checks at boot.
forphan runs forphan, which creates a file, keeps it open and
unlinks it, then prints wait for kill and reclaim and sleeps. dorphan runs
dorphan, which does the same with a directory it has made its
current directory. Once the wait line appears, QEMU is killed.
recover_orphan boots the same image and waits up to 30 seconds for a line starting
with ireclaim, which ireclaim prints when it finds and frees such an inode
during boot (kernel/fs.c:396).
forphan prints wait for kill and reclaim N once the orphan exists.
The kernel prints ireclaim: orphaned inode N during boot.
test_log: try up to 20 times
Whether a kill catches a committed but uninstalled transaction is a matter of timing,
so this test repeats crash and reboot until a recovery is observed, at most 20
times. OK means the kernel replayed a log at least once and that afterwards ls
still listed f5, a basic check that the file system is usable. If 20 attempts never
catch a recovery, the test fails. (If f5 is not listed after a recovery, the run
fails earlier, in monitor.)
The orphan tests and the combined crash test
test_forphan and test_dorphan each crash once and check for reclamation. Both
print “an orphaned file”, although dorphan tests an orphaned directory; the
message was copied. test_crash runs all three crash tests in a row; it is what
./test-xv6.py crash selects.
test_usertests: run the big test suite
Boots a fresh image, types usertests and waits for ALL TESTS PASSED, printing each
test’s OK line as it arrives. The full suite gets 600 seconds (10 minutes); with
-q only the quick tests run, with 300 seconds. When called with a test name (the
fallback in main), it types usertests NAME to run that one test. If a test
fails, usertests prints a failure message instead of ALL TESTS PASSED, so the run
ends at the timeout with FAIL.
Type the command into xv6’s shell.
Wait for success, showing each test ... line on the way.
Choose which tests to run
main prints the parsed arguments, then uses inspect.getmembers to list every
function in this script whose name starts with test (in alphabetical order:
test_crash, test_dorphan, test_forphan, test_log, test_usertests). Each one
whose name contains a match for the argument (re.search, anywhere in the name) is
run. So crash runs test_crash, log runs test_log, and orphan runs both orphan
tests. If nothing matched, the argument is taken as the name of one usertests test.
Line 224 calls main() when the file is run. Any failure exits with status 1 through
error or sys.exit(1); reaching the end means every selected test passed, and
Python exits with status 0.
Every function in this module whose name starts with test.
Run each test whose name the argument matches.
No test function matched: run that one usertests test.