fix: a full reorder ring keeps the arriving packet and counts the overflow, instead of dropping it silently

A packet that needed a slot and found none was neither held nor delivered: no
counter recorded it and the consumer reserved a hole nothing could ever backfill.
It now commits the gap to free a slot and takes the ordering decision once more
against the sequence that commit left behind, bounded to one retry; past that it
is delivered in order with the counters it skipped as its `lost`, never flagged
late, because a packet ahead of the sequence has no hole to be looked up by. A
duplicate can now replace its slot at a full ring, as hold_packet's own comment
already promised, and the 20 ms default window is a named constant.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Martino FerrariandClaude Opus 4.6 committed 2026-10-09 06:50:43 +02:00
1 parent cb1bbd2b7b
commit 176ef1df9f
6 files changed
+113 -38

No files matched your search

+79 -33
View File
@@ -37,6 +37,8 @@
#define UDPS_REORDER_SLOTS 64
/** Total bytes the reorder ring may hold, so a fast stream cannot grow it without bound. */
#define UDPS_REORDER_BYTES (4u * 1024u * 1024u)
/** How long a gap is given to resolve before it is committed, by default. */
#define UDPS_REORDER_WINDOW_S_DEFAULT 0.020
/** Concurrent in-flight reassemblies. The server interleaves at most a couple. */
#define UDPS_MAX_SLOTS 4
@@ -576,20 +578,16 @@ static int deliver_data(udps_client_t *c, const uint8_t *pl, size_t len,
*
* A duplicate counter (same datagram arriving twice on the wire) replaces the
* existing slot so one counter never consumes two slots and both get delivered.
* The search for that slot therefore runs *before* the capacity checks, and the
* checks are applied only on the path that needs a new slot: a duplicate at a
* full ring consumes nothing and must still be accepted.
* Returns non-zero and holds nothing when there is no room (ring full or byte
* budget exhausted). */
* budget exhausted); `order_data` below says what the caller then does. */
static int hold_packet(udps_client_t *c, const uint8_t *pl, size_t len,
uint32_t counter, double recv_time) {
int i;
int slot = -1;
if (c->held_count >= UDPS_REORDER_SLOTS) {
return -1;
}
if (c->held_bytes + len > UDPS_REORDER_BYTES) {
return -1;
}
/* Duplicate counter: reuse the existing slot to avoid double delivery. */
for (i = 0; i < UDPS_REORDER_SLOTS; i++) {
if (c->held[i].used && c->held[i].counter == counter) {
@@ -597,16 +595,28 @@ static int hold_packet(udps_client_t *c, const uint8_t *pl, size_t len,
break;
}
}
if (slot < 0) {
if (slot >= 0) {
/* The replacement releases the length already held, so the budget is
* weighed against what the slot will cost rather than what it adds. */
if (c->held_bytes - c->held[slot].len + len > UDPS_REORDER_BYTES) {
return -1;
}
} else {
if (c->held_count >= UDPS_REORDER_SLOTS) {
return -1;
}
if (c->held_bytes + len > UDPS_REORDER_BYTES) {
return -1;
}
for (i = 0; i < UDPS_REORDER_SLOTS; i++) {
if (!c->held[i].used) {
slot = i;
break;
}
}
}
if (slot < 0) {
return -1;
if (slot < 0) {
return -1;
}
}
if (ensure_cap((void **)&c->held[slot].buf, &c->held[slot].cap, len) != 0) {
@@ -717,10 +727,27 @@ static int commit_gap(udps_client_t *c) {
* milliseconds, and holding is what turns it back into order. A packet behind the
* sequence lost that race: the consumer already has a hole where it belongs, so it
* is delivered flagged rather than discarded, which is the whole point -- a dropped
* packet cannot fill the hole it left. */
* packet cannot fill the hole it left.
*
* The full ring is specified here, in one place. A packet that needs a slot and
* finds none gives up on the current gap -- `commit_gap()` delivers the lowest held
* run and so frees at least one slot -- counts the event in
* `stats.reorder_overflows`, and the decision is then taken again, once, against
* the sequence that commit left behind. That re-decision is why this is a bounded
* loop and not a straight line: the commit advances `next_counter` to the lowest
* held counter, which may put it past the arriving packet, and the packet is then
* behind the sequence rather than ahead of it. Whatever happens, the arriving
* packet is held or delivered; it is never dropped, because nothing else records it
* and the consumer would reserve a hole for it that nothing could ever backfill.
* Past the cap it is delivered *in order* and not flagged late: a packet ahead of
* the sequence has no reserved hole, the consumer looks a late packet's hole up by
* counter and ignores one it cannot place, so advancing `next_counter` to it and
* reporting the counters it skipped as `lost` is what reserves their hole. */
static int order_data(udps_client_t *c, const uint8_t *pl, size_t len,
uint32_t counter, double recv_time) {
int32_t delta;
int attempt;
int rc;
if (c->cfg.reorder_window_s <= 0.0) {
/* Resequencing off: byte-for-byte the behaviour before this task. `delta`
@@ -744,27 +771,46 @@ static int order_data(udps_client_t *c, const uint8_t *pl, size_t len,
c->next_counter = counter + 1u;
return deliver_data(c, pl, len, counter, recv_time, 0u, 0);
}
for (attempt = 0; attempt < 2; attempt++) {
delta = (int32_t)(counter - c->next_counter);
if (delta == 0) {
rc = deliver_data(c, pl, len, counter, recv_time, 0u, 0);
c->next_counter = counter + 1u;
if (rc == 0) { rc = drain_held(c, 0u); }
return rc;
}
if (delta < 0) {
/* Behind the sequence. */
c->stats.late_delivered++;
return deliver_data(c, pl, len, counter, recv_time, 0u, 1);
}
if (hold_packet(c, pl, len, counter, recv_time) == 0) {
if (c->gap_deadline == 0.0) {
c->gap_deadline = recv_time + c->cfg.reorder_window_s;
}
return 0;
}
/* No room: give up on the gap rather than growing the ring, and say so
* in a counter, so an undersized window is a reading and not a mystery. */
c->stats.reorder_overflows++;
rc = commit_gap(c);
if (rc != 0) {
/* The consumer refused a delivery; nothing is freed and nothing more
* may be delivered, so the arriving packet is its caller's problem. */
return rc;
}
}
/* The attempt cap is spent and the ring still has no room (the byte budget
* cannot fit this one even with a slot free). Deliver it rather than drop it. */
delta = (int32_t)(counter - c->next_counter);
if (delta == 0) {
int rc = deliver_data(c, pl, len, counter, recv_time, 0u, 0);
c->next_counter = counter + 1u;
if (rc == 0) { rc = drain_held(c, 0u); }
return rc;
if (delta < 0) {
c->stats.late_delivered++;
return deliver_data(c, pl, len, counter, recv_time, 0u, 1);
}
if (delta > 0) {
if (hold_packet(c, pl, len, counter, recv_time) != 0) {
/* No room: commit the gap now rather than growing. The counter is
* visible, so an undersized window is a reading and not a mystery. */
return commit_gap(c);
}
if (c->gap_deadline == 0.0) {
c->gap_deadline = recv_time + c->cfg.reorder_window_s;
}
return 0;
}
/* delta < 0: behind the sequence. */
c->stats.late_delivered++;
return deliver_data(c, pl, len, counter, recv_time, 0u, 1);
rc = deliver_data(c, pl, len, counter, recv_time, (uint32_t)delta, 0);
c->next_counter = counter + 1u;
if (rc == 0) { rc = drain_held(c, 0u); }
return rc;
}
/*---------------------------------------------------------------------------*/
@@ -1187,7 +1233,7 @@ void udps_client_config_init(udps_client_config_t *cfg) {
cfg->keepalive_interval_s = 15.0; /* server evicts silent clients at 30 s */
cfg->recv_buffer_bytes = 4u * 1024u * 1024u;
cfg->max_packet_bytes = 1u * 1024u * 1024u;
cfg->reorder_window_s = 0.020;
cfg->reorder_window_s = UDPS_REORDER_WINDOW_S_DEFAULT;
}
static void copy_str(char *dst, size_t cap, const char *src) {
+9
View File
@@ -202,6 +202,15 @@ typedef struct {
* before the window existed they were counted in @c stale_packets and dropped.
*/
uint64_t late_delivered;
/**
* Times a DATA packet needed a slot in the reorder ring and found none, so the
* current gap was committed early to make room. Every such packet is still held
* or delivered -- none is dropped -- but the window it was given was shorter
* than @c reorder_window_s asked for. A non-zero reading says the ring is
* undersized for the stream's reordering depth, which is a reading and not a
* mystery; it is the only place that shows up.
*/
uint64_t reorder_overflows;
uint64_t reconnects;
} udps_stats_t;