//! The seam: ESP-Hosted's station data channel, bridged to `src/net/ip.zig`. //! //! Everything below this file is proven - the SDIO host driver, the runtime, the port table, the //! RPC layer, the association. Everything above it is proven too: `ip.zig` has 117 host tests and a //! mutation sweep. This file is the twenty lines of pointer handling in between, and it is the one //! part of the path that no host test can check, because both of its neighbours are C. //! //! So every decision here is cited rather than inferred. //! //! ------------------------------------------------------------------------------------------ //! 1. Where the received frame starts: at `buffer`, offset zero. //! //! This is the single most expensive thing to get wrong. A frame shifted by the 12-byte //! `esp_payload_header` parses as garbage - the ethertype lands in the middle of a MAC address - //! and every one of ip.zig's tests would still pass. The RX convention is established by the //! producer and confirmed by the vendor's own consumer: //! //! * sdio_drv.c:830 rejects any packet whose header `offset` field is not //! `sizeof(struct esp_payload_header)`, so the payload always begins exactly one header in. //! * sdio_drv.c:887 `buf_handle.payload = rxbuff + offset` - `payload` already points past the //! header. `priv_buffer_handle` (:882) is what still points at the header. //! * sdio_drv.c:1396-1400 allocates `copy_payload = _h_malloc(buf_handle->payload_len)` and //! memcpy's `payload_len` bytes from `buf_handle->payload` into it, then frees the original //! buffer at :1401. So the copy is exactly the payload, nothing more. //! * sdio_drv.c:1407-1408 `rx(api_chan, copy_payload, copy_payload, payload_len)` - `buffer` //! and `buff_to_free` are the same pointer, and it is the start of the frame. //! * The vendor's own consumer agrees: esp_wifi_remote_net2.c:40-51 passes `buffer` straight to //! the netif receive function as the frame and `buff_to_free` only as the free handle. //! //! `H_ESP_PAYLOAD_HEADER_OFFSET` appears on the *transmit* side only (transport_drv.c:381), where //! ESP-Hosted is *building* a buffer and has to leave room for the header it is about to write. //! Adding it on receive would be applying the same correction twice, in the wrong direction. //! //! ------------------------------------------------------------------------------------------ //! 2. Who frees, and with what. //! //! `copy_payload` came from `_h_malloc` (sdio_drv.c:1396), so it is freed with `_h_free` - which //! is exactly what `HOSTED_FREE` expands to (port_esp_hosted_host_os.h:139) and what //! `transport_sta_free_cb` reaches through `MEMPOOL_FREE` with the pool disabled //! (transport_util.h:29-31). `onRxFrame` below frees it through `g_h.funcs->_h_free`, once, on //! every path including the error paths, and always returns `ESP_OK`. //! //! Returning `ESP_OK` unconditionally is not laziness, it is the only value that is safe under //! both of sdio_drv.c's ownership rules. With `ESP_WIFI_REMOTE_VERSION` >= 1.3.1 the callee always //! owns the buffer and the caller never frees (:1418). Below that, and when the macro is undefined, //! the caller frees the buffer *if the callee returned non-zero* (:1411-1416). A non-zero return //! from a callback that has already freed is therefore a double free under one rule and a leak //! under neither - so this file frees and returns zero, which is one free under both. //! //! ------------------------------------------------------------------------------------------ //! 3. `api_chan` must not be null. //! //! `transport_drv_sta_tx` opens with `assert(h && h == chan_arr[ESP_STA_IF]->api_chan)` //! (transport_drv.c:369), and the vendor's reference RX callback opens with `assert(h)` //! (esp_wifi_remote_net2.c:41). ESP-Hosted's own registration honours that: it allocates a cookie //! and passes it in (esp_hosted_api.c:200-203). This build compiles the C at -O2 with `-DNDEBUG` //! (Zig adds it for every non-Debug optimize mode), so those asserts are compiled out today and a //! null cookie would merely be an unchecked contract violation rather than a crash - which is a //! worse outcome, not a better one. `channel_cookie` below is that non-null cookie, and it is //! handed back to `tx` on every transmit so the identity check holds. //! //! ------------------------------------------------------------------------------------------ //! 4. The transmitted frame need not outlive the call. //! //! `transport_drv_sta_tx` allocates its own buffer and copies into it before queueing: //! `mempool_alloc(..., MAX_TRANSPORT_BUFFER_SIZE, true)` at transport_drv.c:372 - with the pool //! disabled that is `_h_malloc_align(1536, 64)` (transport_util.h:21-27) - then //! `_h_memcpy(copy_buff + H_ESP_PAYLOAD_HEADER_OFFSET, buffer, len)` at :381, and only then //! `esp_hosted_tx(..., copy_buff, ...)` at :383. Nothing retains `buffer`. That is what makes //! `ip.Stack`'s "the slice is borrowed for the duration of the call" contract satisfiable, and it //! is why `sendFrame` may hand over a pointer into the stack's single transmit staging buffer. //! //! ------------------------------------------------------------------------------------------ //! 5. Why there is a re-entrancy guard. //! //! This is the one hazard the task description does not mention and it is real. //! //! `ip.Stack` is a single-threaded state machine: `onFrame` may send (an ARP reply, an ICMP echo //! reply, a TCP ACK) before it returns, and `tick` and `httpGet` may too. Sending ends in //! `esp_hosted_tx`, whose last act is //! `_h_queue_item(to_slave_queue[prio], &buf_handle, HOSTED_BLOCK_MAX)` (sdio_drv.c:1607). That //! queue holds four items (`CONFIG_ESP_HOSTED_SDIO_TX_Q_SIZE 4`, src/net/hosted/sdkconfig.h:41) //! and `_h_queue_item` with `HOSTED_BLOCK_MAX` is a *blocking* send: port.zig:734-740 forwards it //! to `os.Queue.send`, which suspends the calling task until there is room. //! //! So a full transmit queue suspends whoever is inside the stack. `onRxFrame` runs on ESP-Hosted's //! `sdio_process_rx_task`; `tick` and `httpGet` run on the application's task. Without a guard, //! either one can be suspended mid-mutation and the other walk straight into the same `Stack`. //! On a cooperative scheduler that is not a torn read, it is two interleaved state machines //! sharing one transmit buffer, one TCP sequence space and one `http.out` slice. //! //! The guard makes that impossible, and every way it can fire has a correct answer already: //! //! * a frame arriving while the stack is busy is dropped, which is what a real NIC does when its //! transmit queue is full. DHCP, ARP and TCP all retransmit. //! * a `tick` skipped is a `tick` deferred: `ip.zig`'s timers are absolute deadlines compared //! against `now_ms` (`dhcpTick`, `tcpTick`), not increments, so nothing is lost. //! * `httpGet` returns `error.WouldBlock`, which is precisely the answer its protocol already //! requires the caller to handle by calling again with identical arguments. //! //! Each of those is counted, so a log can say which one happened rather than leaving a stall //! unexplained. const std = @import("std"); const ip = @import("ip.zig"); const port = @import("port.zig"); // ================================================================= ESP-Hosted's C surface /// `esp_hosted_if_type_t`, common/esp_hosted_interface.h:14-24. /// /// Note the value. The enumeration opens with `ESP_INVALID_IF`, so the station interface is **1**, /// not 0. Registering channel 0 would fall through `transport_drv_add_channel`'s switch to /// `default:` (transport_drv.c:481-484), which logs "Not yet supported" and returns NULL after /// having already installed a half-built channel - and `chan_arr[ESP_STA_IF]` would stay NULL, so /// sdio_drv.c:1394 would go on discarding every station frame in silence. const esp_sta_if: c_uint = 1; /// `transport_channel_tx_fn_t`, transport_drv.h:118. Returns `esp_err_t`; 0 is `ESP_OK`. const TxFn = *const fn (h: ?*anyopaque, buffer: ?*anyopaque, len: usize) callconv(.c) c_int; /// `transport_channel_rx_fn_t`, transport_drv.h:119. const RxFn = *const fn ( h: ?*anyopaque, buffer: ?*anyopaque, buff_to_free: ?*anyopaque, len: usize, ) callconv(.c) c_int; /// transport_drv.h:134-136. `tx` is an out-parameter: the transport writes the interface's own /// transmit function into it (transport_drv.c:469-471) and that is the only way to obtain it. /// /// This is compiled in - `transport_drv.c` is on build.zig's source list - but nothing calls it, /// because the file that normally does (`esp_hosted_api.c`'s `add_esp_wifi_remote_channels`) is /// not compiled: this project calls `setup_transport`, `rpc_init` and `transport_drv_reconfigure` /// directly from `src/net/all.zig`. Registering the station channel is therefore ours to do. extern fn transport_drv_add_channel( api_chan: ?*anyopaque, if_type: c_uint, secure: u8, tx: *?TxFn, rx: RxFn, ) ?*anyopaque; /// The station's MAC, through the C shim (src/net/hosted/wifi_shim.c:96). It belongs to the C6's /// radio, not to this chip, and ARP and Ethernet framing are built on it. Valid only after /// `hosted_wifi_sta_start`, because that is what brings the radio up on the coprocessor. extern fn hosted_wifi_get_mac(out: *[6]u8) c_int; // ============================================================================== module state /// The one IPv4 stack. A module-level variable rather than something the caller owns, because /// `ip.Stack.send` is `*const fn ([]const u8) void` with no context pointer: the transmit callback /// has to reach the transport some other way, and a file-scope binding is the honest version of /// "some other way". 3,576 bytes of .bss - see `footprint`. var sta: ip.Stack = undefined; /// The `api_chan` cookie. Its address is what ESP-Hosted stores and compares; its contents are /// never read by anyone. See note 3 in the header for why it may not be null. var channel_cookie: u32 = 0x5354_4100; // 'STA\0', so a memory dump names it /// The transport's station transmit function, from `transport_drv_add_channel`'s out-parameter. var tx_fn: ?TxFn = null; /// Set once the channel is registered and the stack is live. var opened: bool = false; /// The re-entrancy guard. See note 5 in the header. var in_stack: bool = false; pub const Stats = struct { /// Frames handed to us by sdio_drv.c, before any filtering. rx_frames: u32 = 0, /// Frames whose `h` was not our cookie. Non-zero means another channel's traffic reached this /// callback, which would be an ESP-Hosted bug and not something to paper over. rx_wrong_channel: u32 = 0, /// `buffer` was null, or `len` was zero or larger than an Ethernet frame. rx_bad: u32 = 0, /// Frames dropped because the stack was already entered. See note 5. rx_reentrant: u32 = 0, /// Frames actually delivered to `ip.Stack.onFrame`. rx_delivered: u32 = 0, /// `tick` calls that found the stack entered and did nothing. tick_skipped: u32 = 0, /// `httpGet`/`httpGetHost` calls answered `WouldBlock` by the guard rather than by the stack. http_deferred: u32 = 0, /// `resolve` calls answered `WouldBlock` by the guard rather than by the stack. The query's /// own timer runs in `tick`, so these cost a poll and never a retransmission. dns_deferred: u32 = 0, /// Frames handed to the transport. tx_frames: u32 = 0, /// Transmits the transport rejected: not ready, throttled, or out of buffers. tx_failed: u32 = 0, /// Transmits attempted before the channel existed. Should be zero. tx_no_channel: u32 = 0, /// Frames the stack asked to send, accepted into the deferred ring. The difference between this /// and `tx_frames` is what is still waiting for the next `tick`. tx_queued: u32 = 0, /// Frames dropped because the deferred ring was full when the stack tried to send. Non-zero /// means `tick` is not keeping up with the offered load; every protocol above this retransmits, /// so it costs latency rather than correctness. tx_ring_full: u32 = 0, /// Frames the stack offered with an impossible length. Should be zero; a non-zero value points /// at ip.zig rather than at the transport. tx_bad: u32 = 0, }; var counters: Stats = .{}; /// Everything this file adds to .bss, so the number in a report cannot rot. The stack dominates it. pub const footprint: usize = @sizeOf(@TypeOf(sta)) + @sizeOf(@TypeOf(channel_cookie)) + @sizeOf(@TypeOf(tx_fn)) + @sizeOf(@TypeOf(opened)) + @sizeOf(@TypeOf(in_stack)) + @sizeOf(@TypeOf(counters)) + @sizeOf(@TypeOf(tx_ring)); // ================================================================================= transmit /// `ip.Stack.send`. The slice is borrowed for the duration of this call only, which is exactly what /// the transport needs - see note 4 in the header. fn sendFrame(frame: []const u8) void { if (tx_fn == null) { counters.tx_no_channel += 1; return; } if (frame.len == 0 or frame.len > ip.frame_max) { counters.tx_bad += 1; return; } // Queued, never transmitted from here. See `flushTx`. const next = (tx_ring.head + 1) % tx_ring_slots; if (next == tx_ring.tail) { counters.tx_ring_full += 1; return; } @memcpy(tx_ring.slot[tx_ring.head][0..frame.len], frame); tx_ring.len[tx_ring.head] = @intCast(frame.len); tx_ring.head = next; counters.tx_queued += 1; } /// Hand every queued frame to ESP-Hosted. MUST be called only from a task that may block. /// /// This indirection is the fix for a deadlock the board demonstrated, and it is worth stating /// exactly because the shape of it is not obvious. /// /// `ip.Stack.onFrame` answers things: an ARP request gets a reply, an ICMP echo gets an echo, a TCP /// segment gets an ACK. So a received frame turns into a transmitted frame inside `onFrame`. But /// `onFrame` runs on ESP-Hosted's `sdio_process_rx_task`, and transmitting ends in /// `_h_queue_item(to_slave_queue, HOSTED_BLOCK_MAX)` (sdio_drv.c:1607), which SUSPENDS the caller /// when the queue is full. Suspend the RX task and it stops draining the receive queue; the receive /// queue fills; ESP-Hosted logs "task still writing Rx data to queue!" and stops delivering. /// Everything then looks like a dead IP stack. /// /// Measured on the board before this change: frames received froze at 17 and never advanced again, /// no ping was ever answered, and the HTTP GET failed with HostUnreachable because the ARP reply it /// needed was never sent. Raising the SDIO queue depth from 4 to 16 only moved the number. /// /// So the receive path now only ever copies into this ring, which cannot block, and the application /// task drains it from `tick`. The cost is one copy and `tx_ring_slots * frame_max` of .bss. fn flushTx() void { const tx = tx_fn orelse return; while (tx_ring.tail != tx_ring.head) { const i = tx_ring.tail; const n = tx_ring.len[i]; counters.tx_frames += 1; // The const cast is sound and it is load-bearing that it is: `transport_drv_sta_tx` reads // `buffer` exactly once, as the source of a memcpy into its own aligned buffer // (transport_drv.c:381), and neither writes through it nor retains it. ESP-Hosted's // signature is simply not const-correct. const rc = tx(@ptrCast(&channel_cookie), @ptrCast(&tx_ring.slot[i]), n); if (rc != 0) counters.tx_failed += 1; // Advance only after the call returns, so a frame is never handed out twice. tx_ring.tail = (i + 1) % tx_ring_slots; } } /// Outgoing frames waiting for a task that may block. /// /// Four slots, at `ip.frame_max` each. Enough that the replies one pass of received frames can /// generate - an ARP answer, an ICMP echo, a TCP ACK - all fit, since the whole ring is drained on /// the very next `tick`. A full ring drops the newest frame and counts it, which is what a real /// network interface does under load, and every protocol above this retransmits. /// /// Deliberately small: this is .bss competing with the heap ESP-Hosted allocates every received /// frame from, and eight slots cost 12 KB that the transport needs more than this ring does. const tx_ring_slots = 4; var tx_ring: struct { slot: [tx_ring_slots][ip.frame_max]u8 = undefined, len: [tx_ring_slots]u16 = @splat(0), head: usize = 0, tail: usize = 0, } = .{}; // ================================================================================== receive /// `transport_channel_rx_fn_t`. Called from ESP-Hosted's `sdio_process_rx_task` /// (sdio_drv.c:1407), which is one of the tasks `port.zig` spawned on this project's own runtime. /// /// The buffer is ours the moment this is entered, and it is freed on every path. See notes 1 and 2. fn onRxFrame( h: ?*anyopaque, buffer: ?*anyopaque, buff_to_free: ?*anyopaque, len: usize, ) callconv(.c) c_int { // `HOSTED_FREE(buff)` is `g_h.funcs->_h_free(buff)` (port_esp_hosted_host_os.h:139), and this // is that call. First statement in the function so that no early return can miss it: the // failure mode of a missed free here is not a leak that shows up in a heap report, it is the // 32 KiB heap exhausted in a few seconds of the AP's broadcast traffic. defer port.g_h.funcs.free(buff_to_free); counters.rx_frames += 1; if (h != @as(?*anyopaque, @ptrCast(&channel_cookie))) { counters.rx_wrong_channel += 1; return 0; } const bytes: [*]const u8 = @ptrCast(buffer orelse { counters.rx_bad += 1; return 0; }); if (!opened or len == 0 or len > ip.frame_max) { counters.rx_bad += 1; return 0; } if (in_stack) { counters.rx_reentrant += 1; return 0; } in_stack = true; defer in_stack = false; counters.rx_delivered += 1; sta.onFrame(bytes[0..len]); return 0; } // ================================================================================ lifecycle pub const Error = error{ /// `hosted_wifi_get_mac` failed, or answered with the all-zero MAC that means "no radio yet". /// The usual cause is calling this before `hosted_wifi_sta_start`. MacUnavailable, /// `transport_drv_add_channel` refused, or accepted without filling in the transmit function. ChannelRegisterFailed, AlreadyOpen, }; /// Register the station channel and bring the IP stack up behind it. /// /// Call after `net.init` and after `hosted_wifi_sta_start`; association may follow or may already /// have happened, it makes no difference to this. Registering *before* associating is the tidier /// order, because `chan_arr[ESP_STA_IF]` becoming non-null is the moment sdio_drv.c stops /// discarding station frames, and until then a live association fills ESP-Hosted's receive queue /// and logs "task still writing Rx data to queue!". /// /// The order inside matters: the stack is constructed *before* the channel is registered. The /// instant `transport_drv_add_channel` returns, `sdio_process_rx_task` may call `onRxFrame`, and /// that must not find `sta` uninitialised. pub fn open() Error!void { if (opened) return error.AlreadyOpen; var mac_bytes: [6]u8 = @splat(0); if (hosted_wifi_get_mac(&mac_bytes) != 0) return error.MacUnavailable; // An all-zero MAC is not a MAC. It is what the shim hands back if the coprocessor answered // without having a station interface, and building an ARP cache on it would produce a stack // that transmits frames no switch will ever route back. if (std.mem.allEqual(u8, &mac_bytes, 0)) return error.MacUnavailable; sta = .init(mac_bytes, &sendFrame); var tx: ?TxFn = null; const channel = transport_drv_add_channel( @ptrCast(&channel_cookie), esp_sta_if, 0, // secure=0: plain text, as ESP-Hosted itself uses for the two Wi-Fi interfaces // (esp_hosted_api.c:105-107). The secure path is the RPC channel's, and RPC has // its own already. &tx, &onRxFrame, ); if (channel == null) return error.ChannelRegisterFailed; // Belt and braces: the switch at transport_drv.c:467-485 is the only writer of `*tx`, and the // one branch that leaves it untouched also returns NULL. Checking both means a future // ESP-Hosted that separates those cannot leave us with a live channel and no way to transmit. tx_fn = tx orelse return error.ChannelRegisterFailed; opened = true; } /// True once `open` has succeeded. pub fn isOpen() bool { return opened; } // ============================================================ the guarded entry points // // Every function that can mutate the stack goes through `in_stack`. Every function that only reads // it does not, because a read cannot suspend and the worst it can observe is a value one frame out // of date. /// Advance the stack's clock. Returns false if the stack was busy and the tick was skipped, which /// is harmless - see note 5 - but worth being able to see. pub fn tick(now_ms: u64) bool { if (in_stack) { counters.tick_skipped += 1; return false; } in_stack = true; sta.tick(now_ms); in_stack = false; // Outside the guard, and last: draining may block, and `in_stack` must not be held across a // suspension or the receive path would drop every frame that arrived while we waited. flushTx(); return true; } /// Begin DHCP. Call `tick` at least once first: `dhcpStart` stamps the acquisition's start time /// from the stack's idea of now, which only `tick` sets. Returns false if the stack was busy. pub fn dhcpStart() bool { if (in_stack) return false; in_stack = true; defer in_stack = false; sta.dhcpStart(); return true; } /// Configure statically instead of asking a server. pub fn setStatic(addr: [4]u8, mask: [4]u8, gw: [4]u8) bool { if (in_stack) return false; in_stack = true; defer in_stack = false; sta.setStatic(addr, mask, gw); return true; } /// Override the resolver `resolve` asks. Not needed on a network whose DHCP server offers one - /// `dhcpBind` stores option 6 and `resolve` uses it with no configuration at all. Returns false if /// the stack was busy. pub fn setDnsServer(addr: [4]u8) bool { if (in_stack) return false; in_stack = true; defer in_stack = false; sta.setDnsServer(addr); return true; } /// One HTTP GET, with the address literal as the `Host:` header. `ip.Stack.httpGet`'s protocol, /// unchanged: this returns `error.WouldBlock` until the body is complete, and the caller must keep /// calling with *identical* arguments while driving `tick`. `out` is borrowed until a length comes /// back. pub fn httpGet(host: [4]u8, remote_port: u16, path: []const u8, out: []u8) ip.HttpError!usize { return httpGetHost(host, null, remote_port, path, out); } /// The same, with an explicit `Host:` name for a name-based virtual host. See /// `ip.Stack.httpGetHost`; `name` is part of the request's identity, so it must not change between /// calls any more than `path` may. pub fn httpGetHost( host: [4]u8, name: ?[]const u8, remote_port: u16, path: []const u8, out: []u8, ) ip.HttpError!usize { if (in_stack) { // Answering the caller's own protocol back at it. The alternative - waiting - would be a // second place in this file that can block, and the guard exists to have exactly none. counters.http_deferred += 1; return error.WouldBlock; } in_stack = true; defer in_stack = false; return sta.httpGetHost(host, name, remote_port, path, out); } /// Resolve a name to an address. `ip.Stack.resolve`'s protocol, which is `httpGet`'s: this returns /// `error.WouldBlock` until an address or a real error comes back, and the caller keeps calling /// with the same name while driving `tick`. /// /// The guard's answer is the same `error.WouldBlock`, for the same reason it is in `httpGetHost`: /// the query's own retransmissions run in `sta.tick`, so a deferred poll costs nothing and the 7 s /// bound still holds. Frames the query sends go through `sendFrame` into the deferred ring like /// every other frame here - nothing on this path touches the transport's tx function directly. pub fn resolve(name: []const u8) ip.DnsError!ip.Ip4 { if (in_stack) { counters.dns_deferred += 1; return error.WouldBlock; } in_stack = true; defer in_stack = false; return sta.resolve(name); } // ==================================================================== read-only accessors /// The station MAC the stack was built on. pub fn mac() [6]u8 { return sta.mac; } /// The configured address, or null if there is none yet. pub fn address() ?[4]u8 { return sta.addr; } pub fn netmask() [4]u8 { return sta.mask; } pub fn gateway() [4]u8 { return sta.gw; } pub fn dnsServer() ?[4]u8 { return sta.dns; } pub fn dhcpState() ip.DhcpState { return sta.dhcp.state; } pub fn tcpState() ip.TcpState { return sta.tcp.state; } pub fn httpStatus() u16 { return sta.http.status; } /// The IP stack's own counters: frames in, frames dropped, echoes answered, checksums rejected. pub fn ipCounters() ip.Counters { return sta.counters; } /// This file's counters: the transport boundary, and every way the guard fired. pub fn stats() Stats { return counters; } // There are no tests here, and that is an answer rather than an omission. Two of the three things // this file does are calls into ESP-Hosted's C - `transport_drv_add_channel` and the transmit // function it hands back - and the third is a callback that C invokes. A host test could only // exercise it against a mock of the very code whose conventions are the thing in doubt, and it // would pass just as happily against a mock that put the frame one header too late. The evidence // that matters is the citations in this file's header and a board that answers a ping.