diff --git a/docs/jaguar3-tx-ring.md b/docs/jaguar3-tx-ring.md index 14a90d40..2e7a60a5 100644 --- a/docs/jaguar3-tx-ring.md +++ b/docs/jaguar3-tx-ring.md @@ -343,7 +343,16 @@ Available with this PR: The verdict is the send failures, not `txdma_status`: on the faulting runs the periodic `tx.stats` still read `txdma_status` 0 up to its last sample (it is taken once per 500 frames, so a latch just before the stop would - not show). One adapter, one channel. On the 8822B (8812BU) this txdemo + not show). Nor is a nonzero `txdma_status` by itself the wedge: an 8812CU + on USB2 at `DEVOURER_TX_GAP_US=0` with 1400-byte QoS data read `0x2000` + (bit 13, `BIT_PAYLOAD_OVF_8822C`) from the first sample, on the fixed and + the control build alike, while TX completed 8051/8051; at the default 2 ms + gap it read 0, and one later USB2 run at gap 0 did not reproduce the + latch. So bit 13 can latch at max duty on USB2; bit 18 + (`BIT_TXPKTBUF_REQ_ERR`) is the bit measured with the wedge + (`IRtlRadio::GetTxDmaStatus`). One adapter, one channel; a second bench + reproduced the defect and the fix clearing it on an 8812CU, and gave the + same 8812BU LLT result as below. On the 8822B (8812BU) this txdemo form does NOT reproduce - unfixed and fixed alike ran clean (item 3); the Jaguar2 verification is the LLT check below. The 8822E form is unmeasured (the `ap_wpa2` stress, station-mode PR, is its record). Those runs used a radiotap-prefixed beacon; the demo now passes @@ -355,16 +364,6 @@ Available with this PR: submitted / 0 failed with the beacon armed and then stopped, the aggregated path 0 failed, and A-MPDU over QoS data 0 failed. - **The maintainer's bench (josephnef), 2026-09-27:** the reproducer - reproduces and the fix clears it on an 8812CU, and the 8812BU LLT check - gives the same result as above. And a counterpart for `txdma_status`: an - 8812CU on USB2 at `DEVOURER_TX_GAP_US=0` with 1400-byte QoS data read - `0x2000` (bit 13, `BIT_PAYLOAD_OVF_8822C`) from the first sample, on the - fixed and the control build alike, while TX completed 8051/8051; at the - default 2 ms gap it read 0. So a nonzero `txdma_status` is not by itself - the wedge - bit 18 (`BIT_TXPKTBUF_REQ_ERR`) is the bit measured with it - (`IRtlRadio::GetTxDmaStatus`). - **The canonical-frame form does not reproduce**: without `DEVOURER_TX_QOS_DATA`/`DEVOURER_TX_PAYLOAD_BYTES`/`DEVOURER_TX_WITH_RX`, the unfixed build ran 20051 submitted / 0 failed / `txdma_status` 0, the diff --git a/docs/logging.md b/docs/logging.md index cfc43f83..82ffcdbf 100644 --- a/docs/logging.md +++ b/docs/logging.md @@ -110,7 +110,7 @@ Emitters: L = library, RX/TX/... = demo. Optional fields in [brackets]; ### TX plane | ev | emitter | fields | |---|---|---| -| `tx.frame` | TX | n, rc — precoder demo variant: n, ok | +| `tx.frame` | TX | n, rc, t (the `tx.report` timebase: the first one dates the first submit) — precoder demo variant: n, ok | | `tx.stats` | TX | submitted, failed, was_timeout, last_rc; the `final:1` event also carries t (the `tx.report` timebase, so a harness can tell how long before the end a transmitter last reported); periodic events (not the `final:1` one) also carry `txdma_status` (the raw `REG_TXDMA_STATUS` latch; which bits mean a stopped transmitter: `IRtlRadio::GetTxDmaStatus`) where `IRtlRadio::HasTxDmaStatus()` (which backends: its declaration), or `txdma_read_failed:1` when that sample's register read failed | | `tx.agg` | L (`DEVOURER_TX_USB_AGG`, send_packets) | frames, bytes, shim, ok — one per multi-frame bulk-OUT URB. The sync-TX generations (Jaguar2/Jaguar3/RTL8733B) also emit `sent` — bytes actually transferred, OR the negative libusb rc on a transport error (deliberately raw: this event is the only machine-readable carrier of the aggregated-path error code) — and set `ok` only on a FULL write, so `ok=false` splits as `sent < 0` transport error vs `0 <= sent < bytes` short write. Jaguar1 TX is async: its `ok` means URB accepted by the transport and there is no `sent` field (bytes resolve at completion reaping) | | `tx.report` | L (`DEVOURER_TX_REPORT`, CCX decode) | t, state (0=delivered, 1=retry-drop), ok, retries, final_rate, queue_time_raw, bmc, macid, fmt ("8812"\|"halmac"); halmac adds tag (SW_DEFINE echo), rts_retries, missed (fw-stuffed constant on Jaguar3 — tag gaps are the drop signal; `tests/txrpt_coverage_attrib.py`) — t is the achieved-report-rate timebase (the CCX emission ceiling is reports/s) | diff --git a/docs/mt7612u-tx-retry.md b/docs/mt7612u-tx-retry.md index 68735db3..a5a8b67d 100644 --- a/docs/mt7612u-tx-retry.md +++ b/docs/mt7612u-tx-retry.md @@ -158,19 +158,32 @@ What it shows: fps, the same per-frame cost as the No-Ack rows. At limit 5 and on the initvals they time out on every per-frame wait (T40), and three of the eight such rows are 39/40 with the lag below. -- **Characterised, unexplained: a one-step status lag.** In some arms EVERY - per-frame wait times out (T40), yet the entries do arrive - one step - behind: a frame's status becomes visible only after the next frame is - submitted. Such an arm ends 39/40 and its last entry lands in the next arm - as late (or, for the very first arm, as one foreign entry). Arm a lags in - every pass; which other arms lag varies from pass to pass (c, e, f and g - are each clean in some passes and lagging in others), so it tracks chip - state, not arm configuration. Counting 39/40 rows, it hit five of sixteen - at limit 0, eleven at limit 5 and seven on the initvals - one run each, - too few to call a trend. The two candidate explanations - (status posted only on the next TX; the EXT/FIFO pairing off by one) - produce identical signatures in this gate and are not distinguishable - here. The retry and success columns exclude every late and foreign entry. +- **A one-step status lag - a stale EXT read on the arm's first entry.** In + some arms EVERY per-frame wait times out (T40) and the arm ends 39/40, with + one late entry (or, for the very first arm, one foreign entry) in that + SAME arm's row. Counting 39/40 rows, it hit five of sixteen at limit 0, + eleven at limit 5 and seven on the initvals - one run each. Arm a lags in + every pass; which other arms lag varies from pass to pass. The table rules + out "status posted only on the next TX": at limit 0, receiver ON, arm f + lags with L1 although arm e before it settled 40/40 and owed nothing, and + arm g after it shows no late entry. What fits is the two-transfer read: + when the FIFO is empty at the EXT read and an entry is filed before the + main read, the popped entry is paired with the stale EXT word of the + previous entry. On an arm's first entry that is the previous arm's pktid, + so the entry was counted late, the arm stayed one short, and every + per-frame wait timed out. A race on the poll timing, which is why it + varies from pass to pass and with the host. The gate now claims that + entry back (`txs_drain`), under all of: it is the arm's first popped entry + (no own entry yet), the arm has submitted a frame, and its pktid is that + of the last arm that sent a frame, which must have settled with no entry + owed - or, on the session's first arm, any pktid. A claimed entry counts + in entries, and in success when its SUCCESS bit is set; it stays out of + the retry columns, and is reported as "stale-EXT entries claimed". These + tables were taken before that change. With the claim keyed to the + previous arm, the author's unit then settled 16/16 arms at limits 5 and 0 + (recorded on issue #461); keying it to the last arm that sent a frame, + so an arm with every submit failed is skipped, has not been run on + hardware. - **Arms e-h**: e, f and g read like c whenever they are clean; nothing distinguishes them. h (broadcast, WCID 1) lagged in five of six passes. @@ -244,9 +257,11 @@ why it is worth an issue of its own. tables; the delivery table above shows ACK-requesting retries working against a real AP. - UNSETTLED rows are read for the retry value, never for the counts. -- The one-step status lag is unexplained, and its two candidate causes are - indistinguishable in this gate. It moves an arm's last entry into the next - arm's late count, never into another arm's statistics. +- The one-step status lag is attributed to the stale-EXT race above from + this table's pattern, not from a bus trace. The claim recovers at most one + entry per arm - the first - and only on the conditions above; after an + UNSETTLED arm a previous-pktid entry is still reported as late. A deficit + of more than one entry is not this race. - `mt7612uprobe txs` reads two registers per status poll, so its `fps` is per-frame submit-to-status time, not comparable with any steady-state injection figure. diff --git a/examples/tx/main.cpp b/examples/tx/main.cpp index 33112639..636d9f43 100644 --- a/examples/tx/main.cpp +++ b/examples/tx/main.cpp @@ -2,6 +2,7 @@ #include #include #include +#include #include #include #include @@ -783,6 +784,24 @@ int main(int argc, char **argv) { }); logger->info("DEVOURER_POLL_INTR_IN — EP 0x85 interrupt-IN poller running"); } + /* Stops and joins the optional IN drainers on every exit from here: a + * still-joinable std::thread terminates the process when destroyed, and + * both threads poll `handle`, so the normal path joins them explicitly + * before session.close(). (The DEVOURER_TX_WITH_RX fork child never runs + * this: it leaves through std::_Exit - see there.) */ + struct DrainerJoin { + std::atomic &bulk_running, &intr_running; + std::thread &bulk, &intr; + void join() { + bulk_running = false; + intr_running = false; + if (bulk.joinable()) + bulk.join(); + if (intr.joinable()) + intr.join(); + } + ~DrainerJoin() { join(); } + } drainers{bulk_in_running, intr_running, bulk_in_thread, intr_in_thread}; WiFiDriver wifi_driver{logger}; std::unique_ptr owned_device; @@ -1007,6 +1026,28 @@ int main(int argc, char **argv) { if (tx_with_rx && !rx_thread_mode) { pid_t fpid = fork(); if (fpid == 0) { +#if !defined(_MSC_VER) /* fork() is a real fork here, not the (0) stub */ + /* The post-fork rule: the child holds copies of the parent's objects - + * the IN-drainer std::threads among them, joinable copies of threads + * that do not exist here - so it must run no destructor. It flushes + * stdio and leaves through std::_Exit, never through a return; an + * exception from Init must not unwind it either. */ + try { + rtlDevice->Init(packetProcessor, + SelectedChannel{ + .Channel = static_cast(channel), + .ChannelOffset = 0, + .ChannelWidth = CHANNEL_WIDTH_20, + }); + } catch (const std::exception &e) { + logger->error("RX child: {}", e.what()); + } catch (...) { + logger->error("RX child: unknown exception"); + } + std::fflush(nullptr); + std::_Exit(1); +#else + /* The stub: this IS the only process, so it tears down normally. */ rtlDevice->Init(packetProcessor, SelectedChannel{ .Channel = static_cast(channel), @@ -1014,6 +1055,7 @@ int main(int argc, char **argv) { .ChannelWidth = CHANNEL_WIDTH_20, }); return 1; +#endif } } @@ -1031,16 +1073,9 @@ int main(int argc, char **argv) { } catch (const std::exception &e) { /* InitWrite returns void, so a refused bring-up (e.g. a channel/width/ * offset combination the chip rejects) surfaces as an exception. The - * device already tore itself down; exit cleanly instead of aborting — - * which means the optional IN-drainer threads above must be joined - * first, or their still-joinable std::thread destructors terminate. */ + * device already tore itself down; exit cleanly instead of aborting + * (the drainers guard joins the IN-drainer threads). */ logger->error("TX bring-up failed: {}", e.what()); - bulk_in_running = false; - intr_running = false; - if (bulk_in_thread.joinable()) - bulk_in_thread.join(); - if (intr_in_thread.joinable()) - intr_in_thread.join(); return 1; } @@ -1072,6 +1107,30 @@ int main(int argc, char **argv) { }); logger->info("DEVOURER_TX_WITH_RX=thread: RX loop started alongside TX"); } + /* Stops and joins the RX and USB event threads on every exit from here, + * the early returns below included - a joinable std::thread destructor + * terminates the process. The normal teardown's order: StopRxLoop and the + * RX join, then the event pump (which polls g_devourer_should_stop). It is + * declared after the session, so on an early return it runs while the + * device and libusb are still alive. Both threads start after the + * DEVOURER_TX_WITH_RX fork, so a fork child never reaches here. */ + struct IoThreadsJoin { + IRadio *dev; + std::thread &rx, &usb; + bool done = false; + void join() { + if (done) + return; + done = true; + dev->StopRxLoop(); + if (rx.joinable()) + rx.join(); + g_devourer_should_stop = true; + if (usb.joinable()) + usb.join(); + } + ~IoThreadsJoin() { join(); } + } io_threads{rtlDevice, rx_thread, usb_thread}; /* DEVOURER_STA_IDENTITY: arm before the first frame, once the RX loop is * shown running - its first received frame (3 s cap: a silent channel still @@ -1946,9 +2005,10 @@ int main(int argc, char **argv) { * explicitly, before Stop() powers the chip down; the destructor covers an * exception or an early return. It is declared after the DeviceSession, so * it runs before the device is destroyed. `attempted` is cleared only by a - * StopBeacon that returned (true, or a clean false = nothing active, per - * its contract); after three throws it stays set, so a later stop() - the - * destructor on an exception path - tries again. detach() drops the device + * StopBeacon that returned true, or a clean false when nothing was armed; + * after three failed attempts (throws, or a refused disable of an armed + * beacon) it stays set, so a later stop() - the destructor on an exception + * path - tries again. detach() drops the device * before the normal path destroys it. */ struct TxBeaconGuard { IRadio *dev; @@ -1960,17 +2020,18 @@ int main(int argc, char **argv) { return; for (int i = 0; i < 3; i++) { try { - /* true = stopped; a clean false = nothing active (StopBeacon - * contract), expected after a refused StartBeacon - either way - * there is nothing to retry. */ - dev->StopBeacon(); + /* After a successful arm, false means the disable was refused + * (Jaguar2/3), so retry. Otherwise a clean false is nothing + * active - expected after a refused StartBeacon. */ + if (!dev->StopBeacon() && armed) + continue; attempted = false; return; } catch (const std::exception &e) { log->warn("DEVOURER_TX_BEACON_TU: StopBeacon threw: {}", e.what()); } } - /* Three throws: `attempted` stays set so a later stop() tries again. */ + /* Three failures: `attempted` stays set so a later stop() tries again. */ log->error("DEVOURER_TX_BEACON_TU: StopBeacon failed 3 times - the " "beacon may keep airing until the adapter is re-enumerated " "or powered down (Jaguar2 has no teardown power-down)"); @@ -2481,7 +2542,8 @@ int main(int argc, char **argv) { ++frames_in_dwell >= hop_dwell) frames_in_dwell = 0; if (tx_count <= 10 || tx_count % 500 == 0) { - devourer::Ev(*g_ev, "tx.frame").f("n", tx_count).f("rc", rc); + /* t: the tx.report timebase, so a harness can date the first submit. */ + devourer::Ev(*g_ev, "tx.frame").f("n", tx_count).f("rc", rc).t(); /* TX submission health — the driver-drop / congestion feed (xtx). A * climbing failed with was_timeout=1 is a full TX FIFO (recoverable * back-pressure); a hard rc is a broken path. */ @@ -2657,11 +2719,7 @@ int main(int argc, char **argv) { sta_stop = true; if (sta_clear_thread.joinable()) sta_clear_thread.join(); - rtlDevice->StopRxLoop(); - if (rx_thread.joinable()) - rx_thread.join(); - if (usb_thread.joinable()) - usb_thread.join(); + io_threads.join(); /* Clean chip de-init before releasing the interface: card-disable PWR_SEQ on * the HalMAC families, TX quiesce on Jaguar1 — so the adapter re-enumerates @@ -2673,6 +2731,7 @@ int main(int argc, char **argv) { * does exactly the same on every other exit path. */ /* The beacon guard must not call into the device once it is gone. */ tx_beacon.detach(); + drainers.join(); /* they poll the handle session.close() releases */ session.close(); /* A truncated caller stream is a producer fault, and a harness that scored * the run as if it had ended cleanly would be scoring a short measurement. */ diff --git a/src/IRtlRadio.h b/src/IRtlRadio.h index 7e6498b6..90ba4ee9 100644 --- a/src/IRtlRadio.h +++ b/src/IRtlRadio.h @@ -167,10 +167,11 @@ class IRtlRadio : public IRadio { * armed it latched and the part transmitted nothing more for the life * of the process, while the receiver worked on (the 8812BU's wedge * read 0x10, then 0x15 - bits not decoded); - * - bit 13, BIT_PAYLOAD_OVF_8822C (0x00002000), latches under host-side - * max-duty backpressure while TX continues - an 8812CU on USB2 at - * DEVOURER_TX_GAP_US=0 read it from the first sample and still - * completed every frame. A poller must not treat it as the wedge. + * - bit 13, BIT_PAYLOAD_OVF_8822C (0x00002000), can latch under + * host-side max-duty backpressure while TX continues - an 8812CU on + * USB2 at DEVOURER_TX_GAP_US=0 read it from the first sample and still + * completed every frame (a later such run did not latch it). A poller + * must not treat it as the wedge. * Other bits are undecoded. Records: docs/jaguar3-tx-ring.md. * * NOT FOR THE SEND PATH. This is a register read over USB - see the diff --git a/src/StationArm.h b/src/StationArm.h index 49bf0fbc..9d26fc17 100644 --- a/src/StationArm.h +++ b/src/StationArm.h @@ -57,7 +57,9 @@ * (Jaguar2's does not; Jaguar1's is optional) and a port left on MACID = own * / Infra goes on acknowledging for a station whose process has gone. A * (re-)bring-up clears a held arm first (retire()); a clear that does not - * verify keeps the record for ClearStationIdentity to retry. */ + * verify keeps the record for ClearStationIdentity to retry. Stop() also + * clears _station_ready, so an arm after Stop() is refused until the next + * bring-up. */ #include #include diff --git a/src/jaguar1/RtlJaguarDevice.cpp b/src/jaguar1/RtlJaguarDevice.cpp index 399b4be0..b0e05df3 100644 --- a/src/jaguar1/RtlJaguarDevice.cpp +++ b/src/jaguar1/RtlJaguarDevice.cpp @@ -2487,9 +2487,10 @@ void RtlJaguarDevice::Stop() { /* A station arm ends with the session: restored before the optional * power-down (best effort; a failure is logged by the clear), so a chip * left powered (tuning.teardown_power_down=0) does not keep answering for - * the station. */ + * the station. A new arm is refused until the next bring-up. */ { std::lock_guard lock(_port0_mu); + _station_ready = false; if (_station.armed()) (void)_station.clear(_device, _logger, "Jaguar1"); } diff --git a/src/jaguar2/RtlJaguar2Device.cpp b/src/jaguar2/RtlJaguar2Device.cpp index ac80f318..6dd48472 100644 --- a/src/jaguar2/RtlJaguar2Device.cpp +++ b/src/jaguar2/RtlJaguar2Device.cpp @@ -2326,9 +2326,11 @@ void RtlJaguar2Device::Stop() { /* This Stop leaves the chip powered, so a station arm would outlive the * session: port 0 on MACID = own / Infra keeps acknowledging for the * station after the process has gone. Clear it here (best effort; a - * failure is logged by the clear). */ + * failure is logged by the clear). A new arm is refused until the next + * bring-up. */ { std::lock_guard lk(_reg_mu); + _station_ready = false; if (_station.armed()) (void)_station.clear(_device, _logger, "Jaguar2"); } diff --git a/src/jaguar3/RtlJaguar3Device.cpp b/src/jaguar3/RtlJaguar3Device.cpp index 1d86df2b..1f880965 100644 --- a/src/jaguar3/RtlJaguar3Device.cpp +++ b/src/jaguar3/RtlJaguar3Device.cpp @@ -858,9 +858,11 @@ void RtlJaguar3Device::Stop() { /* A station arm ends with the session, not with whatever the de-init * below leaves: restored first (best effort; a failure is logged by the * clear), so the port stops answering for the station even where the - * power-down does not complete. */ + * power-down does not complete. A new arm is refused until the next + * bring-up. */ { std::lock_guard lk(_reg_mu); + _station_ready = false; if (_station.armed()) (void)_station.clear(_device, _logger, "Jaguar3"); } diff --git a/src/mt7612u/tools/bringup.cpp b/src/mt7612u/tools/bringup.cpp index 346dadf9..26559f00 100644 --- a/src/mt7612u/tools/bringup.cpp +++ b/src/mt7612u/tools/bringup.cpp @@ -620,7 +620,7 @@ static int gate_beacon(uint8_t chan, int secs) } /* TX-only: beaconing never reads EP 4. */ if (mt_mac_start(&dev, MT_RX_DRAIN_NONE)) { - printf("GATE A: FAIL - mac_start\n"); return 1; + printf("GATE A: FAIL - mac_start\n"); mt_mac_stop(&dev); return 1; } mt_beacon_init(&dev); @@ -792,7 +792,8 @@ static int gate_ap(uint8_t chan, int secs) } rx_up = 1; if (mt_mac_start(&dev, MT_RX_DRAIN_RING)) { - printf("GATE B: FAIL - mac_start\n"); mt7612u_rx_stop(&dev); return 1; + printf("GATE B: FAIL - mac_start\n"); + rx_teardown(); mt_mac_stop(&dev); return 1; } /* * AP receive filter. The managed default mt_mac_start() just wrote already @@ -957,7 +958,7 @@ static int gate_tx(uint8_t chan, int count, int phy, int mcs) } /* TX only: this gate never reads EP 4, so do not switch the receiver on. */ if (mt_mac_start(&dev, MT_RX_DRAIN_NONE)) { - printf("GATE E: FAIL - mac_start failed\n"); return 1; + printf("GATE E: FAIL - mac_start failed\n"); mt_mac_stop(&dev); return 1; } printf("MAC started: MT_MAC_SYS_CTRL=0x%08x (bit2 TX, bit3 RX)\n", mt_rr(&dev, MT_MAC_SYS_CTRL)); @@ -1014,7 +1015,8 @@ static int gate_rx(uint8_t chan, int want) printf("GATE F: FAIL - set_channel failed\n"); return 1; } if (mt_mac_start(&dev, MT_RX_DRAIN_SYNC)) { - printf("GATE F: FAIL - mac_start failed\n"); return 1; + printf("GATE F: FAIL - mac_start failed\n"); + mt_mac_rx_disable(&dev); mt_mac_stop(&dev); return 1; } /* Monitor: drop only CRC and PHY errors, accept everything else. The * initvals value 0x15f97 drops a great deal more than that. */ @@ -1140,7 +1142,7 @@ static int gate_g(uint8_t chan, int count) if (mt_eeprom_init(&dev)) return 1; if (mt_init_hardware(&dev, NULL)) return 1; if (mt_set_channel(&dev, chan, MT7612U_BW_20)) return 1; - if (mt_mac_start(&dev, MT_RX_DRAIN_NONE)) return 1; + if (mt_mac_start(&dev, MT_RX_DRAIN_NONE)) { mt_mac_stop(&dev); return 1; } memset(frame, 0, sizeof frame); frame[0] = 0x08; @@ -1251,7 +1253,7 @@ static int gate_mtu(uint8_t chan, int count) if (mt_eeprom_init(&dev)) return 1; if (mt_init_hardware(&dev, NULL)) return 1; if (mt_set_channel(&dev, chan, MT7612U_BW_20)) return 1; - if (mt_mac_start(&dev, MT_RX_DRAIN_NONE)) return 1; + if (mt_mac_start(&dev, MT_RX_DRAIN_NONE)) { mt_mac_stop(&dev); return 1; } memset(frame, 0, sizeof frame); frame[0] = 0x08; /* data, 3-address */ @@ -1339,7 +1341,7 @@ static int gate_soak(uint8_t chan, int secs, int framelen) if (mt_eeprom_init(&dev)) return 1; if (mt_init_hardware(&dev, NULL)) return 1; if (mt_set_channel(&dev, chan, MT7612U_BW_20)) return 1; - if (mt_mac_start(&dev, MT_RX_DRAIN_NONE)) return 1; + if (mt_mac_start(&dev, MT_RX_DRAIN_NONE)) { mt_mac_stop(&dev); return 1; } memset(frame, 0, sizeof frame); frame[0] = 0x08; @@ -1458,7 +1460,9 @@ static int gate_arx(uint8_t chan, int secs, int notick) if (mt7612u_rx_start(&dev, arx_cb, &ctx)) { printf("GATE arx: FAIL - rx_start failed\n"); return 1; } - if (mt_mac_start(&dev, MT_RX_DRAIN_RING)) { rx_teardown(); return 1; } + if (mt_mac_start(&dev, MT_RX_DRAIN_RING)) { + rx_teardown(); mt_mac_stop(&dev); return 1; + } mt7612u_set_monitor_rx(&dev, 0); t0 = now_ms(); /* notick is the negative control: without the 1 Hz PHY tick this gate @@ -1525,7 +1529,9 @@ static int gate_duplex(uint8_t chan, int secs) memcpy(frame + 24, "MT7612U-HAL ", 12); if (mt7612u_rx_start(&dev, arx_cb, &ctx)) return 1; - if (mt_mac_start(&dev, MT_RX_DRAIN_RING)) { rx_teardown(); return 1; } + if (mt_mac_start(&dev, MT_RX_DRAIN_RING)) { + rx_teardown(); mt_mac_stop(&dev); return 1; + } mt7612u_set_monitor_rx(&dev, 0); t0 = now_ms(); @@ -1676,7 +1682,7 @@ static int gate_ampdu(uint8_t chan, int count) if (mt_eeprom_init(&dev)) return 1; if (mt_init_hardware(&dev, NULL)) return 1; if (mt_set_channel(&dev, chan, MT7612U_BW_20)) return 1; - if (mt_mac_start(&dev, MT_RX_DRAIN_NONE)) return 1; + if (mt_mac_start(&dev, MT_RX_DRAIN_NONE)) { mt_mac_stop(&dev); return 1; } /* A real station-table entry: aggregation is a per-peer notion, and * wcid 0xff (what the injector normally uses) names no peer. */ @@ -1867,7 +1873,7 @@ static int parse_mac6(const char *s, uint8_t out[6]) * columns, making a No-Ack arm look as if it retried to exactly the * configured limit and displacing one of its own entries from entr/sent. * Entries carrying the previous arm's pktid are reported as late; any other - * pktid as foreign. + * pktid as foreign - with one exception, the stale EXT word (txs_drain). * * Exit: 0 reported, 1 device failure OR no status entry filed at all (the * measurement did not happen), 2 bad argument or retry limit refused, @@ -1877,6 +1883,8 @@ struct txs_sum { long entries, success, retry_total, retry_max; long late_prev; /* entries carrying the PREVIOUS arm's pktid */ long foreign; /* entries with any other pktid */ + long stale_ext; /* own entries popped with a stale EXT word: counted in + * entries/success, kept out of the retry columns */ }; /* mt76's skb pktid range starts at MT_PACKET_ID_FIRST (3) and the id must @@ -1887,14 +1895,35 @@ static unsigned txs_arm_pktid(int rx_on, unsigned arm) return 3u + 8u * (unsigned)rx_on + arm; } #define TXS_NO_PKTID 0x100u /* matches no 8-bit EXT_PKTID */ +#define TXS_ANY_PKTID 0x200u /* txs_drain's stale_id: unknown, so any */ /* Slack on top of frame_budget_ms for one frame's status wait: USB submit * latency plus the drain's two control reads. */ #define TXS_FRAME_MARGIN_MS 50.0 /* Returns 0 when the FIFO was drained (or is empty), -1 when a status read - * failed - the caller must not report the arm as measured then. */ + * failed - the caller must not report the arm as measured then. + * + * The EXT-then-main read is two USB transfers, not one atomic read. When the + * FIFO is EMPTY at the EXT read and an entry is filed before the main read, + * the main read pops that entry but the EXT word read just before it is + * stale - it still describes the last entry popped. Within an arm that is + * harmless (same pktid). On an arm's FIRST entry it is the previous arm's + * pktid (or, on the session's first arm, whatever EXT held), so the entry was + * counted late/foreign, the arm stayed one short for good, and every + * per-frame wait then timed out - the "one-step status lag" rows of + * docs/mt7612u-tx-retry.md (N-1/N, a timeout on every frame, ~5-6 fps, and + * the late entry in the SAME arm's row, after a fully settled arm). + * + * `stale_id` takes that one entry back. It is the pktid a stale EXT word + * would carry: the last arm that SENT a frame (an arm that sent none popped + * nothing, so EXT still describes the arm before it), or TXS_ANY_PKTID on the + * session's first. The caller passes it only once this arm has submitted a + * frame and that arm owed no entries, and TXS_NO_PKTID otherwise (no + * claim). The arm's first popped entry, carrying stale_id, can then only be + * ours. Its main word is fresh, so its SUCCESS bit counts; its retry count is + * the stale word's, so it is kept out of the retry columns. */ static int txs_drain(struct mt7612u_dev *d, struct txs_sum *o, - unsigned want, unsigned prev) + unsigned want, unsigned prev, unsigned stale_id) { int guard; @@ -1919,6 +1948,14 @@ static int txs_drain(struct mt7612u_dev *d, struct txs_sum *o, (unsigned)FIELD_GET(MT_TX_STAT_FIFO_EXT_PKTID, ext); if (id != want) { + if (stale_id != TXS_NO_PKTID && o->entries == 0 && + (stale_id == TXS_ANY_PKTID || id == stale_id)) { + o->entries++; + o->stale_ext++; + if (st & MT_TX_STAT_FIFO_SUCCESS) + o->success++; + continue; + } if (id == prev) o->late_prev++; else o->foreign++; continue; @@ -1933,11 +1970,6 @@ static int txs_drain(struct mt7612u_dev *d, struct txs_sum *o, return 0; } -/* DEVOURER_TX_RETRY_LIMIT, read with env_config's strictness: the whole - * string one number (base auto-detect), trailing whitespace by isspace() - * exactly as env_long_strict() takes it, clamped to the config's 0..63. - * Returns 1 and sets *out when present and valid, 0 when unset, -1 when - * present but not a number. */ /* The whole string one number (base auto-detect, leading and trailing * whitespace allowed, as strtol and isspace define them) - the rule * env_config's env_long_strict() applies. 0 and *out on success, -1 when no @@ -1987,6 +2019,11 @@ static void txs_print_escaped(const char *s) } } +/* DEVOURER_TX_RETRY_LIMIT, read with env_config's strictness: the whole + * string one number (base auto-detect), trailing whitespace by isspace() + * exactly as env_long_strict() takes it, clamped to the config's 0..63. + * Returns 1 and sets *out when present and valid, 0 when unset, -1 when + * present but not a number. */ static int txs_retry_limit_env(int *out) { const char *e = getenv("DEVOURER_TX_RETRY_LIMIT"); @@ -2009,6 +2046,10 @@ static int gate_txs(uint8_t chan, int frames, const char *peer_str) int rx_on, rl = 0, rl_given; long total_entries = 0, total_foreign = 0; unsigned prev_pktid = TXS_NO_PKTID; + /* txs_drain's stale_id: the last arm that sent a frame, and whether it + * settled with no entry owed. */ + unsigned stale_pktid = TXS_ANY_PKTID; + int stale_settled = 1; double frame_budget_ms; int io_fail = 0; /* status read / WCID setup failed: teardown, exit 1 */ double last_tick = 0.0; /* receiver passes: last mt7612u_phy_tick() */ @@ -2120,7 +2161,10 @@ static int gate_txs(uint8_t chan, int frames, const char *peer_str) ctr.acks.store(0); ctr.frames.store(0); - if (mt_mac_start(&dev, MT_RX_DRAIN_NONE)) return 1; + if (mt_mac_start(&dev, MT_RX_DRAIN_NONE)) { + mt_mac_stop(&dev); + return 1; + } if (mt_async_start(&dev, rx_on ? ucast_rx_cb : NULL, rx_on ? (void *)&ctr : NULL)) { mt_mac_stop(&dev); @@ -2155,7 +2199,7 @@ static int gate_txs(uint8_t chan, int frames, const char *peer_str) for (a = 0; !io_fail && a < sizeof arms / sizeof arms[0]; a++) { struct mt7612u_tx_rate rate = { }; - struct txs_sum sum = { 0, 0, 0, 0, 0, 0 }; + struct txs_sum sum = { 0, 0, 0, 0, 0, 0, 0 }; const unsigned pktid = txs_arm_pktid(rx_on, a); const uint8_t *sa = arms[a].own_sa ? dev.macaddr : src; const uint8_t *a1 = arms[a].bcast_a1 ? bcast : peer; @@ -2179,7 +2223,8 @@ static int gate_txs(uint8_t chan, int frames, const char *peer_str) memcpy(frame + 26, "MT7612U-TXS", 11); /* The previous arm's tail: counted as late, never as ours. */ - if (txs_drain(&dev, &sum, pktid, prev_pktid)) io_fail = 1; + if (txs_drain(&dev, &sum, pktid, prev_pktid, TXS_NO_PKTID)) + io_fail = 1; /* Bounded twice, like gate_ampdu's wall clock: a submit * that keeps failing must end the arm, not spin it. The @@ -2198,7 +2243,9 @@ static int gate_txs(uint8_t chan, int frames, const char *peer_str) MT_TXOPT_TXS | MT_TXOPT_PKTID(pktid) | arms[a].opts) != 0) { submit_fail++; - if (txs_drain(&dev, &sum, pktid, prev_pktid)) + if (txs_drain(&dev, &sum, pktid, prev_pktid, + n > 0 && stale_settled ? + stale_pktid : TXS_NO_PKTID)) io_fail = 1; continue; } @@ -2224,7 +2271,9 @@ static int gate_txs(uint8_t chan, int frames, const char *peer_str) do { if (txs_drain(&dev, &sum, pktid, - prev_pktid)) { + prev_pktid, + stale_settled ? stale_pktid + : TXS_NO_PKTID)) { io_fail = 1; break; } @@ -2256,7 +2305,9 @@ static int gate_txs(uint8_t chan, int frames, const char *peer_str) && !g_stop && !io_fail) { txs_tick(rx_on, &last_tick); mt_usleep(2000); - if (txs_drain(&dev, &sum, pktid, prev_pktid)) + if (txs_drain(&dev, &sum, pktid, prev_pktid, + n > 0 && stale_settled ? + stale_pktid : TXS_NO_PKTID)) io_fail = 1; } settled = (sum.entries >= n); @@ -2273,22 +2324,35 @@ static int gate_txs(uint8_t chan, int frames, const char *peer_str) * entries, uncapped: more than `sent` would mean the MAC * filed duplicates, and is shown as such rather than * clipped. */ - printf(" %c %-28s %7.0f %4ld/%-4ld %8ld %9.1f %6ld%s\n", - arms[a].tag, arms[a].what, n * 1000.0 / wall, - sum.entries, n, sum.success, - sum.entries ? (double)sum.retry_total / sum.entries : 0.0, - sum.retry_max, settled ? "" : " UNSETTLED"); + { + /* The retry mean is over the entries whose EXT + * word is their own. */ + const long rtry_n = sum.entries - sum.stale_ext; + + printf(" %c %-28s %7.0f %4ld/%-4ld %8ld %9.1f %6ld%s\n", + arms[a].tag, arms[a].what, n * 1000.0 / wall, + sum.entries, n, sum.success, + rtry_n ? (double)sum.retry_total / rtry_n : 0.0, + sum.retry_max, settled ? "" : " UNSETTLED"); + } if (submit_fail || status_timeouts || sum.late_prev || - sum.foreign || n < frames) + sum.foreign || sum.stale_ext || n < frames) printf(" (pktid %u: %ld submit failures, %ld/%d " "frames sent, %ld per-frame status timeouts, " "%ld late entries from the previous arm, " - "%ld foreign)\n", + "%ld foreign, %ld stale-EXT entries claimed)\n", pktid, submit_fail, n, frames, status_timeouts, - sum.late_prev, sum.foreign); + sum.late_prev, sum.foreign, sum.stale_ext); total_entries += sum.entries; total_foreign += sum.foreign + sum.late_prev; prev_pktid = pktid; + /* More entries than frames would be MAC duplicates: then a + * stale-pktid entry is not provably ours either. An arm that + * sent nothing popped nothing and leaves both as they were. */ + if (n > 0) { + stale_pktid = pktid; + stale_settled = (sum.entries == n); + } if (io_fail) { printf(" (arm %c: a status-FIFO read FAILED - " "the row above is incomplete)\n", arms[a].tag); @@ -2365,7 +2429,9 @@ static int gate_caps(uint8_t chan) * with nothing reading, that is long enough to wedge the part below * the USB level, which no software reset recovers. */ if (mt_async_start(&dev, drain_cb, &drained)) return 1; - if (mt_mac_start(&dev, MT_RX_DRAIN_RING)) { rx_teardown(); return 1; } + if (mt_mac_start(&dev, MT_RX_DRAIN_RING)) { + rx_teardown(); mt_mac_stop(&dev); return 1; + } mt7612u_get_caps(&dev, &c); printf("caps: %s rev 0x%08x %dTx%dRx bw_mask 0x%02x (20%s%s)\n", @@ -2537,7 +2603,9 @@ static int gate_ack(uint8_t chan, int secs, int arm) /* Ring first, receiver second - see gate_caps. Arming the responder and * printing between the two would otherwise leave RX on and undrained. */ if (mt7612u_rx_start(&dev, ack_cb, &off)) return 1; - if (mt_mac_start(&dev, MT_RX_DRAIN_RING)) { rx_teardown(); return 1; } + if (mt_mac_start(&dev, MT_RX_DRAIN_RING)) { + rx_teardown(); mt_mac_stop(&dev); return 1; + } /* CRC and PHY errors only: DUP must stay clear so retries reach us. */ mt_wr(&dev, MT_RX_FILTR_CFG, MT_RX_FILTR_CFG_CRC_ERR | MT_RX_FILTR_CFG_PHY_ERR); @@ -2661,7 +2729,9 @@ static int gate_rxbytes(uint8_t chan, int secs) if (mt_init_hardware(&dev, NULL)) return 1; if (mt_set_channel(&dev, chan, MT7612U_BW_20)) return 1; if (mt7612u_rx_start(&dev, rxbytes_cb, NULL)) return 1; - if (mt_mac_start(&dev, MT_RX_DRAIN_RING)) { rx_teardown(); return 1; } + if (mt_mac_start(&dev, MT_RX_DRAIN_RING)) { + rx_teardown(); mt_mac_stop(&dev); return 1; + } mt7612u_set_monitor_rx(&dev, 0); mt7612u_link_stats_start(&dev); @@ -2741,7 +2811,11 @@ static int gate_linkstat(uint8_t chan, int secs, int with_rx) if (with_rx) { if (mt7612u_rx_start(&dev, drain_cb, &linkstat_drained)) return 1; } - if (mt_mac_start(&dev, with_rx)) { if (with_rx) rx_teardown(); return 1; } + if (mt_mac_start(&dev, with_rx)) { + if (with_rx) rx_teardown(); + mt_mac_stop(&dev); + return 1; + } if (with_rx) mt7612u_set_monitor_rx(&dev, 0); mt7612u_link_stats_start(&dev); @@ -2815,7 +2889,7 @@ static int gate_linktx(uint8_t chan, int count) if (mt_eeprom_init(&dev)) return 1; if (mt_init_hardware(&dev, NULL)) return 1; if (mt_set_channel(&dev, chan, MT7612U_BW_20)) return 1; - if (mt_mac_start(&dev, MT_RX_DRAIN_NONE)) return 1; + if (mt_mac_start(&dev, MT_RX_DRAIN_NONE)) { mt_mac_stop(&dev); return 1; } memset(f, 0, sizeof f); f[0] = 0x08; @@ -2904,7 +2978,9 @@ static int gate_linkrx(uint8_t chan, int secs) if (mt_init_hardware(&dev, NULL)) return 1; if (mt_set_channel(&dev, chan, MT7612U_BW_20)) return 1; if (mt7612u_rx_start(&dev, linkrx_cb, NULL)) return 1; - if (mt_mac_start(&dev, MT_RX_DRAIN_RING)) { rx_teardown(); return 1; } + if (mt_mac_start(&dev, MT_RX_DRAIN_RING)) { + rx_teardown(); mt_mac_stop(&dev); return 1; + } mt7612u_set_monitor_rx(&dev, 0); printf("RX on ch%u for %d s, filtering our own magic\n", chan, secs); @@ -2978,7 +3054,7 @@ static int gate_diversity(uint8_t chan, int count) if (mt_eeprom_init(&dev)) return 1; if (mt_init_hardware(&dev, NULL)) return 1; if (mt_set_channel(&dev, chan, MT7612U_BW_20)) return 1; - if (mt_mac_start(&dev, MT_RX_DRAIN_NONE)) return 1; + if (mt_mac_start(&dev, MT_RX_DRAIN_NONE)) { mt_mac_stop(&dev); return 1; } memset(f, 0, sizeof f); f[0] = 0x08; @@ -3082,7 +3158,7 @@ static int gate_coding(uint8_t chan, int count, int bw) if (mt_eeprom_init(&dev)) return 1; if (mt_init_hardware(&dev, NULL)) return 1; if (mt_set_channel(&dev, chan, (enum mt7612u_bw)bw)) return 1; - if (mt_mac_start(&dev, MT_RX_DRAIN_NONE)) return 1; + if (mt_mac_start(&dev, MT_RX_DRAIN_NONE)) { mt_mac_stop(&dev); return 1; } mt_chan_group(chan, (uint8_t)bw, &hw_chan, NULL, NULL); printf("ch%u (hw centre %u) at %d MHz, %d frames per arm\n\n", @@ -3189,7 +3265,7 @@ static int gate_sweep(uint8_t chan, int count, int bw) if (mt_eeprom_init(&dev)) return 1; if (mt_init_hardware(&dev, NULL)) return 1; if (mt_set_channel(&dev, chan, (enum mt7612u_bw)bw)) return 1; - if (mt_mac_start(&dev, MT_RX_DRAIN_NONE)) return 1; + if (mt_mac_start(&dev, MT_RX_DRAIN_NONE)) { mt_mac_stop(&dev); return 1; } /* Report the centre the hardware actually tuned, not the control * channel: at 80 MHz they differ by up to 6, and a witness listening on @@ -3318,7 +3394,7 @@ static int gate_vht(uint8_t chan, int count, int bw) if (mt_eeprom_init(&dev)) return 1; if (mt_init_hardware(&dev, NULL)) return 1; if (mt_set_channel(&dev, chan, (enum mt7612u_bw)bw)) return 1; - if (mt_mac_start(&dev, MT_RX_DRAIN_NONE)) return 1; + if (mt_mac_start(&dev, MT_RX_DRAIN_NONE)) { mt_mac_stop(&dev); return 1; } mt_chan_group(chan, (uint8_t)bw, &hw_chan, NULL, NULL); printf("chainmask 0x%04x -> %d spatial streams, txwi[17]=0x%02x\n", @@ -3400,7 +3476,7 @@ static int gate_rtap(uint8_t chan, int count) if (mt_eeprom_init(&dev)) return 1; if (mt_init_hardware(&dev, NULL)) return 1; if (mt_set_channel(&dev, chan, MT7612U_BW_20)) return 1; - if (mt_mac_start(&dev, MT_RX_DRAIN_NONE)) return 1; + if (mt_mac_start(&dev, MT_RX_DRAIN_NONE)) { mt_mac_stop(&dev); return 1; } memcpy(pkt, rtap, sizeof rtap); { diff --git a/tests/mt7612u_ap_onair.sh b/tests/mt7612u_ap_onair.sh index 2e9d5585..2289c157 100755 --- a/tests/mt7612u_ap_onair.sh +++ b/tests/mt7612u_ap_onair.sh @@ -58,14 +58,39 @@ bad() { fail=$((fail+1)); printf ' FAIL %s\n' "$*"; } # wpa_supplicant` would drop every wireless client on the host, and a name kill # would reach a concurrent run of this same test. KIDS="" +# sta_pid_alive (zombie-aware) is all this takes from the station lib. +# shellcheck source=tests/mt7612u_sta_lib.sh +. "$ROOT/tests/mt7612u_sta_lib.sh" +# TERM every child, then WAIT for them to exit - up to 10 s: a demo's chip +# de-init runs after the signal, and re-enumerating the adapter under it is +# the hand-back this must not do. Anything still alive then is KILLed and +# reap returns 1, so cleanup leaves the adapter alone; 0 when all exited. reap() { - local pid + local pid live t=0 for pid in $KIDS; do kill "$pid" 2>/dev/null; done + while :; do + live="" + for pid in $KIDS; do sta_pid_alive "$pid" && live="$live $pid"; done + [ -z "$live" ] && break + if [ "$t" -ge 100 ]; then + for pid in $live; do kill -KILL "$pid" 2>/dev/null; done + echo "still running 10 s after TERM (KILLed):$live" + KIDS="" + return 1 + fi + sleep 0.1; t=$((t + 1)) + done + for pid in $KIDS; do wait "$pid" 2>/dev/null; done # reaps our own children KIDS="" + return 0 } +# Returns 1 when it could not reset the AP (a process outlived TERM): the +# adapter may still be airing an autonomous beacon, so no later cell may be +# scored against it. cleanup() { - reap + local reaped=0 + reap || reaped=1 [ -n "${STA_IF:-}" ] && { ip addr flush dev "$STA_IF" 2>/dev/null iw dev "$STA_IF" disconnect 2>/dev/null; } # The MAC beacons autonomously, so a cell that died before its teardown can @@ -87,7 +112,9 @@ cleanup() { # Either way, confirmed against the VID:PID first: this runs as root and # writes to a path the caller supplied, and a stale AP_SYSFS would otherwise # yank whatever else is plugged there. - if [ "$(cat "/sys/bus/usb/devices/$AP_SYSFS/idVendor" 2>/dev/null)" = "0e8d" ] && + if [ "$reaped" != 0 ]; then + echo "a process outlived TERM - not re-enumerating AP_SYSFS=$AP_SYSFS" + elif [ "$(cat "/sys/bus/usb/devices/$AP_SYSFS/idVendor" 2>/dev/null)" = "0e8d" ] && [ "$(cat "/sys/bus/usb/devices/$AP_SYSFS/idProduct" 2>/dev/null)" = "7612" ]; then if [ -n "${AP_VBUS:-}" ]; then uhubctl -l "${AP_VBUS%%:*}" -p "${AP_VBUS##*:}" -a off >/dev/null 2>&1 @@ -101,8 +128,15 @@ cleanup() { sleep 3 fi fi + return "$reaped" } -trap cleanup EXIT INT TERM +# An interrupt must STOP the run: on the EXIT trap alone, INT/TERM would run +# cleanup and then carry on into the next cell against a re-enumerated +# adapter. CLEANED only spares the EXIT pass a second re-enumeration after +# that; the cleanups between cells (CELLS=all) still run every time. +CLEANED=no +trap '[ "$CLEANED" = yes ] || cleanup' EXIT +trap 'cleanup; CLEANED=yes; exit 130' INT TERM # --- the station ----------------------------------------------------------- STA_IF=$(ls "/sys/bus/usb/devices/$STA_SYSFS:1.0/net/" 2>/dev/null | head -1) @@ -126,8 +160,8 @@ say "AP $AP_SYSFS station $STA_SYSFS ($STA_IF) ch$CH ($FREQ MHz)" # live beacon as absent. Observed: a "beacon not scannable" FAIL in a run where # the station then associated, pinged, and got an auth at retry=0. seen() { # $1 = SSID, $2 = BSSID - local i n best=0 - for i in 1 2 3; do + local n best=0 + for _ in 1 2 3; do # Matched on BSSID *and* SSID: a neighbour running "devourerAP" would # otherwise pass an arm check, fail a stop check, or break the exact-count # comparison. awk keeps the pairing - grep -c on two patterns would count @@ -189,7 +223,7 @@ cell_open() { if ping -c 6 -W 1 -I "$STA_IF" "$APIP" 2>&1 | tee "$OUT/open.ping" | grep -q " 0% packet loss"; then ok "open: data plane ($(grep -oE 'rtt [^ ]+ = [0-9./]+' "$OUT/open.ping" | head -1))" else - bad "open: ping lost packets ($(grep -oE '[0-9]+% packet loss' "$OUT/open.ping" | head -1))" + bad "open: ping lost packets ($(grep -oE '[0-9.]+% packet loss' "$OUT/open.ping" | head -1))" fi # retry=0 on auth IS the hardware ACK: an un-ACKed frame comes back with FC # Retry set. This is the only evidence that the APC slot and port identity @@ -222,8 +256,7 @@ cell_wpa2() { ip addr flush dev "$STA_IF" 2>/dev/null wpa_supplicant -i "$STA_IF" -c "$wpa" -P "$OUT/wpa.pid" -B >/dev/null 2>&1 KIDS="$KIDS $(cat "$OUT/wpa.pid" 2>/dev/null)" - local i - for i in $(seq 1 20); do + for _ in $(seq 1 20); do grep -q "4-WAY HANDSHAKE COMPLETE" "$OUT/wpa2.log" && break sleep 1 done @@ -274,8 +307,7 @@ cell_stop() { printf '%s' "${n:-0}" } wait_arm() { # $1 = the count to exceed, $2 = seconds to wait - local i - for i in $(seq 1 "$2"); do [ "$(armed)" -gt "$1" ] && return 0; sleep 1; done + for _ in $(seq 1 "$2"); do [ "$(armed)" -gt "$1" ] && return 0; sleep 1; done return 1 } @@ -284,8 +316,11 @@ cell_stop() { [ "$(seen mtStopCheck 02:4d:54:53:54:50)" = 1 ] && ok "stop: armed - beacon on air" || bad "stop: armed but not scannable" local n_arms; n_arms=$(armed) - local i - for i in $(seq 1 60); do grep -q "PHASE 2" "$OUT/stop.log" && break; sleep 1; done + for _ in $(seq 1 60); do grep -q "PHASE 2" "$OUT/stop.log" && break; sleep 1; done + # No PHASE 2 means StopBeacon was never called: a beacon gone now was + # stopped by whatever ended the process, which says nothing about StopBeacon. + grep -q "PHASE 2" "$OUT/stop.log" || + { bad "stop: never reached PHASE 2 - StopBeacon not exercised"; kill $ap 2>/dev/null; return; } sleep 6 [ "$(seen mtStopCheck 02:4d:54:53:54:50)" = 0 ] && ok "stop: stopped - beacon gone" || bad "stop: STILL AIRING after StopBeacon" @@ -305,10 +340,23 @@ case "$CELLS" in open) cell_open ;; wpa2) cell_wpa2 ;; stop) cell_stop ;; - all) cell_open; cleanup; cell_wpa2; cleanup; cell_stop ;; + all) # A between-cell cleanup that could not reset the AP ends the run: + # the cells after it are recorded as not run, never scored. + cell_open + if ! cleanup; then not_run="wpa2 stop" + else + cell_wpa2 + if ! cleanup; then not_run="stop"; else cell_stop; fi + fi ;; *) echo "usage: $0 [open|wpa2|stop|all]"; exit 2 ;; esac say "" +if [ -n "${not_run:-}" ]; then + say " NOT RUN $not_run - the AP could not be reset between cells" +fi say "=== $pass passed, $fail failed (logs: $OUT) ===" -exit $(( fail > 0 )) +# 1 a check failed; else 2 when cells were left unrun (INCONCLUSIVE); else 0. +[ "$fail" -gt 0 ] && exit 1 +[ -n "${not_run:-}" ] && exit 2 +exit 0 diff --git a/tests/mt7612u_sta_autoack.sh b/tests/mt7612u_sta_autoack.sh index f8091aa1..52f37ce3 100755 --- a/tests/mt7612u_sta_autoack.sh +++ b/tests/mt7612u_sta_autoack.sh @@ -68,17 +68,22 @@ ok() { pass=$((pass+1)); printf ' PASS %s\n' "$*"; } bad() { fail=$((fail+1)); printf ' FAIL %s\n' "$*"; } DUT_PID="" +CLEANED=no # shellcheck disable=SC2317 # reached through the traps below cleanup() { + [ "$CLEANED" = yes ] && return 0 + CLEANED=yes # arm() runs in a command substitution, so the PIDs it starts are recorded # in $OUT (tests/mt7612u_sta_lib.sh) for this trap to find. The peer first: # an orphan txdemo keeps its USB lock and fails the NEXT run's peer open # with "adapter already in use", which yields zero reports - and zero is a # control's passing value. INT, as timeout(1) forwards it to txdemo. sta_pid_kill peer INT; peer_gone=$? - sta_pid_kill dut + sta_pid_kill dut; dut_gone=$? DUT_PID="" - sta_dut_handback + # The same rule for the DUT: never re-enumerate it mid-de-init. + if [ "$dut_gone" = 0 ]; then sta_dut_handback + else echo "DUT still running - not re-enumerating DUT_SYSFS=$DUT_SYSFS"; fi # Only once the peer process has really exited: re-enumerating an adapter # still inside its de-init is what the hand-back must not do. if [ "$peer_gone" = 0 ]; then sta_peer_handback @@ -88,8 +93,9 @@ cleanup() { } trap cleanup EXIT # AND IT MUST STOP: with INT/TERM on the EXIT trap the shell runs cleanup -# and then CARRIES ON into the next arm. cleanup is idempotent, so the EXIT -# pass after it is harmless. +# and then CARRIES ON into the next arm. CLEANED makes the EXIT pass after it +# a no-op: sta_pid_kill forgets a PID on the first pass, so a second pass +# would hand back an adapter the first refused to. trap 'cleanup; exit 130' INT TERM sta_dut_take || exit 2 diff --git a/tests/mt7612u_sta_identity.sh b/tests/mt7612u_sta_identity.sh index 9ea8b3ed..7d3ebe98 100755 --- a/tests/mt7612u_sta_identity.sh +++ b/tests/mt7612u_sta_identity.sh @@ -41,6 +41,10 @@ # first binds (seen: bus 9 -> 10, 3-2.3.3 -> 4-2.3.3). Read AP_SYSFS from # `lsusb -t` after the driver has loaded; a stale one is refused. # +# Exit status: 0 every gate passed; 1 a gate failed; 2 INCONCLUSIVE (a gate +# could not measure, or the rig was refused); 3 interrupted (a gate, or the +# run itself by INT/TERM). +# # Env: AP_SYSFS, DUT_SYSFS, CH, BSSID, SECS, OUT, FW_DIR. set -u @@ -79,9 +83,10 @@ AP_IF="" # The accepted AP's idVendor:idProduct:serial, recorded once the guard has # passed; cleanup re-enumerates AP_SYSFS only while it still names this device. AP_ID="" -# Set only once hostapd is up on a verified AP-capable interface: before -# that, the trap has no business re-enumerating anything (a wrong or default -# AP_SYSFS naming a hub would power-cycle every device under it). +# Set only once AP_SYSFS has passed the AP guard below, and before anything +# touches the interface: before that, the trap has no business +# re-enumerating anything (a wrong or default AP_SYSFS naming a hub would +# power-cycle every device under it). AP_REENUM=no CLEANED=no # shellcheck disable=SC2317 # reached through the traps below @@ -90,11 +95,15 @@ cleanup() { CLEANED=yes sta_fw_unlink sta_pid_kill inject - sta_pid_kill gate - sta_dut_handback - [ "$AP_REENUM" = yes ] || { sta_lock_release; return 0; } + local gate_gone=0 + sta_pid_kill gate || gate_gone=1 + # Never re-enumerate the DUT while its gate is still in de-init. + if [ "$gate_gone" = 0 ]; then sta_dut_handback + else echo "DUT gate still running - not re-enumerating DUT_SYSFS=$DUT_SYSFS"; fi # hostapd -B daemonizes; its PID is the one it wrote to -P for this run. + # Unconditional: nothing is recorded unless hostapd started. sta_pid_kill hostapd + [ "$AP_REENUM" = yes ] || { sta_lock_release; return 0; } sleep 1 iw dev staid_mon del 2>/dev/null # RE-ENUMERATE the AP adapter, do not just bounce the link. @@ -125,7 +134,7 @@ trap cleanup EXIT # AND IT MUST STOP: with INT/TERM on the EXIT trap the shell runs cleanup # and then CARRIES ON into the next arm. cleanup is idempotent, so the EXIT # pass after it is harmless. -trap 'cleanup; exit 130' INT TERM +trap 'cleanup; exit 3' INT TERM # --- the AP ---------------------------------------------------------------- # THE AP GUARD. Cleanup re-enumerates AP_SYSFS as root, so it is accepted only @@ -162,6 +171,17 @@ PHY=$(basename "$(readlink -f "/sys/class/net/$AP_IF/phy80211")") iw phy "$PHY" info 2>/dev/null | grep -q '\* AP$' || ap_refuse "$AP_IF ($PHY) does not support AP mode" AP_ID=$(sta_usb_id "$AP_SYSFS") +# The BSSID gate needs a monitor vif on the AP's phy. Probed here, before +# the gates spend their minute, and again (unchanged) where it is used. +iw dev staid_mon del 2>/dev/null +if ! iw phy "$PHY" interface add staid_mon type monitor 2>/dev/null; then + echo "no monitor vif on $PHY: the BSSID gate would measure broadcast" + echo "reception only - refusing this AP." + exit 2 +fi +iw dev staid_mon del 2>/dev/null +# From here on the trap restores the AP: everything below changes it. +AP_REENUM=yes echo "AP $AP_IF ($PHY) bssid $BSSID ch$CH" echo "DUT $DUT_SYSFS (MT7612U)" @@ -196,11 +216,10 @@ for _ in 1 2 3 4 5 6 7 8 9 10; do fi sleep 1 done -AP_REENUM=yes # hostapd may have run and left its bssid behind if [ "$ap_up" != yes ]; then echo "hostapd did not bring $AP_IF up in AP mode:" tail -12 "$OUT/hostapd.log" 2>/dev/null || echo "(no hostapd log written)" - exit 1 + exit 2 # the rig, not the DUT fi # --- free the DUT ---------------------------------------------------------- @@ -253,7 +272,7 @@ if ! { iw phy "$PHY" interface add staid_mon type monitor 2>/dev/null && ip link set staid_mon up 2>/dev/null; }; then echo "no monitor vif on $PHY: the BSSID gate would measure broadcast" echo "reception only, which is not the question - refusing to run it." - exit 1 + exit 2 # the rig, not the DUT fi # ORDER MATTERS. The gate's bring-up runs the MT7612U's calibrations, whose # MCU replies arrive late under a strong transmitter nearby (mcu.cpp); the @@ -261,7 +280,11 @@ fi # the injector only once the gate prints "bring-up done", and the gate then # pauses 3 s before arm A so the stimulus covers every arm. : > "$OUT/bssid.txt" -"$BUILD/mt7612uprobe" sta "$CH" "$SECS" "$BSSID" > "$OUT/bssid.txt" 2>&1 & +# BOUNDED: six arms of SECS, a 3 s pause, and 3 min for bring-up and slack. +# INT lets the gate restore the registers (exit 3); KILL 10 s later if not. +gate_bound=$(( SECS * 6 + 183 )) +timeout -s INT -k 10 "$gate_bound" \ + "$BUILD/mt7612uprobe" sta "$CH" "$SECS" "$BSSID" > "$OUT/bssid.txt" 2>&1 & sta_gate=$! sta_pid_record gate "$sta_gate" waited=0 @@ -284,6 +307,11 @@ fi wait "$sta_gate" r_bss=$? rm -f "$OUT/.pid_gate" +gate_overran=no +if [ "$r_bss" = 124 ] || [ "$r_bss" = 137 ]; then + echo "the BSSID gate overran its ${gate_bound}s bound - no measurement" + r_bss=2; gate_overran=yes +fi inj_secs=$(( $(date +%s) - inj_t0 )) cat "$OUT/bssid.txt" # Stop the injector (it prints its count on SIGTERM) and require that it @@ -304,7 +332,9 @@ if [ "${injected:-0}" -gt 0 ] 2>/dev/null; then else echo "the unicast injector injected NOTHING (see $OUT/inject.log) - the BSSID" echo "table measured broadcast reception only." - [ "$r_bss" = 3 ] || r_bss=1 # an interrupted gate stays "no verdict" + # An interrupted or overrun gate stays "no verdict": a bring-up that + # wedged before the injector started is not a failed measurement. + [ "$r_bss" = 3 ] || [ "$gate_overran" = yes ] || r_bss=1 fi echo @@ -321,4 +351,15 @@ case "$r_bss" in 3) echo "the BSSID gate was INTERRUPTED - no verdict" ;; *) echo "the BSSID gate did not pass - see $OUT/bssid.txt" ;; esac -exit $(( r_bss != 0 || r_ack != 0 || ${staid:-0} != 0 )) +# 1 if any gate failed, else 3 if any was interrupted, else 2 if any could +# not measure, else 0. +rc=0 +for r in "$r_bss" "$r_ack" "${staid:-0}"; do + case "$r" in + 0) ;; + 3) [ "$rc" = 1 ] || rc=3 ;; + 2) [ "$rc" = 0 ] && rc=2 ;; + *) rc=1 ;; + esac +done +exit "$rc" diff --git a/tests/mt7612u_sta_onair.sh b/tests/mt7612u_sta_onair.sh index ebb31fa9..f4bc009d 100755 --- a/tests/mt7612u_sta_onair.sh +++ b/tests/mt7612u_sta_onair.sh @@ -238,9 +238,35 @@ ap_up() { # $1 open | wpa2, $2 cell printf 'wpa_group_rekey=%s\nwpa_ptk_rekey=%s\n' "$REKEY_S" "$PTK_REKEY_S" fi } > "$OUT/hostapd_$2.conf" + # The previous cell's hostapd exiting is not its interface being back: a + # launch 30 ms after AP-DISABLED found the netdev gone ("Could not read + # interface flags: No such device" / "nl80211 driver initialization + # failed"). Wait, bounded, until the netdev is present, and FORCE it to a + # station once it is: a driver may leave the vif in AP type after hostapd + # exits, and hostapd then fails with "Match already configured" rather + # than anything that names the problem (tests/mt7612u_sta_identity.sh). + local t=0 info + while :; do + info=$(ip netns exec "$NS" iw dev "$AP_IF" info 2>/dev/null) + case "$info" in + *'type managed'*) break ;; + '') ;; # not back yet + *) ip netns exec "$NS" ip link set "$AP_IF" down 2>/dev/null + ip netns exec "$NS" iw dev "$AP_IF" set type managed 2>/dev/null ;; + esac + if [ "$t" -ge 100 ]; then + echo "rig: $AP_IF not back as a managed netdev in netns $NS within 10 s" \ + "- hostapd not started" | tee "$OUT/hostapd_$2.log" + # The loop may just have taken it down; leave it up (best effort). + ip netns exec "$NS" ip link set "$AP_IF" up 2>/dev/null + return 1 + fi + sleep 0.1; t=$((t + 1)) + done + ip netns exec "$NS" ip link set "$AP_IF" up 2>/dev/null ip netns exec "$NS" hostapd -t "$OUT/hostapd_$2.conf" > "$OUT/hostapd_$2.log" 2>&1 & sta_pid_record hostapd $! - local t=0 + t=0 until ip netns exec "$NS" iw dev "$AP_IF" info 2>/dev/null | grep -q 'type AP'; do [ "$t" -ge 15 ] && return 1 sleep 1; t=$((t + 1)) diff --git a/tests/mt7612u_sta_uplink.sh b/tests/mt7612u_sta_uplink.sh index 0eb064ea..76cec4c3 100755 --- a/tests/mt7612u_sta_uplink.sh +++ b/tests/mt7612u_sta_uplink.sh @@ -86,14 +86,19 @@ ok() { pass=$((pass+1)); printf ' PASS %s\n' "$*"; } bad() { fail=$((fail+1)); printf ' FAIL %s\n' "$*"; } RESP="" +CLEANED=no # shellcheck disable=SC2317 # reached through the traps below cleanup() { + [ "$CLEANED" = yes ] && return 0 + CLEANED=yes # arm() runs in a command substitution, so its PIDs are recorded in $OUT # (tests/mt7612u_sta_lib.sh) for this trap to find. - sta_pid_kill dut + sta_pid_kill dut; dut_gone=$? sta_pid_kill resp; peer_gone=$? RESP="" - sta_dut_handback + # Never re-enumerate the DUT while its process is still in de-init. + if [ "$dut_gone" = 0 ]; then sta_dut_handback + else echo "DUT still running - not re-enumerating DUT_SYSFS=$DUT_SYSFS"; fi # Only once the peer process has really exited: re-enumerating an adapter # still inside its de-init is what the hand-back must not do. if [ "$peer_gone" = 0 ]; then sta_peer_handback @@ -103,8 +108,9 @@ cleanup() { } trap cleanup EXIT # AND IT MUST STOP: with INT/TERM on the EXIT trap the shell runs cleanup -# and then CARRIES ON into the next arm. cleanup is idempotent, so the EXIT -# pass after it is harmless. +# and then CARRIES ON into the next arm. CLEANED makes the EXIT pass after it +# a no-op: sta_pid_kill forgets a PID on the first pass, so a second pass +# would hand back an adapter the first refused to. trap 'cleanup; exit 130' INT TERM sta_dut_take || exit 2 @@ -140,13 +146,27 @@ arm() { sta_pid_kill resp; RESP=""; return 1 fi + # BOUNDED, so a wedged gate cannot hold the run (and both adapters) forever. + # The gate's own worst case: 16 gate arms, each frame waiting at most its + # status bound (b + 50 ms) and the settle at most b per frame + 2 s, with b + # = 60 ms + 8 ms per retry past 15 (gate_txs's frame_budget_ms), plus ~7 s of + # fixed cost per arm. Half again on top, and 2 min for bring-up. INT lets the + # gate tear down (exit 3); KILL 10 s later if it does not. + b=$(( RETRY_LIMIT > 15 ? 60 + (RETRY_LIMIT - 15) * 8 : 60 )) + dut_bound=$(( 16 * (FRAMES * (2 * b + 50) / 1000 + 8) * 3 / 2 + 120 )) DEVOURER_TX_RETRY_LIMIT="$RETRY_LIMIT" \ + timeout -s INT -k 10 "$dut_bound" \ "$BUILD/mt7612uprobe" txs "$CH" "$FRAMES" "$TARGET" \ >"$OUT/dut_$tag.txt" 2>&1 & dut=$! sta_pid_record dut "$dut" wait "$dut" + dut_rc=$? rm -f "$OUT/.pid_dut" + if [ "$dut_rc" = 124 ] || [ "$dut_rc" = 137 ]; then + printf '%s ABORTED the DUT gate overran its %ss bound' "$tag" "$dut_bound" + sta_pid_kill resp; RESP=""; return 1 + fi # The limit must have LANDED, not merely been asked for: the gate prints # this line only after mt7612u_set_retry_limit() read it back. if ! grep -q "^retry limit set to $RETRY_LIMIT " "$OUT/dut_$tag.txt"; then diff --git a/tests/realtek_station_onair.sh b/tests/realtek_station_onair.sh index 1bb8208f..3da2613c 100755 --- a/tests/realtek_station_onair.sh +++ b/tests/realtek_station_onair.sh @@ -44,9 +44,9 @@ # station (the AP then deauths the "class 3" sender - expected, harmless). # # An arm is scored only when its transmitter kept airing through the window -# (no silence over MAX_GAP_MS between its reports, or after the last one - -# see summarize) and carried MIN_REPORTS reports and MIN_SUBMITTED -# submissions. Reception in arm A is +# (no silence over MAX_GAP_MS before its first report, between its +# reports, or after the last one - see summarize) and carried MIN_REPORTS reports and its submission floor +# (MIN_SUBMITTED, scaled to the span it aired). Reception in arm A is # judged against the peer's REPORTED frames, which aired; submitted frames # left unreported at window close are printed separately. # @@ -88,12 +88,18 @@ GAP_US="${GAP_US:-5000}" HALF="${HALF:-both}" MIN_REPORTS="${MIN_REPORTS:-50}" MIN_RX_PCT="${MIN_RX_PCT:-80}" -# Transmitter liveness: the longest silence allowed between two of an arm's -# CCX reports, and between its last report and its final tx.stats. +# Transmitter liveness: the longest silence allowed from an arm's first +# submit to its first CCX report, between two reports, and from its last +# report to its final tx.stats. MAX_GAP_MS="${MAX_GAP_MS:-2000}" # Per-arm floor on frames the transmitter submitted. Default: a quarter of -# the nominal SECS / GAP_US rate (500 at the defaults; the slowest arm on -# record, an unacknowledged one at 12 retries, submitted ~900). +# the nominal GAP_US rate over the span the arm actually aired - its first +# submit to its final tx.stats (see summarize) - not over SECS, which also +# holds the transmitter's bring-up: an 8812BU peer has spent 6-9 s of a 10 s +# window in InitWrite and been scored a stall at 283-327 healthy frames. +# The slowest arm on record, an unacknowledged one at 12 retries, submitted +# ~900 in 10 s against the 500 a full window asks. A set MIN_SUBMITTED is a +# fixed floor instead. MIN_SUBMITTED="${MIN_SUBMITTED:-}" EXPECT_UNARMED_SILENT="${EXPECT_UNARMED_SILENT:-1}" READY_TIMEOUT="${READY_TIMEOUT:-30}" @@ -106,14 +112,6 @@ case "$HALF" in both|down|up) ;; *) echo "HALF must be both, down or up"; exit 2 for v in SECS RETRY_LIMIT GAP_US MIN_REPORTS MIN_RX_PCT MAX_GAP_MS READY_TIMEOUT CLEAR_AFTER_MS CH; do case "${!v}" in ''|*[!0-9]*) echo "$v must be a non-negative integer"; exit 2 ;; esac done -# GAP_US=0 (max duty) has no nominal rate, so no default floor. -if [ -z "$MIN_SUBMITTED" ]; then - if [ "$GAP_US" -gt 0 ]; then - MIN_SUBMITTED=$(( SECS * 1000000 / GAP_US / 4 )) - else - MIN_SUBMITTED=0 - fi -fi case "$MIN_SUBMITTED" in *[!0-9]*) echo "MIN_SUBMITTED must be a non-negative integer"; exit 2 ;; esac # RETRY_LIMIT 0 would make every control indistinguishable from a one-shot # send: the retry count is the instrument. @@ -134,6 +132,29 @@ done # shellcheck source=tests/mt7612u_sta_lib.sh . "$ROOT/tests/mt7612u_sta_lib.sh" + +# The AP guard (tests/mt7612u_sta_identity.sh's): cleanup re-enumerates +# AP_SYSFS as root, so it must be a USB device that is not a hub, carrying a +# wireless netdev with no default route on a phy that supports AP mode. Sets +# AP_IF and PHY. Run in the preflight, before the DOWN half spends its +# minute, and again at the start of UP - the adapter can move in between. +ap_refuse() { echo "refusing AP_SYSFS=$AP_SYSFS: $* - cleanup would re-enumerate it."; exit 2; } +ap_guard() { + local fam + [ -n "$(cat "/sys/bus/usb/devices/$AP_SYSFS/idVendor" 2>/dev/null)" ] || + ap_refuse "not a USB device - if its driver just loaded it may have moved; re-read lsusb -t" + [ "$(cat "/sys/bus/usb/devices/$AP_SYSFS/bDeviceClass" 2>/dev/null)" != "09" ] || ap_refuse "a hub" + AP_IF=$(sta_first_netdev "$AP_SYSFS") + [ -n "$AP_IF" ] || ap_refuse "no network interface on it" + [ -e "/sys/class/net/$AP_IF/phy80211" ] || ap_refuse "$AP_IF is not wireless" + for fam in -4 -6; do + ip "$fam" route show default 2>/dev/null | grep -qw "dev $AP_IF" && + ap_refuse "$AP_IF carries a default route" + done + PHY=$(basename "$(readlink -f "/sys/class/net/$AP_IF/phy80211")") + iw phy "$PHY" info 2>/dev/null | grep -q '\* AP$' || ap_refuse "$AP_IF ($PHY) does not support AP mode" +} +[ "$HALF" = down ] || ap_guard sta_out_prepare || exit 2 sta_lock_take || exit 2 sta_pid_init dut peer hostapd @@ -255,23 +276,36 @@ wait_for() { # $1 pid, $2 file, $3 regex, $4 timeout s # One summary line from a transmitter's JSONL (and, optionally, the DUT's # rx.seq stream for frames from TA): reports, submitted, unreported, -# ok_pct, retries_mean, max_gap_ms, tail_ms, live, rx_distinct. +# ok_pct, retries_mean, lead_ms, max_gap_ms, tail_ms, live, aired_ms, +# min_submitted, rx_distinct. +# +# One clock: tx.report, the first tx.frame (txdemo's first submit) and the +# final tx.stats all carry t in the host-monotonic tx.report timebase. # # LIVENESS. A report is a frame that aired, so the reports' own timestamps -# (t, the host-monotonic tx.report timebase) show whether the transmitter -# kept airing through the window: max_gap_ms is the longest silence between -# two reports, tail_ms the silence from the last report to the final -# tx.stats, which carries t in the same timebase. live=0 when either exceeds -# MAX_GAP_MS or the final tx.stats has no t - an arm that aired a burst and -# stalled, which MIN_REPORTS alone would accept. +# show whether the transmitter kept airing through the window: lead_ms is +# the silence from the first submit to the first report, max_gap_ms the +# longest silence between two reports, tail_ms the silence from the last +# report to the final tx.stats. live=0 when any of them exceeds MAX_GAP_MS, +# or a timestamp it needs is missing - an arm that aired a burst and +# stalled, or that started, stalled and burst at the end, which MIN_REPORTS +# alone would accept. +# +# FLOOR. aired_ms runs from the first submit to the final tx.stats - not +# over SECS, which also holds the transmitter's bring-up, and not from the +# first report, which a late burst would move to the end. min_submitted +# is a quarter of the GAP_US rate over it (0 at GAP_US=0, which has no +# nominal rate), or MIN_SUBMITTED when set. summarize() { # $1 tx jsonl, $2 tag, $3 dut jsonl or "" - python3 - "$1" "$2" "${3:-}" "$MAX_GAP_MS" <<'PYEOF' + python3 - "$1" "$2" "${3:-}" "$MAX_GAP_MS" "$GAP_US" "$MIN_SUBMITTED" <<'PYEOF' import json, sys tx, tag, rx, max_gap = sys.argv[1], sys.argv[2], sys.argv[3], int(sys.argv[4]) +gap_us, fixed_floor = int(sys.argv[5]), sys.argv[6] n = okc = retries = 0 submitted = 0 ts = [] final_t = None +first_submit_t = None for line in open(tx, errors='replace'): if not line.startswith('{'): continue @@ -285,19 +319,31 @@ for line in open(tx, errors='replace'): retries += int(e.get('retries', 0) or 0) if 't' in e: ts.append(int(e['t'])) + elif e.get('ev') == 'tx.frame': + if first_submit_t is None and 't' in e: + first_submit_t = int(e['t']) elif e.get('ev') == 'tx.stats': submitted = int(e.get('submitted', submitted) or submitted) if e.get('final') and 't' in e: final_t = int(e['t']) gap = max((b - a for a, b in zip(ts, ts[1:])), default=0) tail = (final_t - ts[-1]) if (final_t is not None and ts) else None -live = int(bool(ts) and tail is not None and gap <= max_gap - and tail <= max_gap) +lead = (ts[0] - first_submit_t) if (first_submit_t is not None and ts) else None +live = int(bool(ts) and tail is not None and lead is not None + and lead <= max_gap and gap <= max_gap and tail <= max_gap) out = (f"{tag} reports={n} submitted={submitted} " f"unreported={max(submitted - n, 0)}") if n: out += f" ok_pct={100.0*okc/n:.1f} retries_mean={retries/n:.2f}" -out += f" max_gap_ms={gap} tail_ms={'none' if tail is None else tail} live={live}" +out += (f" lead_ms={'none' if lead is None else lead} max_gap_ms={gap}" + f" tail_ms={'none' if tail is None else tail} live={live}") +aired = (final_t - first_submit_t) \ + if (final_t is not None and first_submit_t is not None) else 0 +if fixed_floor: + floor = int(fixed_floor) +else: + floor = aired * 1000 // gap_us // 4 if gap_us > 0 else 0 +out += f" aired_ms={aired} min_submitted={floor}" if rx: seen = set() try: @@ -460,12 +506,12 @@ ok() { pass=$((pass+1)); printf ' PASS %s\n' "$*"; } bad() { fail=$((fail+1)); printf ' FAIL %s\n' "$*"; } inc() { inconclusive=$((inconclusive+1)); printf ' INCONCLUSIVE %s\n' "$*"; } field() { sed -n "s/.* $2=\\([0-9.]*\\).*/\\1/p" "$OUT/res_$1"; } -# A usable arm: not aborted, carrying at least MIN_REPORTS reports and -# MIN_SUBMITTED submissions, and with a transmitter that kept airing through +# A usable arm: not aborted, carrying at least MIN_REPORTS reports and its +# min_submitted submissions, and with a transmitter that kept airing through # the window (live=1, see summarize). Anything else leaves its verdicts # INCONCLUSIVE. usable() { - local r n sub + local r n sub floor r=$(cat "$OUT/res_$1" 2>/dev/null) case "$r" in *ABORTED*|*FAILCLEAR*|'') return 1 ;; @@ -473,7 +519,9 @@ usable() { case "$r" in *" live=1"*) ;; *) return 1 ;; esac n=$(field "$1" reports) sub=$(field "$1" submitted) - [ "${n:-0}" -ge "$MIN_REPORTS" ] && [ "${sub:-0}" -ge "$MIN_SUBMITTED" ] + floor=$(field "$1" min_submitted) + [ -n "$floor" ] || return 1 + [ "${n:-0}" -ge "$MIN_REPORTS" ] && [ "${sub:-0}" -ge "$floor" ] } show() { echo " $(cat "$OUT/res_$1" 2>/dev/null)"; } @@ -541,7 +589,7 @@ if [ "$HALF" != up ]; then bad "DOWN receive: the DUT delivered ${rxd:-0} distinct frames of the peer's ${rep:-0} reported (< ${MIN_RX_PCT}%; ${unr:-0} submitted unreported)" fi else - inc "DOWN: arm A, B or C aborted, carried under $MIN_REPORTS reports or $MIN_SUBMITTED submissions, or its transmitter stalled (live=0) - not a measurement" + inc "DOWN: arm A, B or C aborted, carried under $MIN_REPORTS reports or its min_submitted floor, or its transmitter stalled (live=0) - not a measurement" fi if usable A && usable D; then a=$(field A ok_pct); d=$(field D ok_pct) @@ -577,20 +625,8 @@ fi # =============================== UP ========================================= if [ "$HALF" != down ]; then - # --- the AP: the guard tests/mt7612u_sta_identity.sh uses --- - ap_refuse() { echo "refusing AP_SYSFS=$AP_SYSFS: $* - cleanup would re-enumerate it."; exit 2; } - [ -n "$(cat "/sys/bus/usb/devices/$AP_SYSFS/idVendor" 2>/dev/null)" ] || - ap_refuse "not a USB device - if its driver just loaded it may have moved; re-read lsusb -t" - [ "$(cat "/sys/bus/usb/devices/$AP_SYSFS/bDeviceClass" 2>/dev/null)" != "09" ] || ap_refuse "a hub" - AP_IF=$(sta_first_netdev "$AP_SYSFS") - [ -n "$AP_IF" ] || ap_refuse "no network interface on it" - [ -e "/sys/class/net/$AP_IF/phy80211" ] || ap_refuse "$AP_IF is not wireless" - for fam in -4 -6; do - ip "$fam" route show default 2>/dev/null | grep -qw "dev $AP_IF" && - ap_refuse "$AP_IF carries a default route" - done - PHY=$(basename "$(readlink -f "/sys/class/net/$AP_IF/phy80211")") - iw phy "$PHY" info 2>/dev/null | grep -q '\* AP$' || ap_refuse "$AP_IF ($PHY) does not support AP mode" + # --- the AP: the full guard again (it can have moved during DOWN) --- + ap_guard AP_ID=$(sta_usb_id "$AP_SYSFS") nmcli device set "$AP_IF" managed no >/dev/null 2>&1 @@ -639,7 +675,7 @@ EOF bad "UP ack: F ${f}% is not clearly above the control G ${g}%" fi else - inc "UP: arm F or G aborted, carried under $MIN_REPORTS reports or $MIN_SUBMITTED submissions, or its transmitter stalled (live=0) - not a measurement" + inc "UP: arm F or G aborted, carried under $MIN_REPORTS reports or its min_submitted floor, or its transmitter stalled (live=0) - not a measurement" fi # H is information, not a verdict: whether TX ACK matching needs the arm # at all on this die. Never counted into pass/fail/inconclusive.