@@ -65,11 +65,11 @@ typedef unsigned char byte_t;
6565#define FOREACH_PRIO (i ) for (size_t i = 0; (i) < CANARD_PRIO_COUNT; (i)++)
6666
6767#if CANARD_IFACE_COUNT <= 2
68- #define IFACE_INDEX_BIT_LENGTH 1U
68+ #define IFACE_INDEX_BITS 1U
6969#elif CANARD_IFACE_COUNT <= 4
70- #define IFACE_INDEX_BIT_LENGTH 2U
70+ #define IFACE_INDEX_BITS 2U
7171#elif CANARD_IFACE_COUNT <= 8
72- #define IFACE_INDEX_BIT_LENGTH 3U
72+ #define IFACE_INDEX_BITS 3U
7373#else
7474#error "Too many interfaces"
7575#endif
@@ -424,7 +424,7 @@ typedef struct tx_transfer_t
424424 canard_us_t deadline ;
425425 uint64_t seqno ;
426426 uint64_t can_id_msb : CAN_ID_MSb_BITS ;
427- uint64_t transfer_id : CANARD_TRANSFER_ID_BIT_LENGTH ; // TODO remove, not needed.
427+ uint64_t transfer_id : CANARD_TRANSFER_ID_BITS ; // TODO remove, not needed.
428428 uint64_t fd : 1 ;
429429
430430 // Mutable transmission state. All other fields, except for the index handles, are immutable.
@@ -1189,8 +1189,8 @@ typedef struct
11891189 canard_us_t start_ts ;
11901190 uint32_t total_size ; // The raw payload size seen before the implicit truncation and CRC removal.
11911191 uint16_t crc ;
1192- byte_t transfer_id : CANARD_TRANSFER_ID_BIT_LENGTH ;
1193- byte_t iface_index : IFACE_INDEX_BIT_LENGTH ;
1192+ byte_t transfer_id : CANARD_TRANSFER_ID_BITS ;
1193+ byte_t iface_index : IFACE_INDEX_BITS ;
11941194 byte_t expected_toggle : 1 ;
11951195 byte_t payload []; // Extent-sized.
11961196} rx_slot_t ;
@@ -1209,7 +1209,7 @@ static rx_slot_t* rx_slot_new(const canard_subscription_t* const sub,
12091209 slot -> crc = sub -> crc_seed ;
12101210 slot -> transfer_id = transfer_id & CANARD_TRANSFER_ID_MAX ;
12111211 slot -> expected_toggle = kind_is_v1 (sub -> kind ) ? 1 : 0 ;
1212- slot -> iface_index = iface_index & ((1U << IFACE_INDEX_BIT_LENGTH ) - 1U );
1212+ slot -> iface_index = iface_index & ((1U << IFACE_INDEX_BITS ) - 1U );
12131213 }
12141214 return slot ;
12151215}
@@ -1229,48 +1229,6 @@ static void rx_slot_advance(rx_slot_t* const slot, const size_t extent, const ca
12291229 slot -> expected_toggle ^= 1U ;
12301230}
12311231
1232- // This value is unreachable even if seqno is incremented by full transfer-ID period every frame at 5k frames/s;
1233- // at that rate, it would take about 56 years to wrap around.
1234- #define RX_SEQNO_MAX ((UINT64_C(1) << 48U) - 1U)
1235-
1236- // A compact representation is needed because we need to store an array of these in dynamic memory.
1237- typedef struct
1238- {
1239- uint16_t limbs [3 ];
1240- } rx_seqno_packed_t ;
1241-
1242- static uint64_t rx_seqno_unpack (const rx_seqno_packed_t v )
1243- {
1244- return ((uint64_t )v .limbs [0 ]) | (((uint64_t )v .limbs [1 ]) << 16U ) | (((uint64_t )v .limbs [2 ]) << 32U );
1245- }
1246- static rx_seqno_packed_t rx_seqno_pack (const uint64_t v )
1247- {
1248- return (rx_seqno_packed_t ){
1249- { (uint16_t )(v & 0xFFFFU ), (uint16_t )((v >> 16U ) & 0xFFFFU ), (uint16_t )((v >> 32U ) & 0xFFFFU ) }
1250- };
1251- }
1252-
1253- // The linearizer logic results in a behavior that differs compared to v4 significantly: v4 used to admit any
1254- // transfer-ID except the same and the one older compared to the last transfer; in terms of the linearization logic it
1255- // means that the reference point was biased.
1256- static uint64_t rx_seqno_linearize (const uint64_t ref_seqno , const byte_t transfer_id )
1257- {
1258- CANARD_ASSERT (transfer_id <= CANARD_TRANSFER_ID_MAX );
1259- const byte_t ref_tid = (byte_t )(ref_seqno & CANARD_TRANSFER_ID_MAX );
1260- int16_t delta = ((int16_t )transfer_id ) - ((int16_t )ref_tid ); // NOLINT(*-narrowing-conversions)
1261- // Select the nearest congruent seqno in the half-open interval [-16, +16) around the reference.
1262- if (delta > (int16_t )(CANARD_TRANSFER_ID_MAX / 2U )) {
1263- delta -= (int16_t )CANARD_TRANSFER_ID_MODULO ;
1264- } else if (delta < - ((int16_t )(CANARD_TRANSFER_ID_MAX / 2U ) + 1 )) {
1265- delta += (int16_t )CANARD_TRANSFER_ID_MODULO ;
1266- }
1267- // Near the origin the backward representative may underflow, so use the next forward one instead.
1268- if ((delta < 0 ) && (ref_seqno < (uint64_t )(- delta ))) {
1269- delta += (int16_t )CANARD_TRANSFER_ID_MODULO ;
1270- }
1271- return (uint64_t )(((int64_t )ref_seqno ) + ((int64_t )delta ));
1272- }
1273-
12741232// Up to libcanard v4 we used a fixed-capacity array of pointers for per-remote sessions for constant-time lookup,
12751233// but it was too costly on MCUs: with a 32-bit pointer it took 512 bytes for the array plus overheads,
12761234// resulting in 1 KiB o1heap blocks per session, very expensive. Here we use a much less RAM-heavy approach with
@@ -1305,9 +1263,10 @@ typedef struct
13051263 canard_us_t last_admission_ts ;
13061264 rx_slot_t * slots [CANARD_PRIO_COUNT ]; // Indexed by priority level to allow preemption.
13071265 canard_subscription_t * owner ;
1308- rx_seqno_packed_t seqno_frontier [CANARD_PRIO_COUNT ];
1309- byte_t iface_index ;
13101266 byte_t node_id ;
1267+ byte_t last_admitted_transfer_id : CANARD_TRANSFER_ID_BITS ;
1268+ byte_t last_admitted_priority : CANARD_PRIO_BITS ;
1269+ byte_t iface_index : IFACE_INDEX_BITS ;
13111270} rx_session_t ;
13121271static_assert ((sizeof (void * ) > 4 ) || (sizeof (rx_session_t ) <= 120 ), "too large" );
13131272
@@ -1375,109 +1334,87 @@ static size_t rx_session_cleanup(rx_session_t* const ses, const canard_us_t now)
13751334 return n_slots ;
13761335}
13771336
1378- // Maximum seqno seen from the given highest priority level (numerically lowest, inclusive) and down.
1379- static uint64_t rx_session_seqno_frontier (const rx_session_t * const ses , const canard_prio_t highest_priority )
1380- {
1381- uint64_t seqno = 0 ;
1382- for (size_t i = (size_t )highest_priority ; i < CANARD_PRIO_COUNT ; i ++ ) {
1383- seqno = max_u64 (seqno , rx_seqno_unpack (ses -> seqno_frontier [i ]));
1384- }
1385- return seqno ;
1386- }
1387-
13881337static void rx_session_record_admission (rx_session_t * const ses ,
13891338 const canard_prio_t priority ,
1390- const uint64_t seqno ,
1339+ const byte_t transfer_id ,
13911340 const canard_us_t ts ,
13921341 const byte_t iface_index )
13931342{
1394- // Seqno per priority cannot go back. When we reset it on a transfer-ID timeout, we simply bump the seqno state by
1395- // a multiple of transfer-ID overflow periods to ensure monotonicity. Earlier I tried dumb direct approaches
1396- // where we erase the seqno states to zero on tid-timeout, or introduce epoch counters, or per-seqno timestamp,
1397- // but this solution is so much simpler while achieves the same goal: leave older seqnos behind, start new epoch.
1398- CANARD_ASSERT (seqno > rx_seqno_unpack (ses -> seqno_frontier [priority ]));
1399- ses -> seqno_frontier [priority ] = rx_seqno_pack (seqno ); // nothing older than this at this & higher prio from now on
1400- ses -> last_admission_ts = ts ;
1401- ses -> iface_index = iface_index ;
1402- }
1403-
1404- #define RX_SESSION_ADMISSION_REJECTED (UINT64_MAX)
1405- #define RX_SESSION_ADMISSION_CONTINUATION (UINT64_MAX - 1)
1406-
1407- // Frame admittance solver. A complex piece, redesigned after v4 to support priority preemption.
1408- // Key ideas: 1. Separate reassembly state per priority level. 2. TID is linearized into seqno.
1409- // Once we admit a transfer at some priority with a certain seqno, we know that any older seqno at this or higher
1410- // priority would be stale, since only higher priority transfers can preempt lower priority ones.
1411- // On a transfer-ID timeout the seqno is bumped by a full transfer-ID timeout to ensure that it becomes the new
1412- // frontier matching the current transfer-ID while maintaining monotonicity.
1413- static uint64_t rx_session_solve_admission (const rx_session_t * const ses ,
1414- const canard_us_t ts ,
1415- const canard_prio_t priority ,
1416- const bool start ,
1417- const bool toggle ,
1418- const byte_t transfer_id ,
1419- const byte_t iface_index )
1343+ ses -> last_admission_ts = ts ;
1344+ ses -> last_admitted_transfer_id = transfer_id & CANARD_TRANSFER_ID_MAX ;
1345+ ses -> last_admitted_priority = ((byte_t )priority ) & CANARD_PRIO_BITS ;
1346+ ses -> iface_index = iface_index & ((1U << IFACE_INDEX_BITS ) - 1U );
1347+ }
1348+
1349+ // Frame admittance solver. A complex piece, redesigned after v4 to support priority preemption & parallel reassembly.
1350+ static bool rx_session_solve_admission (const rx_session_t * const ses ,
1351+ const canard_us_t ts ,
1352+ const canard_prio_t priority ,
1353+ const bool start ,
1354+ const bool end ,
1355+ const bool toggle ,
1356+ const byte_t transfer_id ,
1357+ const byte_t iface_index )
14201358{
14211359 // Continuation frames cannot create new state so their handling is simpler.
14221360 // They are only accepted if there is a slot with an exact match of all transfer parameters.
14231361 // We ignore the transfer-ID timeout to avoid breaking transfers that are preempted for a long time,
14241362 // and especially to allow reassembly of multi-frame transfers even when the transfer-ID timeout is zero.
14251363 if (!start ) {
14261364 const rx_slot_t * const slot = ses -> slots [priority ];
1427- const bool admit = (slot != NULL ) && (slot -> transfer_id == transfer_id ) && (slot -> iface_index == iface_index ) &&
1428- (slot -> expected_toggle == toggle );
1429- return admit ? RX_SESSION_ADMISSION_CONTINUATION : RX_SESSION_ADMISSION_REJECTED ;
1365+ return (slot != NULL ) && (slot -> transfer_id == transfer_id ) && (slot -> iface_index == iface_index ) &&
1366+ (slot -> expected_toggle == toggle );
14301367 }
1431-
1432- // It is best to postpone seqno derivation until the last moment because it is costly.
1433- // Life would have been so much easier if we could just use normal non-wrapping IDs like we have in Cyphal/UDP!
1434- const uint64_t frontier_global = rx_session_seqno_frontier (ses , canard_prio_exceptional );
1435- const uint64_t seqno = rx_seqno_linearize (frontier_global , transfer_id );
1436-
14371368 // Duplicate start frames do not require special treatment because a duplicate frame can only follow the original
14381369 // without any frames belonging to the same transfer in between (see the assumptions). If we get a duplicate start,
14391370 // with a nonzero TID timeout it will be rejected as not-new; even if the timeout is zero, accepting the duplicate
14401371 // will simply restart the slot without loss of information since there were no other frames in between.
14411372 //
1442- // In the case of a single priority level, the seqno would be considered new if it is greater than last admitted.
1443- // Priority preemption makes this simple condition insufficient, because a newer higher-priority transfer with
1444- // a greater seqno may push older lower-priority transfers aside, causing the receiver to observe seqno going
1445- // backward for new transfers. Our solution is to keep a dedicated seqno frontier per priority level.
1446- // The lowest bound of a genuinely new seqno would then be located at the lowest priority level, since that level
1447- // cannot preempt others. To decide if a given seqno is new, one needs to scan all priority levels from the
1448- // current one (inclusive) down to the lowest level (numerically greater).
1449- // Higher priority levels (numerically lesser) may have greater seqno frontiers which bear no relevance.
1450- //
1451- // The original design had a special case that enabled admittance of a transfer with the next seqno from a
1373+ // The original design had a special case that enabled admittance of a transfer with the next transfer-ID from a
14521374 // different interface if there is no pending reassembly in progress; see details here and the original v4 code:
14531375 // https://github.com/OpenCyphal/libcanard/issues/228. This behavior is no longer present here mostly because we
14541376 // now update the session state only on admittance and not upon completion of a transfer, which changes the logic
14551377 // considerably. One side effect is that even after a timeout (potentially a very long time as long as the session
14561378 // survives) we will still reject a new transfer arriving from a different interface if it happened to roll the
1457- // same transfer-ID timeout . This is not an issue because we would still accept new transfers on the same iface,
1379+ // same transfer-ID. This is not an issue because we would still accept new transfers on the same iface,
14581380 // and after the RX_SESSION_TIMEOUT the session is destroyed and all new transfers will be accepted unconditionally.
1459- const uint64_t frontier_priority = rx_session_seqno_frontier (ses , priority );
1460- const bool seqno_new = seqno > frontier_priority ;
1461- const bool iface_match = ses -> iface_index == iface_index ;
1462- const bool timed_out = ts > (ses -> last_admission_ts + ses -> owner -> transfer_id_timeout );
1463- const bool admit = (seqno_new && iface_match ) || (iface_match && timed_out ) || (timed_out && seqno_new );
1464- if (!admit ) {
1465- return RX_SESSION_ADMISSION_REJECTED ;
1466- }
1467-
1468- // It is vital that seqno is monotonically increasing even across timeouts, otherwise following a transfer-ID
1469- // timeout further arrivals on higher priority levels may be rejected if their seqnos are greater.
1470- // Instead of sweeping seqnos across all priority levels, we ensure monotonicity without breaking transfer-ID
1471- // matching by bumping the seqno by the minimal required number of overflow periods.
1472- uint64_t admitted_seqno = seqno ;
1473- if (!seqno_new ) {
1474- static const uint64_t mod = CANARD_TRANSFER_ID_MODULO ;
1475- CANARD_ASSERT (frontier_priority >= admitted_seqno );
1476- const uint64_t periods = ((frontier_priority - admitted_seqno ) + mod ) / mod ;
1477- admitted_seqno += periods * mod ;
1478- }
1479- CANARD_ASSERT (admitted_seqno > frontier_priority );
1480- return admitted_seqno ;
1381+ //
1382+ // Merely comparing against the last admitted transfer-ID is not enough because there is a preemption-related edge
1383+ // case. Suppose the transfer-ID modulo is 4, i.e., the values are {0, 1, 2, 3}; a low-priority transfer is sent
1384+ // but preempted by higher-priority peers before it hits the bus as illustrated below.
1385+ //
1386+ // |SENDER |
1387+ // P=low |0 |
1388+ // P=high | 1 2 3 0 | <-- preempt the low-priority frame
1389+ //
1390+ // |RECEIVER |
1391+ // P=low | 0 | <-- arrived late, rejected as duplicate!
1392+ // P=high | 1 2 3 0 |
1393+ //
1394+ // One solution is to unroll the transfer-ID sequence locally to track wraparounds:
1395+ //
1396+ // |RECEIVER (unrolling) |
1397+ // P=low | 5 | <-- thanks to unrolling we observe this as not a duplicate
1398+ // P=high | 1 2 3 4 | <-- unrolled
1399+ //
1400+ // Another solution that is much simpler but yields identical behavior is to track the last admitted priority
1401+ // along with the transfer-ID: preemption alters the admitted priority, which prevents false rejection.
1402+ //
1403+ // However, both solutions have a critical edge case: if a low-priority frame is duplicated (due to the CAN ACK
1404+ // glitch effect) such that at least one higher-priority frame is admitted between the original frame and the
1405+ // duplicate, then the duplicate will be accepted as a new transfer, which is unsound. Even though the ACK glitch
1406+ // is an incredibly rare phenomenon, we prefer the risk of NOT accepting a valid transfer rather than accepting
1407+ // a transfer twice, because failure to deduplicate breaks strong protocol invariants.
1408+ //
1409+ // There is one case though where we can admit a duplicate without breaking invariants, which is simply when the
1410+ // admitted start is part of a multi-frame transfer, i.e., is not the last one. In this case, the worst outcome
1411+ // is that we restart the slot, without affecting the application, so it has no real downsides (see above).
1412+ const bool multiframe = !end ;
1413+ const bool fresh = (transfer_id != ses -> last_admitted_transfer_id ) || // always accept if transfer-ID is different
1414+ ((priority != ses -> last_admitted_priority ) && multiframe ); // CAN ack glitch handling improvement
1415+ const bool affine = ses -> iface_index == iface_index ;
1416+ const bool timeout = ts > (ses -> last_admission_ts + ses -> owner -> transfer_id_timeout );
1417+ return (fresh && affine ) || (affine && timeout ) || (timeout && fresh );
14811418}
14821419
14831420// Returns false on OOM, no other failure modes.
@@ -1514,19 +1451,23 @@ static bool rx_session_update(canard_subscription_t* const sub,
15141451 }
15151452
15161453 // Decide admit or drop.
1517- const uint64_t seqno = rx_session_solve_admission (
1518- ses , ts , frame -> priority , frame -> start , frame -> toggle , frame -> transfer_id , iface_index );
1519- if (seqno == RX_SESSION_ADMISSION_REJECTED ) {
1454+ const bool admit = rx_session_solve_admission (ses , //
1455+ ts ,
1456+ frame -> priority ,
1457+ frame -> start ,
1458+ frame -> end ,
1459+ frame -> toggle ,
1460+ frame -> transfer_id ,
1461+ iface_index );
1462+ if (!admit ) {
15201463 return true; // Rejection is not a failure.
15211464 }
15221465
15231466 // The frame must be accepted. If this is the start of a new transfer, we must update state.
1524- if (seqno != RX_SESSION_ADMISSION_CONTINUATION ) {
1525- CANARD_ASSERT (seqno <= RX_SEQNO_MAX );
1526- CANARD_ASSERT (frame -> start );
1467+ if (frame -> start ) {
15271468 // Animate only when a new transfer is started to manage load. Correctness-wise there is not much difference.
15281469 enlist_tail (& sub -> owner -> rx .list_session_by_animation , & ses -> list_animation );
1529- // Destroy the old slot if it exists (if we're discarding a stale transfer) .
1470+ // Destroy the old slot if it exists, meaning we're discarding stale transfer.
15301471 if (ses -> slots [frame -> priority ] != NULL ) {
15311472 rx_slot_destroy (sub , ses -> slots [frame -> priority ]);
15321473 ses -> slots [frame -> priority ] = NULL ;
@@ -1541,7 +1482,7 @@ static bool rx_session_update(canard_subscription_t* const sub,
15411482 }
15421483 }
15431484 // Register the new state only after we have a confirmation that we have memory to store the frame.
1544- rx_session_record_admission (ses , frame -> priority , seqno , ts , iface_index );
1485+ rx_session_record_admission (ses , frame -> priority , frame -> transfer_id , ts , iface_index );
15451486 }
15461487
15471488 // TODO acceptance is not yet implemented.
0 commit comments