Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/logging.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ Emitters: L = library, RX/TX/... = demo. Optional fields in [brackets];
### Init / infrastructure
| ev | emitter | fields |
|---|---|---|
| `init.timing` | L (`src/InitTimer.h`) + demos | stage ("scope.stage", e.g. "demo.first_rx_frame", "txdemo.first_tx_submit"), ms |
| `init.timing` | L (`src/InitTimer.h`) + demos | stage ("scope.stage", e.g. "demo.first_rx_frame", "txdemo.first_tx_submit"), ms, xfers (USB vendor control transfers the stage spent; 0 on PCIe) |
| `adapter.caps` | RX, TX, doctor, txpower (`examples/common/caps_event.h`) | supported, chip, names, chip_id "0x..", gen, variant, transport, tx_chains, rx_chains, n_ss, stbc, ldpc, sgi, bw_max, bw[] (MHz), txpwr_max, txpwr_step_qdb, txpwr_step_measured, txpwr_min_qdb, txpwr_max_qdb, txpwr_rate_diffs, txpwr_rate_diffs_hw, txpwr_rate_diffs_measured, tune_2g4[]\|null, tune_5g[]\|null, char_2g4[]\|null, char_5g[]\|null, ldpc_rx_ht, ldpc_rx_vht, ldpc_rx_flag, per_pkt_txpwr, narrowband, fastretune, ack_responder, tx_retry_limit, he_er_su, per_chain_rssi |
| `debug.wreg` | L (`DEVOURER_LOG_WRITES`) | addr "0x0nnn", width, val "0x…" |
| `hop.prof` | L (`DEVOURER_HOP_PROF`) | gen, ch, `<stage>_us`…, total_us |
Expand Down
28 changes: 22 additions & 6 deletions src/InitTimer.h
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,14 @@
#include <utility>

#include "logger.h"
#include "UsbXferCount.h"

/* Stage timer for init-path profiling. Emits one event per checkpoint:
*
* {"ev":"init.timing","stage":"<scope>.<stage>","ms":N}
* {"ev":"init.timing","stage":"<scope>.<stage>","ms":N,"xfers":K}
*
* `xfers` is the number of USB control transfers (register reads/writes)
* the stage spent — see UsbXferCount.h; 0 on the PCIe transport.
*
* `stage()` reports time since the previous checkpoint (or construction);
* `total()` reports time since construction. Always-on: a handful of events
Expand All @@ -21,23 +25,33 @@ class InitTimer {
public:
InitTimer(Logger_t logger, const char *scope)
: _logger{std::move(logger)}, _scope{scope}, _start{clock::now()},
_last{_start} {}
_last{_start}, _x_start{xfers()}, _x_last{_x_start} {}

void stage(const char *name) {
const auto now = clock::now();
emit(name, ms(_last, now));
const auto x = xfers();
emit(name, ms(_last, now), static_cast<long long>(x - _x_last));
_last = now;
_x_last = x;
}

void total() { emit("total", ms(_start, clock::now())); }
void total() {
emit("total", ms(_start, clock::now()),
static_cast<long long>(xfers() - _x_start));
}

private:
void emit(const char *name, long long millis) {
static uint64_t xfers() {
return devourer::usb_ctrl_xfers().load(std::memory_order_relaxed);
Comment on lines +44 to +45

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remediation recommended

4. Timing records mix adapters' usb work 🐞 Bug ◔ Observability

InitTimer computes each stage's xfers delta from a process-wide usb_ctrl_xfers atomic instead
of a counter associated with the timer's transport or adapter. When adapters initialize concurrently
or any USB worker performs control transfers during the checkpoint interval, those transfers are
attributed to unrelated stages and can also reach PCIe timer metrics that are documented to remain
zero.
Agent Prompt
## Issue description
`init.timing.xfers` is intended to describe the control-transfer work performed by a single initialization stage, but `InitTimer` derives it from a process-wide USB counter. Concurrent adapters or workers therefore contaminate one another's stage measurements, and PCIe timers can report USB activity despite being documented to report zero.

## Issue Context
Associate transfer accounting with each `UsbTransport` or adapter, or inject the relevant transport-specific counter into `InitTimer`, so each timer snapshots only its own instance's activity. Preserve a zero-valued source for PCIe timers and count transfers consistently with the documented metric semantics.

## Fix Focus Areas
- src/InitTimer.h[25-45]
- src/UsbXferCount.h[1-14]
- src/UsbTransport.h[55-73]
- src/UsbTransport.h[212-237]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

}

void emit(const char *name, long long millis, long long nx) {
char stage[96];
std::snprintf(stage, sizeof(stage), "%s.%s", _scope, name);
devourer::Ev(_logger->events(), "init.timing")
.f("stage", stage)
.f("ms", millis);
.f("ms", millis)
.f("xfers", nx);
}

static long long ms(clock::time_point from, clock::time_point to) {
Expand All @@ -49,6 +63,8 @@ class InitTimer {
const char *_scope;
clock::time_point _start;
clock::time_point _last;
uint64_t _x_start;
uint64_t _x_last;
};

#endif /* INIT_TIMER_H */
4 changes: 4 additions & 0 deletions src/RtlAdapter.h
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,10 @@ class RtlAdapter {
/* Pre-power-on HCI programming (rtw88 rtw_hci_setup slot): PCIe TRX ring
* registers; no-op on USB. Call per bring-up attempt, before power-on. */
void hci_setup() { _transport->hci_setup(); }
/* Pipelined register writes — see IRtlTransport::write_batch_begin. */
void write_batch_begin() { _transport->write_batch_begin(); }
void write_batch_end() { _transport->write_batch_end(); }
void flush_writes() { _transport->flush_writes(); }

/* Kernel-style async RX: keep n_urbs concurrent bulk-IN transfers in flight
* (USB) or reap the RX buffer-descriptor ring (PCIe), invoking
Expand Down
14 changes: 14 additions & 0 deletions src/RtlTransport.h
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,20 @@ class IRtlTransport {
return read32(static_cast<uint16_t>(addr));
}

/* ---- pipelined register writes ----
* Inside a write batch, register writes are submitted as asynchronous
* in-order transfers and only a read (or a bulk transfer, or flush_writes)
* waits for them. Bring-up is ~14k synchronous EP0 round trips at ~80 us
* each on an embedded host; pipelined, a write costs ~27 us (measured,
* ssc338q + RTL8812EU, depth >= 8). Correctness rests on EP0 completing
* URBs in submission order, so a read that follows a write still sees it.
* Single-threaded by contract: open a batch only while no other thread
* touches the transport (the Jaguar3 InitWrite/Init bring-up), and close
* it before any worker thread starts. Defaults are no-ops (PCIe). */
virtual void write_batch_begin() {}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Rebase note: after #415 (90e1cad) there is no IRtlTransportRtlAdapter forwards to devourer::ITransport in src/Transport.h, so these three virtuals need to land there instead, and the IRtlTransport::write_batch_begin references in RtlAdapter.h, HalJaguar3.cpp and src/jaguar3/CLAUDE.md need retargeting. Flagging because it's a real conflict resolution, not a textual one.

virtual void write_batch_end() {}
virtual void flush_writes() {}

/* ---- frame plane ---- */
/* Fire-and-forget data TX (the send_packet hot path). `ep` is the USB
* bulk-OUT endpoint choice; the PCIe transport ignores it (the ring is
Expand Down
193 changes: 193 additions & 0 deletions src/UsbTransport.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -336,6 +336,23 @@ UsbTransport::UsbTransport(libusb_device_handle *dev_handle, Logger_t logger,
}

UsbTransport::~UsbTransport() {
flush_writes();
int leaked = 0;
for (auto *w : _aw_all) {
/* libusb forbids freeing an active transfer, and its callback still
* writes through `w`. If the drain above could not reap it (dead event
* loop / yanked device), leaking the slot is the lesser evil: a callback
* that somehow fires later touches leaked memory, whereas freeing here
* hands libusb a dangling transfer it is still holding. */
if (w->inflight) {
++leaked;
continue;
}
libusb_free_transfer(w->t);
delete w;
}
if (leaked)
_logger->error("USB: leaked {} unreaped pipelined transfer slot(s)", leaked);
/* Backstop only. The device's Stop()/destructor quiesces TX while every
* owner is alive, which is the path that makes teardown safe; reaching the
* transport destructor with transfers still in flight means the caller tore
Expand All @@ -352,7 +369,181 @@ UsbTransport::~UsbTransport() {
quiesce_tx();
}

/* ---- pipelined register writes ------------------------------------------
* Submission order == completion order on EP0, so a pending queue of writes
* followed by a read behaves exactly like the synchronous sequence; the win
* is that the host does not sit through a full URB round trip per write. */
void UsbTransport::write_batch_begin() {
if (_batch)
return;
/* A session that already failed to reap its transfers has a short pool and
* a suspect event loop; stay synchronous rather than pipeline into it. */
if (_aw_abandoned)
return;
if (_aw_all.empty()) {
for (int i = 0; i < kAsyncWriteDepth; ++i) {
auto *w = new AsyncWrite{};
w->t = libusb_alloc_transfer(0);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

libusb_alloc_transfer can return NULL; w->t then flows straight into libusb_fill_control_transfer in async_write/async_read. OOM-only, but a one-line guard here (bail out of the batch, leave _batch false) costs nothing.

w->self = this;
_aw_all.push_back(w);
_aw_free.push_back(w);
}
Comment on lines +383 to +390

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Action required

3. Low-memory bring-up can crash usb setup 🐞 Bug ☼ Reliability

write_batch_begin() inserts each AsyncWrite into both pool vectors without checking whether
libusb_alloc_transfer(0) returned a null t pointer. Under allocation pressure during Jaguar3 TX
initialization, the next batched read or write passes that null transfer to the fill and submit path
instead of falling back to synchronous bring-up or returning a controlled error.
Agent Prompt
## Issue description
The asynchronous slot pool can accept an `AsyncWrite` whose libusb transfer allocation failed, leaving a null transfer for later fill and submission. Do not enter batch mode with an unusable slot.

## Issue Context
Build the pool transactionally and validate every `libusb_alloc_transfer(0)` result before inserting or exposing a slot. If any allocation fails, release all partially allocated batch slots and leave batching disabled so register access falls back to the synchronous path, or return a controlled error. Other transfer-allocation paths in this transport already check for null before filling or submitting a transfer.

## Fix Focus Areas
- src/UsbTransport.cpp[376-394]
- src/UsbTransport.cpp[439-469]
- src/UsbTransport.cpp[518-539]
- src/UsbTransport.cpp[608-627]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

}
_aw_errors = 0;
_batch = true;
}

void UsbTransport::write_batch_end() {
flush_writes();
if (_batch && _aw_errors)
_logger->error("USB: {} pipelined register write(s) failed in this batch",
_aw_errors);
_batch = false;
}

void LIBUSB_CALL UsbTransport::async_write_cb(libusb_transfer *t) {
auto *w = static_cast<AsyncWrite *>(t->user_data);
UsbTransport *self = w->self;
self->_aw_inflight--;
self->_aw_completed++;
w->inflight = false;
w->done = true;
w->status = t->status;
w->actual = t->actual_length;
if (t->status != LIBUSB_TRANSFER_COMPLETED ||
t->actual_length != t->length - LIBUSB_CONTROL_SETUP_SIZE)
self->_aw_errors++;
/* The slot goes back on the free list here; a reader that is waiting on
* this very slot copies its data out before it submits anything else
* (single-threaded by contract), so the buffer is still intact. */
self->_aw_free.push_back(w);
}

UsbTransport::AsyncWrite *UsbTransport::async_take_slot() {
while (_aw_free.empty()) {
if (!async_wait_progress()) {
flush_writes(); /* recovers the pool on a stuck queue */
if (_aw_free.empty())
return nullptr;
}
}
AsyncWrite *w = _aw_free.back();
_aw_free.pop_back();
w->done = false;
w->inflight = false;
w->status = -1;
w->actual = 0;
return w;
}

bool UsbTransport::async_read(uint16_t wvalue, uint16_t windex, void *data,
size_t n) {
if (n > kAsyncMaxPayload)
return false;
AsyncWrite *w = async_take_slot();
if (!w)
return false;
libusb_fill_control_setup(w->buf, REALTEK_USB_VENQT_READ, 5, wvalue, windex,
static_cast<uint16_t>(n));
libusb_fill_control_transfer(w->t, _dev_handle, w->buf, async_write_cb, w,
USB_TIMEOUT);
const int rc = libusb_submit_transfer(w->t);
if (rc != 0) {
_aw_free.push_back(w);
_aw_errors++;
_logger->error("USB: pipelined read submit failed ({})", rc);
return false;
}
w->inflight = true;
_aw_inflight++;
while (!w->done) {
if (!async_wait_progress()) {
flush_writes(); /* cancels + recovers; w->done is set by the cancel */
break;
}
}
if (!w->done || w->status != LIBUSB_TRANSFER_COMPLETED ||
w->actual != static_cast<int>(n))
return false;
std::memcpy(data, w->buf + LIBUSB_CONTROL_SETUP_SIZE, n);
return true;
}

bool UsbTransport::async_wait_progress() {
const uint64_t before = _aw_completed;
for (int turns = 0; turns < 8 && _aw_completed == before; ++turns) {
struct timeval tv {0, 250 * 1000};
const int rc = libusb_handle_events_timeout_completed(_ctx, &tv, nullptr);
if (rc < 0) {
_logger->error("USB: event loop error {} while draining pipelined writes",
rc);
return false;
}
}
return _aw_completed != before;
}

void UsbTransport::flush_writes() {
while (_aw_inflight > 0) {
if (async_wait_progress())
continue;
/* Stuck queue (~2 s without a completion): cancel what is still submitted

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor: with USB_TIMEOUT at 500 ms (UsbTransport.h:28), libusb times a stuck transfer out long before 2 s and completes it through the callback. This path is really only reachable when the event loop isn't being pumped at all — which is what the code below handles correctly, but the "~2 s without a completion" framing points at the wrong cause.

* so the caller's next synchronous transfer is not queued behind it. */
_logger->error("USB: pipelined write drain timed out ({} in flight)",
_aw_inflight);
for (auto *w : _aw_all)
if (w->inflight)
libusb_cancel_transfer(w->t);
/* A cancellation still completes through the callback, which is what
* clears `inflight` and returns the slot. Keep pumping for it: the count
* must never be zeroed by hand, or a late callback decrements it below
* zero (silently disabling every later drain), pushes its slot onto the
* free list a second time, and races the destructor's free. */
for (int i = 0; i < kFlushCancelTurns && _aw_inflight > 0; ++i)
async_wait_progress();
if (_aw_inflight > 0) {
/* The event loop itself is gone (a yanked device reports the error
* immediately, so the turns above cost nothing). Leave the slots
* submitted and off the free list; the destructor leaks them. */
_aw_errors += _aw_inflight;
_aw_abandoned = true;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

_aw_abandoned is only ever read in write_batch_begin, so setting it here doesn't affect the batch that is currently open. _batch stays true, and the retired slots keep inflight == true, so _aw_inflight > 0 forever.

Every subsequent register access then walks async_take_slot -> async_wait_progress (8 x 250 ms) -> flush_writes (another ~2 s plus the cancel turns) -> nullptr. That's roughly 4 s per register for the remainder of a bring-up that issues thousands of them.

The trigger is narrow — USB_TIMEOUT is 500 ms so libusb normally completes stuck transfers itself, and a genuinely dead loop fails async_wait_progress immediately — but the fix is one line: set _batch = false here so the batch degrades to synchronous for the rest of the session, which is what the comment above already describes as the intent.

_logger->error("USB: {} pipelined transfer(s) could not be reaped; "
"their slots are retired for this session",
_aw_inflight);
}
Comment on lines +505 to +513

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Action required

2. Usb teardown can access freed memory 🐞 Bug ☼ Reliability

flush_writes() can mark submitted control transfers abandoned and return, while each unreaped
AsyncWrite retains a raw UsbTransport *self that async_write_cb() uses to update transport
counters and its free-list vector. If cancellation callbacks cannot be pumped before destruction but
a shared live libusb context later dispatches the transfer, the leaked slot survives while its
transport does not, and the callback dereferences freed transport state.
Agent Prompt
## Issue description
Abandoned asynchronous control transfers retain a callback pointer to `UsbTransport`, but the destructor may complete while libusb still owns those transfers. A later callback can then access destroyed transport counters and free-list state.

## Issue Context
Leaking an unreaped transfer slot preserves its transfer and buffer memory, but it does not preserve the `UsbTransport` referenced by `AsyncWrite::self`. Make callback-owned bookkeeping independent of the transport lifetime, or otherwise guarantee that every submitted transfer is reaped and its callback cannot execute after transport destruction, including when another adapter sharing the process continues pumping the libusb context.

## Fix Focus Areas
- src/UsbTransport.cpp[338-355]
- src/UsbTransport.cpp[404-420]
- src/UsbTransport.cpp[486-515]
- src/UsbTransport.h[107-140]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

return;
}
}

bool UsbTransport::async_write(uint16_t wvalue, uint16_t windex,
const void *data, size_t n) {
if (n > kAsyncMaxPayload)
return false;
AsyncWrite *w = async_take_slot();
if (!w)
return false;
libusb_fill_control_setup(w->buf, REALTEK_USB_VENQT_WRITE, 5, wvalue, windex,
static_cast<uint16_t>(n));
std::memcpy(w->buf + LIBUSB_CONTROL_SETUP_SIZE, data, n);
libusb_fill_control_transfer(w->t, _dev_handle, w->buf, async_write_cb, w,
USB_TIMEOUT);
const int rc = libusb_submit_transfer(w->t);
if (rc != 0) {
_aw_free.push_back(w);
_aw_errors++;
_logger->error("USB: pipelined write submit failed ({})", rc);
return false;
}
w->inflight = true;
_aw_inflight++;
return true;
}

bool UsbTransport::write_bytes(uint16_t reg_num, const uint8_t *ptr, size_t n) {
/* A vendor control transfer like any other -- counted so an InitTimer stage
* that downloads firmware this way reports what it actually spent. */
usb_ctrl_xfers().fetch_add(1, std::memory_order_relaxed);
flush_writes();
return libusb_control_transfer(_dev_handle, REALTEK_USB_VENQT_WRITE, 5,
reg_num, 0, const_cast<uint8_t *>(ptr), n,
USB_TIMEOUT) == static_cast<int>(n);
Expand Down Expand Up @@ -854,6 +1045,7 @@ void UsbTransport::quiesce_tx() {

bool UsbTransport::tx_async(uint8_t tx_ep, uint8_t *packet, size_t length,
unsigned timeout_ms) {
flush_writes();
/* Reap completed async-TX transfers before submitting the next one — in the
* caller's own thread, so there is no background pump to race libusb
* teardown. A non-blocking handle_events (timeout 0) processes every ready
Expand Down Expand Up @@ -1001,6 +1193,7 @@ bool UsbTransport::tx_async(uint8_t tx_ep, uint8_t *packet, size_t length,

int UsbTransport::tx_sync(uint8_t ep, uint8_t *packet, size_t length,
int timeout_ms) {
flush_writes();
/* No libusb_clear_halt here. rtw88_8814au's usbmon shows the first bulk
* OUT is preceded by 0 CLEAR_FEATUREs; later CLEAR_FEATUREs happen during
* normal TX-queue operation, not the per-send hot path. Resetting the
Expand Down
Loading