diff options
Diffstat (limited to 'examples/http.zig')
| -rw-r--r-- | examples/http.zig | 922 |
1 files changed, 922 insertions, 0 deletions
diff --git a/examples/http.zig b/examples/http.zig new file mode 100644 index 0000000..03d89c1 --- /dev/null +++ b/examples/http.zig @@ -0,0 +1,922 @@ +//! The data path, end to end: associate, take a DHCP lease, answer a ping, fetch a URL. +//! +//! examples/radio.zig proved the control path - SDIO up, RPC in both directions, a scan, an +//! association event from the C6. What it did not do is move a single Ethernet frame, and the +//! symptom was ESP-Hosted logging `task still writing Rx data to queue!` once associated: the AP's +//! traffic arriving with nobody registered to consume it. +//! +//! This file registers that consumer (src/net/link.zig) and puts src/net/ip.zig behind it. Nothing +//! from lwIP, esp_netif or the IDF network stack is linked; the IPv4, ARP, ICMP, UDP, DHCP, TCP and +//! HTTP are all this project's, in one 3.4 KB struct that allocates nothing and reads no clock. +//! +//! Run: +//! zig build -Dhosted -Dapp=examples/http.zig \ +//! -Dssid=YOUR_SSID -Dpsk-file=/path/to/psk run +//! +//! What it prints, in order, and what each line means. Every wait below is bounded and every +//! bound prints why it gave up, because a silent wait on this board is indistinguishable from a +//! dead scheduler. +//! +//! MARK HTTP_START cpu=... the image booted; the CPU clock, from two independent counters +//! MARK HTTP_WDT ... the bootloader's RTC watchdog was found armed and disarmed +//! MARK HTTP_TICK ... heartbeat during bring-up: scheduler alive, C making progress +//! MARK HTTP_RADIO_UP after N ms the C6 answered and the transport reached its active state +//! bad case: MARK HTTP_FAIL radio ... - the transport never came up. Nothing below can run. +//! MARK HTTP_WIFI_START rc=0 the first RPC round trip after bring-up +//! bad case: rc!=0, or spun>0 meaning the RPC threads never left their not-ready loop +//! MARK HTTP_LINK mac=... foot=N the station channel is registered; `mac` is the C6's radio's +//! own address and is what ARP will answer for. From this line on, +//! station frames reach src/net/ip.zig instead of being discarded. +//! bad case: MARK HTTP_FAIL link=MacUnavailable (asked before the radio was started) or +//! =ChannelRegisterFailed (transport_drv_add_channel refused; wrong if_type) +//! MARK HTTP_JOIN ssid=... psk_len=N the association request went out. The passphrase is never +//! printed; only its length, which distinguishes a missing +//! -Dpsk-file from a wrong one. +//! MARK HTTP_ASSOC after N ms by=X rx=N associated. `by=event` means WIFI_EVENT_STA_CONNECTED +//! (id 4) arrived; `by=traffic` means station frames arrived, +//! which an AP does not send to an unassociated station and which +//! is what happens when the C6 was already on this SSID. +//! bad case: MARK HTTP_FAIL assoc timeout ... - the radio never associated. Wrong passphrase, +//! wrong band, or out of range. Any RADIO_EVENT id=5 line before it is a disconnect +//! and carries the reason in its length. +//! MARK HTTP_DHCP_TX ... DISCOVER sent. This is the first frame this project has ever +//! put on a real network. +//! MARK HTTP_DHCP_WAIT ... progress, every second: DHCP state and the frame counters. If +//! `rx=0` stays zero the receive callback is not being called at +//! all; if `rx` climbs while `drop` climbs with it, frames are +//! arriving and being rejected - which is what a frame shifted by +//! a header looks like, so `csum` and `drop` are printed too. +//! MARK HTTP_IP ip=... mask=... a real lease from the real DHCP server +//! MARK HTTP_ROUTE gw=... dns=... in N ms and the route it came with +//! bad case: MARK HTTP_FAIL dhcp state=selecting - DISCOVERs went out, no OFFER came back +//! (frames leaving but not arriving), or state=requesting - an OFFER arrived and the +//! ACK did not. The state is the diagnosis, which is why it is printed rather than +//! just "timeout". +//! MARK HTTP_PING_OPEN ip=... for N ms ping the board from the workstation NOW +//! MARK HTTP_PING n=N arp_rx=... arp_tx=... one line per echo request answered. The ARP +//! counters beside it are what prove the exchange was two-way: +//! the workstation had to ARP for this address and be answered +//! before it could send the echo at all. +//! bad case: no such line for the whole window. If `HTTP_PING_IDLE` shows rx climbing, frames +//! arrive and the board is not recognising them as ICMP for its own address. +//! MARK HTTP_GET host=... port=... path=... the request begins +//! MARK HTTP_STATUS code=... len=N tcp=... the response's status line was parsed +//! MARK HTTP_BODY ... the first bytes of the body, non-printables as '.' +//! bad case: MARK HTTP_FAIL get=HostUnreachable (no ARP answer for the peer), +//! =TimedOut (SYN or data unacknowledged), =ConnectionReset (nothing listening on +//! that port), =WouldBlock (our own deadline, printed with the TCP state) +//! MARK HTTP_LIVE ... still running and still answering ICMP, once a second + +const std = @import("std"); +const hal = @import("hal"); +const soc = @import("soc"); +const net = @import("net"); +const p4 = @import("io"); +const config = @import("config"); + +/// Required by any application implementing `std.Io.VTable` on this target, and it must be in the +/// ROOT module: std reads `std.options` from `@import("root")`. +pub const std_options: std.Options = .{ .page_size_min = 4096 }; + +pub const panic = std.debug.FullPanic(struct { + fn call(msg: []const u8, _: ?usize) noreturn { + soc.rom.print("MARK HTTP_PANIC %s\r\n", .{msg.ptr}); + while (true) {} + } +}.call); + +// =============================================================================== the target +// +// The HTTP peer. A host on this workstation's own /24 rather than the gateway's web UI, so the +// expected response bytes are known in advance and a wrong answer means this stack is wrong rather +// than that a router's firmware is unusual. +// +// Serve it with, from the directory holding the file to be fetched: +// python3 -m http.server 8080 --bind 192.168.1.181 +// A `Content-Length` is what that sends, so the body's end is framed by the header rather than by +// the peer's FIN - which exercises the more interesting of ip.zig's two termination paths. +const http_host: [4]u8 = .{ 192, 168, 1, 181 }; +const http_port: u16 = 8080; +const http_path = "/"; + +/// How long the board stays quietly pingable before it makes the HTTP request. Long enough for a +/// person to run `ping` after reading the line that says to. +const ping_window_ms: u64 = 30_000; + +// ================================================================================== budgets +// +// Every one of these is a bound on a wait that would otherwise be a hang, and each is generous +// against the thing it is waiting for rather than round. + +/// The C6 boots in well under a second; examples/radio.zig saw the transport up at 1,890 ms. +const radio_deadline_ms: u64 = 10_000; +/// A 2.4 GHz association with WPA2 is a second or two. Twenty allows for a scan and a retry. +const assoc_deadline_ms: u64 = 60_000; + +/// How many association attempts before giving up. +/// +/// Ten, because this link is genuinely marginal and the failure is not ours. Measured on this board: +/// the same SSID and passphrase associate on some attempts and are refused on others with reason=4 +/// ASSOC_EXPIRE at about -68 dBm, and one run exhausted four attempts in a row. Reason 15 would mean +/// a wrong passphrase; 4 means the AP expired the association, and the only cure is to ask again. +/// +/// Ten attempts against a 45 s deadline is roughly one every 4.5 s, which is slower than the AP's own +/// timeout and so does not pile requests on top of each other. +const assoc_attempts: u32 = 6; + +/// Pause between association attempts. See the comment at the retry itself: hammering the AP is what +/// turns a marginal link into a refused one. +const assoc_backoff_ms: u64 = 3_000; +/// DHCP's own first retransmission is at 4 s and ip.zig retries with backoff; fifteen seconds is +/// three or four DISCOVERs, which is enough to distinguish "no server" from "one lost frame". +const dhcp_deadline_ms: u64 = 15_000; +/// ARP, a SYN, a request and a response, with TCP's initial RTO at 1 s and six retries available. +const http_deadline_ms: u64 = 20_000; + +// ================================================================================ resources + +/// Twelve task slots. Sizing this too small does NOT fail loudly: `io.async` with no free slot runs +/// the body eagerly instead of returning an error, so a reader task that should loop forever runs +/// once at spawn and is never seen again. ESP-Hosted spawns seven for the transport plus RPC's. +var pool: p4.Static(12, 2048) = .{}; + +/// ESP-Hosted's heap. examples/radio.zig measured the transport and RPC at well under 32 KiB with +/// the SDIO queues at 4/4; the station channel adds one 1,536-byte aligned transmit buffer in +/// flight per queued frame, and each received frame is a `payload_len` allocation freed by +/// `link.onRxFrame` before the next is dispatched. HTTP_HEAP prints what was really used. +var heap_backing: [70 * 1024]u8 align(8) = undefined; + +// ==================================================================================== state + +var associated: bool = false; +var disconnects: u32 = 0; +var bringup_result: ?anyerror = null; +var bringup_done: bool = false; + +/// ESP-Hosted's Wi-Fi RPC, through the C shim in src/net/hosted/wifi_shim.c. The structs stay in C +/// on purpose; see that file's header. +extern fn hosted_wifi_sta_start() c_int; +extern fn hosted_wifi_connect(ssid: [*:0]const u8, psk: [*:0]const u8) c_int; + +/// NUL-terminated credential copies for the C ABI, sized to the maxima `wifi_config_t` itself uses. +/// File scope rather than local because the retry loop below re-issues the request and must pass the +/// same bytes; the passphrase is never printed and never written anywhere else. +var ssid_buf: [33]u8 = @splat(0); +var psk_buf: [65]u8 = @splat(0); + +/// `WIFI_EVENT_STA_CONNECTED`. The C6 reported this as `base=wifi id=4` on the run that proved +/// association; `id=5` beside it is `WIFI_EVENT_STA_DISCONNECTED`. +const wifi_event_sta_connected: i32 = 4; +const wifi_event_sta_disconnected: i32 = 5; + +fn bringUp(ctx: struct { io: std.Io, gpa: std.mem.Allocator }) void { + net.init(ctx.io, ctx.gpa) catch |err| { + bringup_result = err; + bringup_done = true; + return; + }; + bringup_done = true; +} + +fn onEvent(ev: net.port.Event) void { + const base_name: [*:0]const u8 = switch (ev.base) { + .wifi => "wifi", + .named => |n| n, + }; + soc.rom.print("MARK HTTP_EVENT base=%s id=%d len=%u\r\n", .{ + base_name, + ev.id, + @as(u32, @intCast(if (ev.data) |d| d.len else 0)), + }); + if (ev.base == .wifi) { + if (ev.id == wifi_event_sta_connected) associated = true; + // A disconnect after association is worth counting rather than acting on: the C6 retries on + // its own, and a stack that tore itself down on every roam would be less useful than one + // that says how many times it happened. + if (ev.id == wifi_event_sta_disconnected) { + disconnects += 1; + // The reason code is the whole diagnostic value of this event: without it a refused + // association is indistinguishable from an access point that was never there. Layout is + // `wifi_event_sta_disconnected_t` - ssid[32], ssid_len, bssid[6], reason, rssi - so 41 + // bytes with reason at offset 39. Length-checked rather than assumed, because another + // IDF version would move it. + if (ev.data) |d| { + if (d.len >= 41) { + const reason = d[39]; + const rssi: i8 = @bitCast(d[40]); + soc.rom.print("MARK HTTP_DISCONNECT reason=%u rssi=%d %s\r\n", .{ + @as(u32, reason), + @as(i32, rssi), + reasonName(reason), + }); + } else { + soc.rom.print("MARK HTTP_DISCONNECT payload=%u B, expected 41\r\n", .{ + @as(u32, @intCast(d.len)), + }); + } + } + } + } +} + +/// The disconnect reasons that actually occur during bring-up, named. From `wifi_err_reason_t` +/// (esp_wifi_types_generic.h). Anything else prints its number, which is more useful than a wrong +/// guess at a name. +fn reasonName(reason: u8) [*:0]const u8 { + return switch (reason) { + 2 => "AUTH_EXPIRE", + 4 => "ASSOC_EXPIRE", + 15 => "4WAY_HANDSHAKE_TIMEOUT - almost always a wrong passphrase", + 23 => "802_1X_AUTH_FAILED", + 200 => "BEACON_TIMEOUT", + 201 => "NO_AP_FOUND - wrong SSID, or not on a channel the C6 can see", + 202 => "AUTH_FAIL", + 203 => "ASSOC_FAIL", + 204 => "HANDSHAKE_TIMEOUT", + 205 => "CONNECTION_FAIL", + else => "see wifi_err_reason_t", + }; +} + +/// Reset entry. The bootloader hands over with an unspecified stack pointer and the FPU off, so: +/// enable the F extension (mstatus.FS = Initial), establish the stack, clear .bss, then enter Zig. +export fn _start() linksection(".text.entry") callconv(.naked) noreturn { + asm volatile ( + \\ li t0, 1 << 13 + \\ csrs mstatus, t0 + \\ la sp, __stack_top + \\ mv fp, sp + \\ la t0, __bss_start + \\ la t1, __bss_end + \\ bgeu t0, t1, 2f + \\1: + \\ sw zero, 0(t0) + \\ addi t0, t0, 4 + \\ bltu t0, t1, 1b + \\2: + \\ j zig_main + ); +} + +export fn zig_main() noreturn { + // The bootloader leaves the RTC watchdog armed and expects the application to take it over. + // Nothing here feeds it, and this program deliberately runs for minutes. + const wdt_was_armed = hal.rwdt.armed(); + const wdt_off = hal.rwdt.disable(); + soc.rom.print("MARK HTTP_WDT armed_at_entry=%u disabled=%u\r\n", .{ + @as(u32, @intFromBool(wdt_was_armed)), + @as(u32, @intFromBool(wdt_off)), + }); + + // Two independent clocks over one interval: the systimer is a fixed 16 MHz, so the CPU + // frequency falls out of the ratio. Everything above assumes the bootloader's 90 MHz. + const t0 = hal.systimer.read(.unit0) orelse 0; + const c0 = soc.cycles(); + soc.rom.ets_delay_us(10_000); + const t1 = hal.systimer.read(.unit0) orelse 0; + const c1 = soc.cycles(); + const cpu_khz: u64 = if (t1 > t0) ((c1 - c0) * (hal.systimer.hz / 1000)) / (t1 - t0) else 0; + soc.rom.print("\r\nMARK HTTP_START cpu=%u kHz\r\n", .{@as(u32, @truncate(cpu_khz))}); + + const runtime = pool.init(.{}); + const io = runtime.io(); + + var backing = net.heap.Heap.init(&heap_backing); + const gpa = backing.allocator(); + + net.port.setEventHandler(&onEvent); + + // Bring-up runs on its own task: `transport_drv_reconfigure` blocks in a 200 ms retry loop + // waiting for the slave, so a main task inside it could not print. With it elsewhere, the + // heartbeat below distinguishes "the transport is stuck" from "the scheduler is stuck". + var bringup = io.async(bringUp, .{.{ .io = io, .gpa = gpa }}); + soc.rom.print("MARK HTTP_SETUP dispatched\r\n", .{}); + + radioUp(runtime); + bringup.cancel(io); + + // The station channel, and the IP stack behind it. Before the association request rather than + // after, so there is never a window in which the AP's traffic arrives with nothing to consume + // it - that window is exactly what produced `task still writing Rx data to queue!`. + wifiStart(); + linkUp(); + join(runtime); + dhcp(runtime); + pingWindow(runtime); + httpGet(runtime); + internetGet(runtime); + live(runtime); +} + +// ============================================================================= 1. the radio + +fn radioUp(runtime: *p4.Runtime) void { + const started = nowMs(); + var last_report: u64 = 0; + while (!net.isUp()) { + runtime.yield(); + const elapsed = nowMs() - started; + if (elapsed - last_report >= 1000) { + last_report = elapsed; + const s = net.port.stats(); + soc.rom.print("MARK HTTP_TICK t=%u ms heap=%u B blocks=%u stubs=%u done=%u\r\n", .{ + @as(u32, @intCast(elapsed)), + @as(u32, @intCast(s.bytes_live)), + @as(u32, @intCast(s.blocks_live)), + s.stub_calls, + @as(u32, @intFromBool(bringup_done)), + }); + } + if (bringup_done) { + if (bringup_result) |err| fail("radio", @errorName(err)); + } + if (elapsed > radio_deadline_ms) { + soc.rom.print("MARK HTTP_FAIL radio timeout after %u ms (slave never answered)\r\n", .{ + @as(u32, @intCast(elapsed)), + }); + park(); + } + } + soc.rom.print("MARK HTTP_RADIO_UP after %u ms\r\n", .{@as(u32, @intCast(nowMs() - started))}); +} + +// ========================================================================== 2. the station + +fn wifiStart() void { + // `spun` counts ESP-Hosted's RPC not-ready spins across the call. Both `rpc_rx_thread` and + // `rpc_tx_thread` open their loop with `if (!is_rpc_lib_ready()) _h_sleep(1)` + // (rpc_core.c:482-485, :543-547) and those are the only `_h_sleep` callers linked here, so a + // non-zero value means the request was enqueued and never transmitted - a completely different + // failure from the coprocessor refusing it. + const before = net.port.stats().hosted_sleep_calls; + const rc = hosted_wifi_sta_start(); + const after = net.port.stats().hosted_sleep_calls; + soc.rom.print("MARK HTTP_WIFI_START rc=%d spun=%u\r\n", .{ rc, @as(u32, after - before) }); + if (rc != 0) fail("wifi_start", "rpc_wifi_start refused"); +} + +fn linkUp() void { + net.link.open() catch |err| fail("link", @errorName(err)); + const m = net.link.mac(); + soc.rom.print("MARK HTTP_LINK mac=%02x:%02x:%02x:%02x:%02x:%02x foot=%u B\r\n", .{ + @as(u32, m[0]), @as(u32, m[1]), @as(u32, m[2]), + @as(u32, m[3]), @as(u32, m[4]), @as(u32, m[5]), + @as(u32, net.link.footprint), + }); +} + +fn join(runtime: *p4.Runtime) void { + if (config.wifi_ssid.len == 0) { + soc.rom.print("MARK HTTP_FAIL join: no -Dssid given, nothing to associate with\r\n", .{}); + park(); + } + if (config.wifi_ssid.len > 32 or config.wifi_psk.len > 64) { + soc.rom.print("MARK HTTP_FAIL join: ssid or psk longer than 802.11 allows\r\n", .{}); + park(); + } + // NUL-terminated copies for the C ABI, sized to the maxima `wifi_config_t` itself uses. + @memcpy(ssid_buf[0..config.wifi_ssid.len], config.wifi_ssid); + @memcpy(psk_buf[0..config.wifi_psk.len], config.wifi_psk); + + // The SSID is printed; the passphrase is not, and only its length is - enough to tell a missing + // -Dpsk-file from a wrong one without putting the secret on a serial line. + soc.rom.print("MARK HTTP_JOIN ssid=%s psk_len=%u\r\n", .{ + @as([*:0]const u8, @ptrCast(&ssid_buf)), + @as(u32, @intCast(config.wifi_psk.len)), + }); + + // Already associated? The C6 keeps its association across a host reset, and radio.zig may have + // left one behind. Asking again is harmless and is what makes this file runnable twice. + const rc = hosted_wifi_connect(@ptrCast(&ssid_buf), @ptrCast(&psk_buf)); + soc.rom.print("MARK HTTP_CONNECT rc=%d\r\n", .{rc}); + if (rc != 0) fail("connect", "rpc_wifi_connect refused"); + + // Association is asynchronous: the request is answered at once and the outcome arrives as an + // event. Keep the scheduler running and bound the wait. + // + // Two conditions end this wait, not one. The event is the direct evidence. Received station + // traffic is the *indirect* evidence and it is just as conclusive: an AP does not forward + // broadcasts to a station that is not associated with it. The second condition exists because + // the C6 keeps its association across a host reset - examples/radio.zig may have left one + // behind - and a `connect` request that finds the radio already on this SSID need not produce + // a fresh id=4. Without it, the commonest happy case would time out with a misleading reason. + const started = nowMs(); + var why: [*:0]const u8 = "event"; + // Retry, because a single refusal is normal on a real network rather than a failure. + // + // Measured on this board: the same passphrase and SSID associate on some attempts and are + // refused on others with reason=4 ASSOC_EXPIRE at about -68 dBm. That is the AP expiring the + // association, not a credential problem - reason 15 would say wrong passphrase - and giving up + // on the first one made a working stack look broken half the time. Re-issuing the request is + // what any supplicant does. + var attempts: u32 = 1; + var seen_disconnects = disconnects; + while (!associated) { + drive(runtime); + if (net.link.ipCounters().rx_frames > 0) { + why = "traffic"; + break; + } + + // A new disconnect while still unassociated means this attempt is over; start another + // rather than waiting out a deadline that cannot now be met. + if (disconnects != seen_disconnects) { + seen_disconnects = disconnects; + if (attempts < assoc_attempts) { + attempts += 1; + // Back off before asking again, and this matters more than the attempt count. + // + // Retrying immediately made things worse, not better: ten back-to-back attempts + // produced an alternating run of reason=205 CONNECTION_FAIL at rssi=-128 and + // reason=15 4WAY_HANDSHAKE_TIMEOUT at rssi=-58 - a strong signal and a failed + // handshake, with a passphrase that had associated successfully minutes earlier. + // That pattern is an access point refusing a client that is hammering it, not a + // credential problem: reason 15 would otherwise mean a wrong passphrase, and + // psk_len proves the bytes are unchanged. + // + // Three seconds is longer than the AP's own handshake timeout, so each attempt is a + // fresh one rather than a collision with the last. + const backoff_started = nowMs(); + while (nowMs() - backoff_started < assoc_backoff_ms) drive(runtime); + soc.rom.print("MARK HTTP_RETRY attempt %u of %u after %u ms backoff\r\n", .{ + attempts, + assoc_attempts, + @as(u32, @intCast(assoc_backoff_ms)), + }); + if (hosted_wifi_connect(@ptrCast(&ssid_buf), @ptrCast(&psk_buf)) != 0) { + fail("connect", "rpc_wifi_connect refused on retry"); + } + } + } + + const elapsed = nowMs() - started; + if (elapsed > assoc_deadline_ms) { + soc.rom.print( + "MARK HTTP_FAIL assoc timeout after %u ms (attempts=%u, disconnects=%u, no id=4, no frames)\r\n", + .{ @as(u32, @intCast(elapsed)), attempts, disconnects }, + ); + park(); + } + } + soc.rom.print("MARK HTTP_ASSOC after %u ms by=%s rx=%u\r\n", .{ + @as(u32, @intCast(nowMs() - started)), + why, + net.link.ipCounters().rx_frames, + }); +} + +// ================================================================================== 3. DHCP + +fn dhcp(runtime: *p4.Runtime) void { + // `dhcpStart` stamps the acquisition's start from the stack's idea of now, and only `tick` + // sets that. One tick first, therefore, or every DHCP deadline is measured from zero. + _ = net.link.tick(nowMs()); + if (!net.link.dhcpStart()) fail("dhcp", "stack busy at dhcpStart, which cannot happen here"); + soc.rom.print("MARK HTTP_DHCP_TX discover sent\r\n", .{}); + + const started = nowMs(); + var last_report: u64 = 0; + while (net.link.address() == null) { + drive(runtime); + const elapsed = nowMs() - started; + if (elapsed - last_report >= 1000) { + last_report = elapsed; + const c = net.link.ipCounters(); + const l = net.link.stats(); + soc.rom.print( + "MARK HTTP_DHCP_WAIT t=%u state=%s rx=%u drop=%u csum=%u tx=%u dhcp_rx=%u re=%u\r\n", + .{ + @as(u32, @intCast(elapsed)), + @tagName(net.link.dhcpState()).ptr, + c.rx_frames, + c.rx_dropped, + c.checksum_bad, + c.tx_frames, + c.dhcp_rx, + l.rx_reentrant, + }, + ); + } + if (elapsed > dhcp_deadline_ms) { + const c = net.link.ipCounters(); + soc.rom.print( + "MARK HTTP_FAIL dhcp timeout after %u ms state=%s sent=%u got=%u rx=%u drop=%u\r\n", + .{ + @as(u32, @intCast(elapsed)), + @tagName(net.link.dhcpState()).ptr, + c.dhcp_tx, + c.dhcp_rx, + c.rx_frames, + c.rx_dropped, + }, + ); + park(); + } + } + + const a = net.link.address().?; + const m = net.link.netmask(); + const g = net.link.gateway(); + const d = net.link.dnsServer() orelse @as([4]u8, @splat(0)); + // Two lines rather than one seventeen-argument line. `ets_printf` is the mask ROM's, taking + // its arguments the ordinary variadic way - eight in registers and the rest on the stack - and + // there is no reason to be the first caller in this repo to find out where its limit is. + soc.rom.print("MARK HTTP_IP ip=%u.%u.%u.%u mask=%u.%u.%u.%u\r\n", .{ + @as(u32, a[0]), @as(u32, a[1]), @as(u32, a[2]), @as(u32, a[3]), + @as(u32, m[0]), @as(u32, m[1]), @as(u32, m[2]), @as(u32, m[3]), + }); + soc.rom.print("MARK HTTP_ROUTE gw=%u.%u.%u.%u dns=%u.%u.%u.%u in %u ms\r\n", .{ + @as(u32, g[0]), @as(u32, g[1]), @as(u32, g[2]), @as(u32, g[3]), + @as(u32, d[0]), @as(u32, d[1]), @as(u32, d[2]), @as(u32, d[3]), + @as(u32, @intCast(nowMs() - started)), + }); + heapReport(); +} + +// ================================================================================== 4. ICMP + +/// Yield, and tick the IP stack at most every `tick_interval_ms`. +/// +/// The rate limit is the whole point. `link.tick` holds the stack's re-entrancy guard for its +/// duration, and a frame that arrives while the guard is held is dropped - ESP-Hosted's receive task +/// and this one are different tasks, so they genuinely collide. A loop of the obvious shape, +/// +/// while (...) { runtime.yield(); _ = net.link.tick(nowMs()); } +/// +/// ticks thousands of times a second, holds the guard for most of the elapsed time, and drops much +/// of the traffic it is waiting for. Measured on the board: 40 dropped frames out of 113 received, +/// and ping answered at about 40%, even though every reply that was attempted did arrive. +/// +/// Five milliseconds is far finer than anything ip.zig needs - its timers are absolute deadlines, so +/// a late tick is late rather than lost - and it leaves the guard clear almost all the time. +fn drive(runtime: *p4.Runtime) void { + runtime.yield(); + const now = nowMs(); + if (now - last_tick_ms >= tick_interval_ms) { + last_tick_ms = now; + _ = net.link.tick(now); + } +} + +const tick_interval_ms: u64 = 5; +var last_tick_ms: u64 = 0; + +fn pingWindow(runtime: *p4.Runtime) void { + const a = net.link.address().?; + soc.rom.print( + "MARK HTTP_PING_OPEN ip=%u.%u.%u.%u for %u ms - ping it now\r\n", + .{ + @as(u32, a[0]), + @as(u32, a[1]), + @as(u32, a[2]), + @as(u32, a[3]), + @as(u32, @intCast(ping_window_ms)), + }, + ); + + const started = nowMs(); + var seen: u32 = net.link.ipCounters().icmp_echo; + var last_report: u64 = 0; + while (true) { + drive(runtime); + const c = net.link.ipCounters(); + if (c.icmp_echo != seen) { + seen = c.icmp_echo; + // One line per echo answered. `arp` beside it is what proves the exchange was two-way: + // the workstation had to ARP for this address and get an answer before it could send + // the echo at all. + soc.rom.print("MARK HTTP_PING n=%u arp_rx=%u arp_tx=%u\r\n", .{ + seen, + c.arp_rx, + c.arp_tx, + }); + } + const elapsed = nowMs() - started; + if (elapsed - last_report >= 5000) { + last_report = elapsed; + // Heap included deliberately. The board reported "mempool OOM start (RX)" at ~13 s in a + // previous run: with the pool off, ESP-Hosted allocates every received frame from our + // heap via `_h_malloc_align(1536, 64)`, and when that fails the receive path simply + // stops delivering. `live` climbing while `rx` is static means a leak; `live` pinned + // near the heap size with `rx` frozen means a heap too small for the queue depths. + // Without both numbers side by side those two are indistinguishable. + const h = net.port.stats(); + soc.rom.print( + "MARK HTTP_PING_IDLE t=%u echo=%u rx=%u drop=%u tx=%u live=%u B blocks=%u fails=%u\r\n", + .{ + @as(u32, @intCast(elapsed)), + c.icmp_echo, + c.rx_frames, + c.rx_dropped, + c.tx_frames, + @as(u32, @intCast(h.bytes_live)), + @as(u32, @intCast(h.blocks_live)), + @as(u32, @intCast(h.alloc_failures)), + }, + ); + } + if (elapsed > ping_window_ms) break; + } + soc.rom.print("MARK HTTP_PING_DONE echoes=%u\r\n", .{net.link.ipCounters().icmp_echo}); +} + +// ================================================================================== 5. HTTP + +/// The response body. Static rather than on the stack because `httpGet` borrows it for the whole +/// request - the body is written into it from inside the receive callback, on a different task - +/// and a task stack is 4 KiB. +var body: [2048]u8 = undefined; + +fn httpGet(runtime: *p4.Runtime) void { + soc.rom.print("MARK HTTP_GET host=%u.%u.%u.%u port=%u path=%s\r\n", .{ + @as(u32, http_host[0]), @as(u32, http_host[1]), + @as(u32, http_host[2]), @as(u32, http_host[3]), + @as(u32, http_port), @as([*:0]const u8, http_path), + }); + + const started = nowMs(); + var last_report: u64 = 0; + const got: usize = while (true) { + drive(runtime); + // Identical arguments every call, and `body` never moves: that is the whole of the + // WouldBlock protocol. Different arguments mid-flight would return error.Busy instead. + if (net.link.httpGet(http_host, http_port, http_path, &body)) |n| { + break n; + } else |err| switch (err) { + error.WouldBlock => {}, + else => { + soc.rom.print("MARK HTTP_FAIL get=%s tcp=%s after %u ms\r\n", .{ + @errorName(err).ptr, + @tagName(net.link.tcpState()).ptr, + @as(u32, @intCast(nowMs() - started)), + }); + park(); + }, + } + const elapsed = nowMs() - started; + if (elapsed - last_report >= 2000) { + last_report = elapsed; + const c = net.link.ipCounters(); + soc.rom.print("MARK HTTP_WAIT t=%u tcp=%s tx=%u retx=%u rx=%u rst=%u\r\n", .{ + @as(u32, @intCast(elapsed)), + @tagName(net.link.tcpState()).ptr, + c.tcp_tx, + c.tcp_retx, + c.tcp_rx, + c.tcp_rst_rx, + }); + } + if (elapsed > http_deadline_ms) { + const c = net.link.ipCounters(); + soc.rom.print( + "MARK HTTP_FAIL get timeout after %u ms tcp=%s arp_tx=%u tcp_tx=%u tcp_rx=%u\r\n", + .{ + @as(u32, @intCast(elapsed)), + @tagName(net.link.tcpState()).ptr, + c.arp_tx, + c.tcp_tx, + c.tcp_rx, + }, + ); + park(); + } + }; + + soc.rom.print("MARK HTTP_STATUS code=%u len=%u in %u ms\r\n", .{ + @as(u32, net.link.httpStatus()), + @as(u32, @intCast(got)), + @as(u32, @intCast(nowMs() - started)), + }); + printBody(body[0..got]); + heapReport(); +} + +/// The first bytes of the body, on one line, with anything that is not printable ASCII shown as +/// '.'. A response body is arbitrary bytes and this is a serial console: a stray 0x0d would split +/// the line and a 0x00 would truncate it, and either would look like a short read. +fn printBody(bytes: []const u8) void { + const preview_max = 120; + var buf: [preview_max + 1]u8 = undefined; + const n = @min(bytes.len, preview_max); + for (bytes[0..n], 0..) |b, i| buf[i] = if (b >= 0x20 and b < 0x7f) b else '.'; + buf[n] = 0; + soc.rom.print("MARK HTTP_BODY %u/%u |%s|\r\n", .{ + @as(u32, @intCast(n)), + @as(u32, @intCast(bytes.len)), + @as([*:0]const u8, @ptrCast(&buf)), + }); +} + +// ============================================================================== 6. stay live + +/// Keep running. The transport's threads must keep being scheduled, the stack keeps answering ICMP, +/// and leaving the board in a live state is what lets the next experiment start from here. +/// A real host on the real internet: resolve a name, then fetch it. +/// +/// A strictly harder test than the GET against this workstation, and every extra thing it exercises +/// is one the local test could not: +/// +/// * DNS. The name is resolved over UDP against the server DHCP handed us - on this network the +/// gateway - so the resolver, the wire format and its name-compression handling are all in play. +/// * Off-subnet routing. 0x4200.cafe is not on 192.168.1.0/24, so the stack has to ARP for the +/// GATEWAY and send to its MAC while the IP header addresses the far host. The local test never +/// leaves the subnet and so never proved that. +/// * Name-based virtual hosting. The site is behind Cloudflare, which serves it only for a request +/// carrying `Host: 0x4200.cafe`. `Host: 104.21.46.8` gets someone else's error page - which is +/// the entire reason `httpGetHost` exists. +/// * Chunked transfer encoding. Measured from a workstation the response is +/// `Transfer-Encoding: chunked` with no Content-Length, so the body arrives in framed pieces +/// whose boundaries fall wherever TCP puts them. +/// +/// The lines, and what each means: +/// +/// MARK NET_DNS name -> a.b.c.d in N ms the resolver worked end to end +/// bad: failed=Timeout no reply from the DHCP-supplied server inside the bound +/// failed=NoAddress no A record (the name does have AAAA; this stack is IPv4) +/// failed=Malformed a reply arrived and was rejected +/// MARK NET_GET host=... via gw=... the request begins, naming the next hop +/// MARK NET_STATUS code=200 len=N in M ms a real page came back +/// bad: get=HttpChunkMalformed chunked framing rejected +/// get=StreamTooLong the page is bigger than the buffer here - not a stack bug; it +/// means this buffer is small, and this says so rather than truncating +/// MARK NET_BODY n/N |...| first bytes, non-printables as '.' +const internet_name = "0x4200.cafe"; + +/// 2 KiB, which is what the target resource needs and no more. +/// +/// The site's root page is 132,932 bytes decoded - larger than this board's entire 128 KB of L2MEM, +/// so it cannot be buffered whole by anything running here and `StreamTooLong` is the correct answer +/// for asking. Fetching it would need a streaming API that hands the caller each chunk as it arrives +/// and keeps none of it, which this stack does not have. That is a real limitation and it is stated +/// rather than hidden. +/// +/// `/robots.txt` on the same host is 1,248 bytes and exercises everything the root page would about +/// the path being tested here: the name is resolved by DNS, the destination is off-subnet so the +/// frame goes to the gateway's MAC, and the response only comes back at all because the request +/// carries `Host: 0x4200.cafe`. +var net_body: [2048]u8 = undefined; + +/// The path fetched. See `net_body` for why it is not `/`. +const internet_path = "/robots.txt"; + +fn internetGet(runtime: *p4.Runtime) void { + // 1. Resolve. Same WouldBlock protocol as httpGet: identical argument every call, drive between. + const dns_started = nowMs(); + const addr: [4]u8 = while (true) { + drive(runtime); + if (net.link.resolve(internet_name)) |a| { + break a; + } else |err| switch (err) { + error.WouldBlock => {}, + else => { + soc.rom.print("MARK NET_DNS %s failed=%s after %u ms\r\n", .{ + internet_name.ptr, + @errorName(err).ptr, + @as(u32, @intCast(nowMs() - dns_started)), + }); + return; + }, + } + if (nowMs() - dns_started > 15_000) { + soc.rom.print("MARK NET_DNS %s gave up after %u ms\r\n", .{ + internet_name.ptr, + @as(u32, @intCast(nowMs() - dns_started)), + }); + return; + } + }; + soc.rom.print("MARK NET_DNS %s -> %u.%u.%u.%u in %u ms\r\n", .{ + internet_name.ptr, + @as(u32, addr[0]), + @as(u32, addr[1]), + @as(u32, addr[2]), + @as(u32, addr[3]), + @as(u32, @intCast(nowMs() - dns_started)), + }); + + // The gateway is printed because it is the thing under test here: for an off-subnet destination + // the Ethernet frame goes to the gateway's MAC while the IP header addresses the far host, and a + // stack that got that wrong would look like a dead internet rather than a routing bug. + const gw = net.link.gateway(); + soc.rom.print("MARK NET_GET host=%s%s via gw=%u.%u.%u.%u\r\n", .{ + internet_name.ptr, + internet_path.ptr, + @as(u32, gw[0]), + @as(u32, gw[1]), + @as(u32, gw[2]), + @as(u32, gw[3]), + }); + + // 2. Fetch, with the NAME in the Host header rather than the address. + const started = nowMs(); + var last_report: u64 = 0; + const got: usize = while (true) { + drive(runtime); + if (net.link.httpGetHost(addr, internet_name, 80, internet_path, &net_body)) |n| { + break n; + } else |err| switch (err) { + error.WouldBlock => {}, + else => { + soc.rom.print("MARK NET_FAIL get=%s tcp=%s after %u ms\r\n", .{ + @errorName(err).ptr, + @tagName(net.link.tcpState()).ptr, + @as(u32, @intCast(nowMs() - started)), + }); + return; + }, + } + const elapsed = nowMs() - started; + if (elapsed - last_report >= 2000) { + last_report = elapsed; + soc.rom.print("MARK NET_WAIT t=%u tcp=%s\r\n", .{ + @as(u32, @intCast(elapsed)), + @tagName(net.link.tcpState()).ptr, + }); + } + if (elapsed > 30_000) { + soc.rom.print("MARK NET_FAIL timeout tcp=%s\r\n", .{@tagName(net.link.tcpState()).ptr}); + return; + } + }; + + soc.rom.print("MARK NET_STATUS code=%u len=%u in %u ms\r\n", .{ + @as(u32, net.link.httpStatus()), + @as(u32, @intCast(got)), + @as(u32, @intCast(nowMs() - started)), + }); + // First bytes only, printable-filtered: this is a serial line and an HTML page is not a log. + var shown: [96]u8 = undefined; + const n = @min(got, shown.len - 1); + for (0..n) |i| { + const c = net_body[i]; + shown[i] = if (c >= 0x20 and c < 0x7f) c else '.'; + } + shown[n] = 0; + soc.rom.print("MARK NET_BODY %u/%u |%s|\r\n", .{ + @as(u32, @intCast(n)), + @as(u32, @intCast(got)), + @as([*:0]const u8, @ptrCast(&shown)), + }); +} + +fn live(runtime: *p4.Runtime) noreturn { + var last: u64 = 0; + var last_echo: u32 = 0; + while (true) { + drive(runtime); + const t = nowMs(); + if (t - last >= 1000) { + last = t; + const c = net.link.ipCounters(); + const l = net.link.stats(); + soc.rom.print( + "MARK HTTP_LIVE t=%u echo=%u(+%u) rx=%u drop=%u tx=%u txfail=%u re=%u skip=%u\r\n", + .{ + @as(u32, @intCast(t / 1000)), + c.icmp_echo, + c.icmp_echo - last_echo, + c.rx_frames, + c.rx_dropped, + l.tx_frames, + l.tx_failed, + l.rx_reentrant, + l.tick_skipped, + }, + ); + last_echo = c.icmp_echo; + } + } +} + +// =================================================================================== shared + +fn heapReport() void { + const s = net.port.stats(); + soc.rom.print("MARK HTTP_HEAP live=%u B reserved=%u B peak=%u B blocks=%u fails=%u\r\n", .{ + @as(u32, @intCast(s.bytes_live)), + @as(u32, @intCast(s.bytes_reserved)), + @as(u32, @intCast(s.peak_reserved)), + @as(u32, @intCast(s.blocks_live)), + @as(u32, @intCast(s.alloc_failures)), + }); +} + +fn fail(comptime what: []const u8, why: [*:0]const u8) noreturn { + soc.rom.print("MARK HTTP_FAIL " ++ what ++ "=%s\r\n", .{why}); + park(); +} + +fn nowMs() u64 { + // The systimer is a fixed 16 MHz and the only trustworthy timebase here: the CPU runs at the + // bootloader's 90 MHz and nothing in this image reconfigures the PLL. A failed snapshot read + // yields 0, which makes an elapsed calculation conservative rather than wrong. + const ticks = hal.systimer.read(.unit0) orelse return 0; + return ticks / 16_000; +} + +/// `while (true) {}` rather than a reset, so the last message stays on screen instead of scrolling +/// past in a reboot loop. Note that this stops the scheduler too: nothing here is recoverable. +fn park() noreturn { + soc.rom.print("MARK HTTP_DONE parked\r\n", .{}); + while (true) {} +} |
