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

@ -531,6 +531,7 @@ int e1000_present(void);
// net_is_up reports whether an address has been obtained.
void netinit(void);
int net_is_up(void);
int net_info(char *buf, int cap);
// net/lwip/ -- the port layer and the interface, called only from net.c.
void lwip_sys_init(void);
@ -538,6 +539,7 @@ void lwip_sys_lock(void);
void lwip_sys_unlock(void);
int net_e1000_attach(void);
int net_e1000_poll(void);
void net_e1000_counts(uint64 *rx, uint64 *tx);
struct netif *net_e1000_netif(void);
void e1000_selftest(void);

View file

@ -364,6 +364,7 @@ gen_filesystems(struct sbuf *sb)
#define PROC_INTERRUPTS 12
#define PROC_DEVICES 13
#define PROC_FSSTAT 14
#define PROC_NETIF 15
// Render global file `id` (a PROC_* constant) into `buf`. Returns its length.
//
@ -393,6 +394,10 @@ proc_gen_global(int id, char *buf, int cap)
case PROC_PCI: sb.len = pci_info(buf, PROC_BUFSZ); break;
// Likewise irq.c: it owns the table, so it renders it.
case PROC_INTERRUPTS: sb.len = irq_info(buf, PROC_BUFSZ); break;
// And likewise net.c, which is the only file here allowed to include lwIP's
// headers -- reaching into the stack from this one would put them on every
// kernel file's include path.
case PROC_NETIF: sb.len = net_info(buf, PROC_BUFSZ); break;
case PROC_DEVICES: gen_devices(&sb); break;
default: return 0;
}

View file

@ -91,6 +91,7 @@ static const struct global_file globals[] = {
{ "interrupts", 12 },
{ "devices", 13 },
{ "fsstat", 14 },
{ "netif", 15 },
};
#define NGLOBALS ((int)(sizeof(globals) / sizeof(globals[0])))

View file

@ -24,6 +24,19 @@
static struct netif e1000_netif;
// Frames in and out, for /proc/netif. Counted here rather than in the driver
// because this is the seam lwIP actually uses: a frame the driver received and
// this file dropped never reached the stack, and a counter kept one layer down
// would say otherwise. Only the network thread touches them, so no lock.
static uint64 rx_frames, tx_frames;
void
net_e1000_counts(uint64 *rx, uint64 *tx)
{
*rx = rx_frames;
*tx = tx_frames;
}
// One staging buffer, not one per call: a frame is 1518 bytes and the kernel
// stack is 4096 in total, so a local of this size on a path that is several
// frames deep is exactly the overflow the kstack canary exists to catch.
@ -56,6 +69,7 @@ e1000_linkoutput(struct netif *netif, struct pbuf *p)
if (e1000_transmit(txframe, off) != 0)
return ERR_WOULDBLOCK;
tx_frames++;
return ERR_OK;
}
@ -131,6 +145,7 @@ net_e1000_poll(void)
if (e1000_netif.input(p, &e1000_netif) != ERR_OK)
pbuf_free(p);
rx_frames++;
moved++;
}
return moved;

View file

@ -113,6 +113,139 @@ net_thread(void)
}
}
/* ------------------------------------------------------------------ */
/* /proc/netif */
/* ------------------------------------------------------------------ */
//
// Named netif rather than net, and that is deliberate: on Linux /proc/net is a
// *directory* of a dozen files, and putting a plain file there would mean
// anything reaching for the familiar /proc/net/dev got ENOTDIR from a path that
// looks like it exists. This is one interface's state, so it is named for what
// it holds and leaves the Linux name free for the day there is a directory to
// put under it.
//
// No snprintf in the kernel, so this appends by hand in the same style as
// kernel/dev/pci.c.
static int
put_str(char *out, const char *s)
{
int n = 0;
while (s[n]) { out[n] = s[n]; n++; }
return n;
}
static int
put_u(char *out, uint64 v)
{
char tmp[24];
int i = 0, n = 0;
if (v == 0) { out[0] = '0'; return 1; }
while (v && i < (int)sizeof(tmp)) { tmp[i++] = '0' + (char)(v % 10); v /= 10; }
while (i--) out[n++] = tmp[i];
return n;
}
static int
put_ip4(char *out, const ip4_addr_t *a)
{
int n = 0;
n += put_u(out + n, ip4_addr1(a)); out[n++] = '.';
n += put_u(out + n, ip4_addr2(a)); out[n++] = '.';
n += put_u(out + n, ip4_addr3(a)); out[n++] = '.';
n += put_u(out + n, ip4_addr4(a));
return n;
}
static int
put_mac(char *out, const unsigned char *m)
{
static const char d[] = "0123456789abcdef";
int n = 0;
for (int i = 0; i < 6; i++) {
if (i) out[n++] = ':';
out[n++] = d[m[i] >> 4];
out[n++] = d[m[i] & 0xF];
}
return n;
}
// One "name value" pair per line, which is the shape /proc/schfs and
// /proc/fsstat already use here -- a reader that can scan one can scan all of
// them.
static int
put_field(char *out, const char *name)
{
int n = put_str(out, name);
while (n < 12)
out[n++] = ' ';
return n;
}
int
net_info(char *buf, int cap)
{
int len = 0;
if (cap < 512)
return 0;
if (!net_started) {
len += put_field(buf + len, "state");
len += put_str(buf + len, e1000_present() ? "down\n" : "no interface\n");
return len;
}
struct netif *nif = net_e1000_netif();
lwip_sys_lock();
len += put_field(buf + len, "interface");
buf[len++] = nif->name[0];
buf[len++] = nif->name[1];
len += put_u(buf + len, nif->num);
buf[len++] = '\n';
len += put_field(buf + len, "mac");
len += put_mac(buf + len, nif->hwaddr);
buf[len++] = '\n';
len += put_field(buf + len, "state");
len += put_str(buf + len, net_bound ? "up\n" : "no address\n");
len += put_field(buf + len, "address");
len += put_ip4(buf + len, netif_ip4_addr(nif));
buf[len++] = '\n';
len += put_field(buf + len, "netmask");
len += put_ip4(buf + len, netif_ip4_netmask(nif));
buf[len++] = '\n';
len += put_field(buf + len, "gateway");
len += put_ip4(buf + len, netif_ip4_gw(nif));
buf[len++] = '\n';
len += put_field(buf + len, "mtu");
len += put_u(buf + len, nif->mtu);
buf[len++] = '\n';
lwip_sys_unlock();
uint64 rx, tx;
net_e1000_counts(&rx, &tx);
len += put_field(buf + len, "rx_frames");
len += put_u(buf + len, rx);
buf[len++] = '\n';
len += put_field(buf + len, "tx_frames");
len += put_u(buf + len, tx);
buf[len++] = '\n';
return len;
}
// Start the stack. Called from main() once processes exist.
void
netinit(void)

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