From f46fb41966c60aefebc0f6907d1f3157fd67fc7f Mon Sep 17 00:00:00 2001 From: Nexoniarz Date: Sat, 15 Aug 2026 16:11:49 +0200 Subject: [PATCH] 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 --- include/defs.h | 2 + kernel/dev/procfs.c | 5 ++ kernel/fs/procfs.c | 1 + kernel/net/lwip/netif_e1000.c | 15 ++++ kernel/net/net.c | 133 ++++++++++++++++++++++++++++++++++ tools/windows/bootcheck.ps1 | 113 ++++++++++++++++++++++++++++- 6 files changed, 268 insertions(+), 1 deletion(-) diff --git a/include/defs.h b/include/defs.h index dabae7d..8968f9f 100644 --- a/include/defs.h +++ b/include/defs.h @@ -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); diff --git a/kernel/dev/procfs.c b/kernel/dev/procfs.c index e2e74b0..8cc356c 100644 --- a/kernel/dev/procfs.c +++ b/kernel/dev/procfs.c @@ -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; } diff --git a/kernel/fs/procfs.c b/kernel/fs/procfs.c index 16b767a..5152448 100644 --- a/kernel/fs/procfs.c +++ b/kernel/fs/procfs.c @@ -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]))) diff --git a/kernel/net/lwip/netif_e1000.c b/kernel/net/lwip/netif_e1000.c index 872c882..6faee36 100644 --- a/kernel/net/lwip/netif_e1000.c +++ b/kernel/net/lwip/netif_e1000.c @@ -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; diff --git a/kernel/net/net.c b/kernel/net/net.c index 014b440..821def8 100644 --- a/kernel/net/net.c +++ b/kernel/net/net.c @@ -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) diff --git a/tools/windows/bootcheck.ps1 b/tools/windows/bootcheck.ps1 index 5a396d2..6440100 100644 --- a/tools/windows/bootcheck.ps1 +++ b/tools/windows/bootcheck.ps1 @@ -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