test: the network is checked by the suite now, and /proc/netif says what it did

The TCP/IP stack landed with its end-to-end test performed by hand: start
a listener, boot, type nettest, read the output. That is evidence once
and nothing afterwards, and it is exactly the kind of check that quietly
stops being run.

bootcheck -Net does the whole exchange. A listener runs on this host on a
port picked by binding one and letting go, the guest is told to connect
out to 10.0.2.2 at that port, and both ends are asserted: the guest
reports what it received, the listener reports what arrived. One side
alone proves much less than it looks -- a stack that completes a
handshake and then drops every byte satisfies any check made from inside
the guest.

The listener is a background job, a separate process, because this
harness has one hard rule: nothing may block the writer feeding QEMU's
stdin. A socket accept is precisely the wait that deadlocked two earlier
attempts at reading QEMU's output. It answers the protocol nettest
speaks and then closes -- the close is load-bearing, since nettest reads
until end of file and a listener that stayed open would hang the guest
rather than fail it.

The NIC is spelled out (-netdev user + -device e1000) rather than left to
QEMU's default. Other runs can take the default and still answer "did a
driver bind"; a run that asserts on an address should be reading its own
configuration.

/proc/netif is new, and the address is why. Until now the only report was
a line on the console at boot, which userland cannot read and a test can
only find by scraping the transcript. It gives interface, MAC, state,
address, netmask, gateway, MTU and frame counts. Named netif rather than
net on purpose: /proc/net is a *directory* on Linux, and a plain file
there would give ENOTDIR to anything reaching for /proc/net/dev, which is
worse than not having it. The counters are kept at the lwIP seam rather
than in the driver, because a frame the driver received and the netif
dropped never reached the stack.

Verified: 38 [ok], no failures. Eleven of them are new -- lwIP up, DHCP
obtaining 10.0.2.15 from SLIRP (a number nothing in the guest knows in
advance, so it is the exchange completing rather than a guess), the four
/proc/netif fields, frames counted in both directions, the handshake, the
reply read back, and the listener confirming HELLO-FROM-SCHOS arrived.

One bug found by writing it: $netPort was chosen after the command list
was built, so the guest ran `nettest 10.0.2.2` with no port and printed
its usage. An unset PowerShell variable interpolates to nothing rather
than failing, which is worth remembering next time a command in this
file looks right and behaves oddly.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Nexoniarz 2026-08-15 16:11:49 +02:00
commit f46fb41966
6 changed files with 268 additions and 1 deletions

View file

@ -51,6 +51,15 @@ param(
# it exists for -- one directory per process -- is exactly what nothing
# else in this suite touches.
[switch]$Ps,
# TCP/IP. A listener is started on this host and the guest is told to
# connect out to it, which is the only arrangement that checks both
# directions from both ends: the guest reports what it received, and the
# listener reports what arrived. A stack that completed a handshake and
# then dropped every byte would pass any check made from one side alone.
#
# Under QEMU's user-mode networking the host is 10.0.2.2 seen from inside,
# and SLIRP connects to the host's loopback -- so the listener binds there.
[switch]$Net,
[int]$BootSeconds = 20,
[string]$Qemu
)
@ -230,6 +239,21 @@ if ($Usb) {
if ($Sock) {
$commands += 'socktest'
}
if ($Net) {
# A free loopback port, found by binding one and letting go, so two runs on
# one machine do not collide. It has to be chosen here rather than with the
# rest of the network setup below: the command line that uses it is built
# in this block, and an unset variable interpolates to nothing -- which is
# how nettest first ran with no port at all and reported its usage.
$l = [System.Net.Sockets.TcpListener]::new([System.Net.IPAddress]::Loopback, 0)
$l.Start(); $script:netPort = $l.LocalEndpoint.Port; $l.Stop()
# The interface first, so a failure below reads as "the address never
# arrived" rather than "TCP is broken" -- two very different bugs that look
# identical from a failed connect.
$commands += 'cat /proc/netif'
$commands += "nettest 10.0.2.2 $netPort"
}
if ($Ps) {
$commands += 'ls /proc'
$commands += 'cat /proc/1/stat'
@ -297,7 +321,56 @@ $monPort = if ($Usb -or $Evdev) {
$monitor = if ($Usb -or $Evdev) { @("-monitor tcp:127.0.0.1:$monPort,server,nowait") } else { @() }
$psi.Arguments = ($drives + $usbDevices + $monitor + @(
# The network run: an explicit NIC, and a listener on this host for the guest
# to talk to.
#
# The NIC is spelled out rather than left to QEMU's default. Every other run
# gets the default one and that is fine for "did a driver bind", but a check
# that asserts on an address wants to be reading its own configuration rather
# than whatever the machine type happens to supply this year.
$netDevices = if ($Net) {
@('-netdev user,id=net0', '-device e1000,netdev=net0')
} else { @() }
# The listener runs in a background job -- a separate process -- rather than on
# a thread here. This script's one hard rule is that nothing may block the
# writer feeding QEMU's stdin, and a socket accept is exactly the kind of wait
# that has deadlocked this harness before.
#
# It answers the protocol cmd/nettest speaks: read what the guest sends, send a
# line back, close. The close matters -- nettest reads until end of file, so a
# listener that stayed open would hang the guest rather than fail it.
$netJob = if ($Net) {
Start-Job -ArgumentList $netPort -ScriptBlock {
param($port)
$l = [System.Net.Sockets.TcpListener]::new([System.Net.IPAddress]::Loopback, $port)
$l.Start()
# Generous, because it has to cover the whole boot and every command
# before nettest, under TCG. Nothing waits on this deadline: the job is
# torn down by the caller either way.
$deadline = (Get-Date).AddSeconds(900)
while (-not $l.Pending()) {
if ((Get-Date) -gt $deadline) { $l.Stop(); return '' }
Start-Sleep -Milliseconds 200
}
$c = $l.AcceptTcpClient()
$s = $c.GetStream()
$s.ReadTimeout = 30000
$buf = New-Object byte[] 512
$got = ''
try {
$n = $s.Read($buf, 0, $buf.Length)
if ($n -gt 0) { $got = [Text.Encoding]::ASCII.GetString($buf, 0, $n) }
} catch { }
$reply = [Text.Encoding]::ASCII.GetBytes("HELLO-FROM-HOST`n")
try { $s.Write($reply, 0, $reply.Length); $s.Flush() } catch { }
$c.Close()
$l.Stop()
return $got
}
} else { $null }
$psi.Arguments = ($drives + $usbDevices + $netDevices + $monitor + @(
'-m 1G -smp 1 -cpu qemu64,+rdtscp -machine pc-i440fx-9.2'
'-display none'
"-chardev stdio,id=c0,logfile=`"$log`""
@ -493,6 +566,17 @@ try {
Remove-Item -Force $pidfile -ErrorAction SilentlyContinue
}
# What the listener on this host received, if there was one. Collected after
# QEMU is gone: the job either finished when the guest closed the connection or
# is still sitting in accept, and the second case is a failure to report rather
# than one to wait out.
$netFromGuest = ''
if ($Net -and $netJob) {
Wait-Job $netJob -Timeout 20 | Out-Null
$netFromGuest = (Receive-Job $netJob) -join ''
Remove-Job $netJob -Force -ErrorAction SilentlyContinue
}
if (-not (Test-Path $log)) { throw "no serial log produced at $log" }
$text = Get-Content $log -Raw
if ($null -eq $text) { $text = '' }
@ -651,6 +735,33 @@ if ($Sock) {
Expect 'socktest: ALL CHECKS PASSED' 'every socket check passed'
}
if ($Net) {
# The stack came up at all. Cheap, and it separates "lwIP never started"
# from every other network failure below.
Expect 'net: lwIP \d+\.\d+\.\d+ up' 'the TCP/IP stack started'
# 10.0.2.15 is the address QEMU's user-mode networking hands out, so this
# is DHCP genuinely completing -- discover, offer, request, ack -- and not
# a hardcoded guess: nothing in the guest knows that number in advance.
Expect 'net: 10\.0\.2\.15/' 'DHCP obtained an address from SLIRP'
Expect '(?m)^state\s+up' '/proc/netif reports the interface up'
Expect '(?m)^address\s+10\.0\.2\.15' '/proc/netif reports the address it was given'
Expect '(?m)^gateway\s+10\.0\.2\.2' '/proc/netif reports the gateway'
# Frames in both directions. DHCP alone guarantees these are non-zero, so a
# zero here means the counters are wrong rather than the network.
Expect '(?m)^rx_frames\s+[1-9]' 'frames arrived and reached the stack'
Expect '(?m)^tx_frames\s+[1-9]' 'frames were transmitted'
# And now real traffic. Each of these is a different part of the exchange,
# so a failure says which one: the handshake, the guest's receive path, and
# the guest's send path as seen from outside.
Expect 'nettest: connected' 'a TCP connection out of the guest completed'
Expect 'nettest: OK' 'the guest read a reply back off that connection'
Expect 'HELLO-FROM-HOST' 'and the reply is the host''s bytes'
ExpectThat ($netFromGuest -match 'HELLO-FROM-SCHOS') `
'the listener on this host received what the guest sent' `
"the listener read '$($netFromGuest -replace '\s+', ' ')'"
}
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