summaryrefslogtreecommitdiff
path: root/src/net/link.zig
diff options
context:
space:
mode:
Diffstat (limited to 'src/net/link.zig')
-rw-r--r--src/net/link.zig552
1 files changed, 552 insertions, 0 deletions
diff --git a/src/net/link.zig b/src/net/link.zig
new file mode 100644
index 0000000..4277595
--- /dev/null
+++ b/src/net/link.zig
@@ -0,0 +1,552 @@
+//! 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.