Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

gnrc/ipv6/nib: don't queue packets on 6lo neighbors and drop/flush if… #20834

Merged

Conversation

derMihai
Copy link
Contributor

@derMihai derMihai commented Aug 26, 2024

Contribution description

This following fix only applies for CONFIG_GNRC_IPV6_NIB_QUEUE_PKT == 1 (default config).

This PR is an alternative solution for #20781:

There is currently the case that a on-link neighbor's packet queue might never be emptied. Specifically: if a neighbor is unreachable, the packets queued to be later send (i.e. once the neighbor is resolved) will never be de-queued if the neighbor is never resolved. On a typical link (e.g. ethernet), this is less of a problem, as the NIB subsystem actively tries to resolve the host, and in case of failure the neighbor entry is removed and the packets in it's queue get released. However, on e.g. 6LoWPANs, there is no active discovery going on, so the code path for removing the neighbor never gets executed.

This PR adds following changes:

  • packets are not enqueued on 6lo neighbors, as this is not required by specification: https://www.rfc-editor.org/rfc/rfc6775.html#section-5.6
  • once a neighbor's status switches to UNREACHABLE, flush it's packet queue
  • for UNREACHABLE neighbors: drop packets instead of queuing
  • neighbor packet queue length is capped

Tests

Spun up this thread:

void *udp_send(void *arg)
{
    (void)arg;

    sock_udp_t sock;
    sock_udp_ep_t remote;
    sock_udp_ep_t remote2;

    assert(res == 0);
    res = sock_udp_str2ep(&remote, "[fdf7:0:0:1::2]:777");
    assert(res == 0);
    res = sock_udp_str2ep(&remote2, "[fdf7:0:0:1::3]:777");
    assert(res == 0);

    res = sock_udp_create(&sock, NULL, NULL, 0);
    assert(res == 0);

    static char const to_send[] = "I Receive you receive he/she receives we receive you receive they receive.";

    while (1) {
        res = sock_udp_send(&sock, to_send, sizeof(to_send), &remote);
        res = sock_udp_send(&sock, to_send, sizeof(to_send), &remote2);
        (void) to_send;
        if (res < 0) {
            printf("%s: send error: %d\n", __func__, res);
        } 
        ztimer_sleep(ZTIMER_MSEC, 50);
    }
    return NULL;
}

Both remote points are on-link (ethernet) but unreachable. The sock_udp_send() should never fail because we drop silently. By enabling debugging in ipv6/nib/nib.c, following is printed:

2024-10-08 08:02:53,474 # nib: get next hop link-layer address of fdf7:0:0:1::3%0
2024-10-08 08:02:53,489 # nib: fdf7:0:0:1::3 is in NC, start address resolution
2024-10-08 08:02:53,489 # nib: resolve address fdf7:0:0:1::3 by probing neighbors
2024-10-08 08:02:53,490 # nib: no free pktqueue entries, popping from fdf7:0:0:1::2 hogging 3
2024-10-08 08:02:53,490 # nib: host unreachable
2024-10-08 08:02:53,505 # nib: get next hop link-layer address of fdf7:0:0:1::2%0
2024-10-08 08:02:53,506 # nib: fdf7:0:0:1::2 is in NC, start address resolution
2024-10-08 08:02:53,521 # nib: resolve address fdf7:0:0:1::2 by probing neighbors
2024-10-08 08:02:53,522 # nib: no free pktqueue entries, popping from fdf7:0:0:1::3 hogging 3
2024-10-08 08:02:53,522 # nib: host unreachable
2024-10-08 08:02:53,536 # nib: get next hop link-layer address of fdf7:0:0:1::3%0
2024-10-08 08:02:53,537 # nib: fdf7:0:0:1::3 is in NC, start address resolution
2024-10-08 08:02:53,537 # nib: resolve address fdf7:0:0:1::3 by probing neighbors
2024-10-08 08:02:53,538 # nib: no free pktqueue entries, popping from fdf7:0:0:1::2 hogging 3
2024-10-08 08:02:53,552 # nib: host unreachable
2024-10-08 08:02:53,553 # nib: get next hop link-layer address of fdf7:0:0:1::2%0
2024-10-08 08:02:53,568 # nib: fdf7:0:0:1::2 is in NC, start address resolution
2024-10-08 08:02:53,569 # nib: resolve address fdf7:0:0:1::2 by probing neighbors
2024-10-08 08:02:53,570 # nib: no free pktqueue entries, popping from fdf7:0:0:1::3 hogging 3
2024-10-08 08:02:53,570 # nib: host unreachable

i.e. packets are dropped from whichever nieghbor has the most in its queue.

Issues/PRs references

#20781

@github-actions github-actions bot added Area: network Area: Networking Area: sys Area: System labels Aug 26, 2024
gnrc_pktqueue_t *oldest = _nbr_pop_pkt(node);
assert(oldest != NULL);
gnrc_icmpv6_error_dst_unr_send(ICMPV6_ERROR_DST_UNR_ADDR, oldest->pkt);
gnrc_pktbuf_release_error(oldest->pkt, ENOBUFS);
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Not sure if we should drop with ENOBUFS or silently.

@derMihai derMihai force-pushed the mir/nib/drop_for_unreachable_rebase branch from e4aeb78 to f840f00 Compare August 26, 2024 07:52
@benpicco benpicco requested a review from fabian18 August 26, 2024 09:10
@benpicco benpicco added the CI: ready for build If set, CI server will compile all applications for all available boards for the labeled PR label Aug 26, 2024
@riot-ci
Copy link

riot-ci commented Aug 26, 2024

Murdock results

✔️ PASSED

3a5612e gnrc/ipv6/nib: don't queue packets on 6lo neighbors and drop/flush if UNREACHABLE

Success Failures Total Runtime
10215 0 10215 16m:52s

Artifacts

#if CONFIG_GNRC_IPV6_NIB_QUEUE_PKT
/**
* @brief queue capacity for the packets waiting for address resolution,
* per neighbor. SHOULD always be smaller than @ref CONFIG_GNRC_IPV6_NIB_NUMOF
Copy link
Contributor

Choose a reason for hiding this comment

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

SHOULD always be smaller than @ref CONFIG_GNRC_IPV6_NIB_NUMOF

Why should that be?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Because if it's >= CONFIG_GNRC_IPV6_NIB_NUMOF, then even for a single neighbor, you can't even get to drop old packets from it's queue because you can't allocate a new one in the first place. With multiple neighbors, this can happen anyway, so this is why I went for a SHOULD instead of MUST.

Copy link
Contributor

Choose a reason for hiding this comment

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

Ah

#if IS_ACTIVE(CONFIG_GNRC_IPV6_NIB_QUEUE_PKT)
static gnrc_pktqueue_t _queue_pool[CONFIG_GNRC_IPV6_NIB_NUMOF];
#endif  /* CONFIG_GNRC_IPV6_NIB_QUEUE_PKT */

That is not just an array of queues, but rather every packet to be queued has to get a slot here.

Copy link
Contributor

Choose a reason for hiding this comment

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

If CONFIG_GNRC_IPV6_NIB_QUEUE_PKT_CAP is the queue capacity per neighbor.
I would define this as 1 by default and change the upper code snipped to:

#if IS_ACTIVE(CONFIG_GNRC_IPV6_NIB_QUEUE_PKT)
static gnrc_pktqueue_t _queue_pool[CONFIG_GNRC_IPV6_NIB_NUMOF * CONFIG_GNRC_IPV6_NIB_QUEUE_PKT_CAP];
#endif  /* CONFIG_GNRC_IPV6_NIB_QUEUE_PKT */

Copy link
Contributor

Choose a reason for hiding this comment

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

Hm but it would be bad if packets cannot be queued even if there are 15 available slots because only one slot per neighbor is allowed. The current definition nevertheless looks a bit strange to me.

What about a neighbor is not allowed to have more than halve of the slots?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Sure we still need space in pktbuf to actually send out the neighbor solicitation (and receive the response), but that isn't deepening on the number of queued packets but their size - or do I misunderstand something here?

I think this is a different problem. What I'm trying to solve is the scenario where a neighbor gets flooded with packets and we run out of free queue entries. In that case, the neighbor(s) queue(s) will be filled with stale packets because we can't even _alloc_queue_entry() in order to drop the oldest packets.

Then one neighbor could take all the slots and at some point _alloc_queue_entry() will always fail.

AFAIK we should always drop the oldest, but that can't be done if we can't allocate in the first place.

The current capping solution doesn't guarantee this scenario won't happen, just makes it less probable.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Also note that the default number of free queue entries is CONFIG_GNRC_IPV6_NIB_NUMOF == 16, which is rather small.

Copy link
Contributor

Choose a reason for hiding this comment

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

You could as well drop the oldest packet, as soon as allocation fails and try to allocate again.
It could be that 16 slots are held by one neighbor and another neighbor for which allocation fails does not have an oldest packet to drop, so allocation fails again because one neighbor holds all the slots.

With your capacity limit per neighbor you want to make this case less likely, but also do not prevent this case.
Yes the idea is good, but at the same time you accept that a packet is not queued even though a slot could be allocated.

If I got that right I am not sure if this is really beneficial as long as we don´t note an issue that packets cannot be queued because one neighbor is taking most of the queue slots.
Did you observe this case?

Copy link
Contributor Author

@derMihai derMihai Aug 29, 2024

Choose a reason for hiding this comment

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

If I got that right I am not sure if this is really beneficial as long as we don´t note an issue that packets cannot be queued because one neighbor is taking most of the queue slots.
Did you observe this case?

No, I only had issues with one host.

What bothers me most isn't failed allocations, but stale packets in a neighbor's queue. This goes against the first part of be strict when sending and tolerant when receiving.

It could be that 16 slots are held by one neighbor and another neighbor for which allocation fails does not have an oldest packet to drop, so allocation fails again because one neighbor holds all the slots.

This is partially true. We have the static _nib_onl_entry_t _nodes[CONFIG_GNRC_IPV6_NIB_NUMOF]; . We could iterate through that and drop either from the first neighbor (faster) or the one with the largest queue (may be slower for large CONFIG_GNRC_IPV6_NIB_NUMOF). Anyway, I don't see iterating through that list as a performance problem. We already do so when searching for free packets, adding one more run isn't changing much.

Copy link
Contributor Author

@derMihai derMihai Aug 29, 2024

Choose a reason for hiding this comment

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

I removed the per-neighbor queue cap. Packet allocation now never fails, it just pops a packet from the neighbor with the most packets in its queue. I made the queue entry count one more than the neighbor count. That way, there must always be a neighbor with at least two packets in it's queue, so we never leave it packet-less.

sys/net/gnrc/network_layer/ipv6/nib/nib.c Outdated Show resolved Hide resolved
Copy link
Contributor

@benpicco benpicco left a comment

Choose a reason for hiding this comment

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

Sorry for not getting back to this earlier, this looks very sane IMHO.

How did you test the _alloc_queue_entry() exhaustion case?

@@ -100,6 +100,7 @@ typedef struct _nib_onl_entry {
* @note Only available if @ref CONFIG_GNRC_IPV6_NIB_QUEUE_PKT != 0.
*/
gnrc_pktqueue_t *pktqueue;
size_t pktqueue_len; /**< Number of queued packets */
Copy link
Contributor

Choose a reason for hiding this comment

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

We only need this in the error path, so no need to cache this information.
Let's save some memory and do

static inline size_t gnrc_pktqueue_len(const gnrc_pktqueue_t *queue)
{
    size_t len = 0;

    while (queue->next) {
        queue = queue->next;
        ++len;
    }

    return len;
}

Copy link
Contributor Author

@derMihai derMihai Oct 8, 2024

Choose a reason for hiding this comment

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

What about a large neighbor base (~128) and a high-bandwidth link (e.g. ethernet)? Once the queue entries are used up (at least one neighbor is unreachable and we to send it lots of packets), this will get executed for each packet you want to put on the wire.

Copy link
Contributor

Choose a reason for hiding this comment

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

Hm ok but it's only the sending to a neighbor that is already unreachable that would then be slow, right?
(Not sure if we can speed up the loop much by only considering neighbors in GNRC_IPV6_NIB_NC_INFO_NUD_STATE_PROBE)

The alternative with the 128 neighbor NIB would be an additional 512 bytes of RAM for the counting.

Copy link
Contributor Author

@derMihai derMihai Oct 9, 2024

Choose a reason for hiding this comment

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

The whole network stack will be slow, I'm generally trying to avoid an unpredictable slowdown.

The alternative with the 128 neighbor NIB would be an additional 512 bytes of RAM for the counting.

I aggre that's a lot. I made it uint8_t and moved it at the struct's end where 1 byte is wasted anyway because of padding.

The more I dig through the network stack I realize it is not really made for these numbers. I would also re-introduce the queue cap, see _nbr_push_pkt()

sys/net/gnrc/network_layer/ipv6/nib/_nib-arsm.c Outdated Show resolved Hide resolved
@derMihai
Copy link
Contributor Author

derMihai commented Oct 8, 2024

How did you test the _alloc_queue_entry() exhaustion case?

I updated the PR message.

Comment on lines +1821 to +1835
if (ARRAY_SIZE(_queue_pool) > CONFIG_GNRC_IPV6_NIB_NBR_QUEUE_CAP &&
node->pktqueue_len == CONFIG_GNRC_IPV6_NIB_NBR_QUEUE_CAP) {
Copy link
Contributor

Choose a reason for hiding this comment

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

Suggested change
if (ARRAY_SIZE(_queue_pool) > CONFIG_GNRC_IPV6_NIB_NBR_QUEUE_CAP &&
node->pktqueue_len == CONFIG_GNRC_IPV6_NIB_NBR_QUEUE_CAP) {
if (node->pktqueue_len == (ARRAY_SIZE(_queue_pool) - 1) ||
node->pktqueue_len == CONFIG_GNRC_IPV6_NIB_NBR_QUEUE_CAP) {

I thought you wanted to prevent one node from hogging the entire queue?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

we're talking past each other here. Let's recap:

  • we have CONFIG_GNRC_IPV6_NIB_NUMOF + 1 queue entries in the cache s.t. when they're used up there's always one neighbor with at least two
  • with CONFIG_GNRC_IPV6_NIB_NUMOF small ( ~ 16) we really don't care how they are distributed. Once we're out of entries, find the hog and take from there. This is fast, because we have at most ~16 neighbors and at most ~16 entries. The check ARRAY_SIZE(_queue_pool) > CONFIG_GNRC_IPV6_NIB_NBR_QUEUE_CAP compiles the cap-check away.
  • with CONFIG_GNRC_IPV6_NIB_NUMOF big, we want a per-neighbor cap, which defaults at 16, out of the reasons stated previously:
    • A single hog cannot deplete the entry cache by itself -> less cases where we have to iterate all neighbors to find a hog
    • RFC recommends keeping the per-neighbor queue small (whatever is considered small)

Copy link
Contributor

Choose a reason for hiding this comment

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

Ah makes perfect sense!
But since I had trouble getting it the first time, better also add a comment to the code to prevent misunderstandings with the next one reading this.

if (ARRAY_SIZE(_queue_pool) > CONFIG_GNRC_IPV6_NIB_NBR_QUEUE_CAP &&
node->pktqueue_len == CONFIG_GNRC_IPV6_NIB_NBR_QUEUE_CAP) {
gnrc_pktqueue_t *oldest = _nbr_pop_pkt(node);
gnrc_pktbuf_release(oldest->pkt);
Copy link
Contributor

@benpicco benpicco Oct 10, 2024

Choose a reason for hiding this comment

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

Not gnrc_pktbuf_release_error(oldest->pkt, ENOMEM)?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I think not. You can always send a packet by dropping older ones from a queue, and I think this is arguably normal operation. Also, in this case here we're not necessary out of queue entries, but reaching the per-neighbor limit.

* @attention This MUST be leq UINT8_MAX
*/
#ifndef CONFIG_GNRC_IPV6_NIB_NBR_QUEUE_CAP
#define CONFIG_GNRC_IPV6_NIB_NBR_QUEUE_CAP (16)
Copy link
Contributor

Choose a reason for hiding this comment

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

Any reason to have the default be so high?

Copy link
Contributor Author

@derMihai derMihai Oct 16, 2024

Choose a reason for hiding this comment

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

This disables some code when the max neighbor count < CONFIG_GNRC_IPV6_NIB_NBR_QUEUE_CAP, which is so per default. With a large neighbor count, we mainly want to prevent a neighbor depleting the queue - for performance reasons - and in that case it's not that big anymore.

This was my only rationale when picking 16.

Copy link
Contributor

@benpicco benpicco left a comment

Choose a reason for hiding this comment

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

Looks good to me, feel free to squash!

@derMihai derMihai force-pushed the mir/nib/drop_for_unreachable_rebase branch from fd50e13 to 3a5612e Compare October 16, 2024 07:04
@benpicco benpicco added this pull request to the merge queue Oct 16, 2024
Merged via the queue into RIOT-OS:master with commit 3706589 Oct 16, 2024
26 checks passed
@benpicco benpicco added this to the Release 2024.10 milestone Nov 27, 2024
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Area: network Area: Networking Area: sys Area: System CI: ready for build If set, CI server will compile all applications for all available boards for the labeled PR
Projects
None yet
Development

Successfully merging this pull request may close these issues.

4 participants