Skip to content

helper: Drop packets when the peer stops reading from the socket - #266

Closed
leolannenmaki wants to merge 2 commits into
nirs:mainfrom
avrea-com:bounded-enobufs-retry
Closed

helper: Drop packets when the peer stops reading from the socket#266
leolannenmaki wants to merge 2 commits into
nirs:mainfrom
avrea-com:bounded-enobufs-retry

Conversation

@leolannenmaki

Copy link
Copy Markdown
Contributor

When sendmsg_x() or write() fail with ENOBUFS, write_to_vm() retries every
50 microseconds with no bound. The vm socket is a datagram socket, so ENOBUFS
means the peer receive buffer is full: the client (vfkit, krunkit) is not
draining the socket as fast as the host sends. A client that stops reading,
or reads too slowly, blocks write_to_vm() until the client catches up.

All host->vm forwarding runs on one serial queue, so one slow client delays
every host->vm packet behind it, for all flows on the interface, including
latency sensitive frames such as TCP acks and HTTP/2 pings.

A stalled client also prevents the helper from stopping: on SIGTERM the
atexit handler stops the vmnet interface on the same blocked queue, so the
helper never exits and has to be killed.

We hit this with vfkit VMs. During large concurrent downloads into a guest,
the guest drains slowly and the helper holds the queue full for seconds at a time.
HTTP/2 clients inside the guest time out their connection health pings and
close the connection, killing all in-flight streams with "http2: client connection lost".

Fix

Bound the wait to VM_MAX_RETRIES (5 ms), shared by the whole batch. When the
budget is exhausted every remaining packet gets one last write() before being dropped,
since a smaller packet may still fit in the peer receive buffer. Dropping is safe: ethernet
does not guarantee delivery, the guest transport protocols recover from the loss, and
TCP backs off, which keeps the queue short. Dropped packets are counted and logged
at most once per second.

Example

WARN  [host->vm] dropped 11 packets (11 total): peer is not reading from the socket

The first commit adds a stalled client test: it fills its own receive buffer by sending pings
to the gateway without reading the replies, waits for the dropped packets warning in the
helper log, and verifies that forwarding recovers once the client reads again. It fails
without the fix, since the helper never gives up and blocks until the client reads,
and passes with it.

Benchmarked with ./bench (vfkit, shared mode, iperf3): no measurable change.
The bound is never reached at line rate and no packets were dropped.

AI Disclaimer

As you can probably see from the description text above, LLMs were used
in generating this PR description. They were also used in debugging the problem in
the first place and in coming up with the failing test and actual fix.

I'm not that interested getting this exact PR merged as I'm in letting the maintainers
know of the issue we ran into and the, AI generated, workaround we found in
hopes of getting some fix landed.

Happy to provide more debugging assistance if need be.

The test fills its own receive buffer by sending pings to the gateway
without reading the replies, and expects the helper to report dropped
packets and to keep forwarding once the client reads again.
It fails on current code: when sendmsg_x() or write() fail with ENOBUFS,
write_to_vm() retries forever, so a client that stops reading from the
socket blocks the host->vm forwarding queue indefinitely, delaying every
flow behind it.

A helper blocked this way cannot even stop: on SIGTERM the atexit
handler stops the vmnet interface on the same blocked queue, so the
helper never exits. The test reads the backlog before stopping the
helper to avoid hanging on failure.
write_to_vm() retried ENOBUFS forever, 50 microseconds at a time. The
vm socket is a datagram socket, so ENOBUFS means the peer receive
buffer is full: the client (vfkit, krunkit) is not draining the socket
as fast as the host sends. Since all host->vm forwarding runs on one
serial queue, one slow or stalled client blocked every host->vm packet
behind it with no bound, delaying unrelated flows and latency
sensitive frames such as TCP acks and HTTP/2 pings. Real clients hit
this: a guest that drains slowly during large concurrent downloads can
hold the queue full for seconds, and HTTP/2 clients in the guest time
out their connection health pings and drop the connection with all its
streams.

Bound the wait to VM_MAX_RETRIES (5 ms), shared by the whole batch.
When the budget is exhausted every remaining packet gets one last
write() before being dropped, since a smaller packet may still fit in
the peer receive buffer. Dropping is safe: ethernet does not guarantee
delivery, the guest transport protocols recover from the loss, and TCP
backs off, which keeps the queue short. Dropped packets are counted
and logged at most once per second.

Example:

    WARN  [host->vm] dropped 11 packets (11 total): peer is not reading from the socket
@nirs

nirs commented Jul 21, 2026

Copy link
Copy Markdown
Owner

@leolannenmaki Thanks for the PR!

When sendmsg_x() or write() fail with ENOBUFS, write_to_vm() retries every 50 microseconds with no bound. The vm socket is a datagram socket, so ENOBUFS means the peer receive buffer is full: the client (vfkit, krunkit) is not draining the socket as fast as the host sends. A client that stops reading, or reads too slowly, blocks write_to_vm() until the client catches up.

All host->vm forwarding runs on one serial queue, so one slow client delays every host->vm packet behind it, for all flows on the interface, including latency sensitive frames such as TCP acks and HTTP/2 pings.

I agree the current behaviour is simplistic, but I never seen an issue in real use. libkrun uses a similar solution - packets are never dropped.

A stalled client also prevents the helper from stopping: on SIGTERM the atexit handler stops the vmnet interface on the same blocked queue, so the helper never exits and has to be killed.

This is easy to fix, we can check if signal was received in the wait loop.

We hit this with vfkit VMs. During large concurrent downloads into a guest, the guest drains slowly and the helper holds the queue full for seconds at a time. HTTP/2 clients inside the guest time out their connection health pings and close the connection, killing all in-flight streams with "http2: client connection lost".

This is the most important thing - can you open an issue and explain how to reproduce this and which real world use cases are affected?

Fix

Bound the wait to VM_MAX_RETRIES (5 ms), shared by the whole batch. When the budget is exhausted every remaining packet gets one last write() before being dropped, since a smaller packet may still fit in the peer receive buffer. Dropping is safe: ethernet does not guarantee delivery, the guest transport protocols recover from the loss, and TCP backs off, which keeps the queue short. Dropped packets are counted and logged at most once per second.

Makes sense to limit the time we wait. Do you know how real switches handle the same? how much time a packet is waiting in the switch buffers when the receiver is not fast enough?
vmnet-helper should behave in a similar way by default, and maybe have a configurable delay.

nirs added a commit that referenced this pull request Jul 29, 2026
When the kernel's vmnet buffer is full, the helper retries
sendmsg_x() with a 50 microsecond sleep between attempts. Previously
the retries were unbounded, causing head-of-line blocking: a
high-priority small packet (e.g. an HTTP/2 PING or TCP ACK) queued
behind large data packets is delayed without bound while the helper
waits for buffer space. If the delay exceeds the protocol's timeout,
the connection breaks even though the network is otherwise healthy.

Limit retries to 10 attempts (500µs maximum wait) shared across the
entire batch. The fast path retries sendmsg_x() within this budget,
then falls back to the slow path which writes remaining packets one
at a time, also retrying within the same budget. Packets are dropped
only after the budget is exhausted or on non-ENOBUFS errors.

Before (ENOBUFS in fast path):

    sendmsg_x(batch) → ENOBUFS
    → sleep 50µs → retry → ENOBUFS
    → sleep 50µs → retry → ...
    (unbounded retries, blocks all packets in the batch)

After (ENOBUFS in fast path):

    sendmsg_x(batch) → ENOBUFS
    → sleep 50µs → retry (up to 10 times, shared budget)
    → fall back to slow path
    → write(packet₁) → ENOBUFS → retry (same budget)
    → write(packet₁) → success
    → write(packet₂) → ENOBUFS → budget exhausted → drop
    → write(packet₃) → success
    (bounded wait across both paths, then drop)

The retry limit is per batch, not per sendmsg_x call, ensuring the
total wait is bounded regardless of partial successes between
retries.

Dropping packets is correct because the helper implements an ethernet
link between the VM and the host. Like a physical network link under
congestion, packets may be lost. TCP retransmits lost segments, and
UDP applications are expected to tolerate loss.

Bounded retries are preferable to dropping immediately because TCP
retransmission is far more expensive than a 50µs sleep. Dropping
causes TCP to halve its congestion window and wait for retransmit,
taking milliseconds to seconds to recover. In testing, dropping
immediately on ENOBUFS caused throughput to collapse from 26 Gbps to
20 Gbps with 4 parallel streams as TCP repeatedly backed off.

ENOBUFS occurs when the VM cannot drain the vmnet buffer fast enough.
With a fast VMM (krunkit) and --busy-poll, the VM processes packets
fast enough that ENOBUFS never occurs even at 36 Gbps:

    krunkit, --busy-poll, 4 CPUs, 8 MiB buffer:

                     TX (Gbps)  drops   fast calls/sec
    before (retry)       36.4       0          14.5k
    after (drop)         36.4       0          14.5k

With a slower VMM (vfkit), the VM cannot keep up and ENOBUFS occurs.
The bounded retry mechanism absorbs most ENOBUFS pressure, but some
packets are dropped. The previous unbounded retry behavior achieved
slightly higher throughput for vfkit by naturally throttling the
sending rate:

    vfkit, --busy-poll, 4 CPUs, 4 MiB buffer:

                     TX (Gbps)  drops   fast calls/sec
    before (retry)        9.8       0          15-28k
    after (drop)          9.4     780          ~28k

The slight throughput regression for slow VMMs is acceptable because
head-of-line blocking is a correctness issue, not a performance
issue. Without bounded retries, a single blocked batch can stall all
traffic for an unbounded duration.

Based on #266 with the following changes:

- Tighter retry budget: 10 retries (500µs) vs 100 (5ms). Benchmarks
  show retries rarely exceed a few attempts.
- Change error logging in sendmsg_x() and write() from ERROR to DEBUG
  for ENOBUFS (normal backpressure signal) and WARN for other errors
  (unexpected but recoverable).
- Drops tracked via stats counters instead of a separate rate-limited
  warning log.

Thanks: Leo Lännenmäki <leo.lannenmaki@avrea.com>
Fixes: #267
nirs added a commit that referenced this pull request Jul 31, 2026
When the kernel's vmnet buffer is full, the helper retries
sendmsg_x() with a 50 microsecond sleep between attempts. Previously
the retries were unbounded, causing head-of-line blocking: a
high-priority small packet (e.g. an HTTP/2 PING or TCP ACK) queued
behind large data packets is delayed without bound while the helper
waits for buffer space. If the delay exceeds the protocol's timeout,
the connection breaks even though the network is otherwise healthy.

Limit retries to 10 attempts (500µs maximum wait) shared across the
entire batch. The fast path retries sendmsg_x() within this budget,
then falls back to the slow path which writes remaining packets one
at a time, also retrying within the same budget. Packets are dropped
only after the budget is exhausted or on non-ENOBUFS errors.

Before (ENOBUFS in fast path):

    sendmsg_x(batch) → ENOBUFS
    → sleep 50µs → retry → ENOBUFS
    → sleep 50µs → retry → ...
    (unbounded retries, blocks all packets in the batch)

After (ENOBUFS in fast path):

    sendmsg_x(batch) → ENOBUFS
    → sleep 50µs → retry (up to 10 times, shared budget)
    → fall back to slow path
    → write(packet₁) → ENOBUFS → retry (same budget)
    → write(packet₁) → success
    → write(packet₂) → ENOBUFS → budget exhausted → drop
    → write(packet₃) → success
    (bounded wait across both paths, then drop)

The retry limit is per batch, not per sendmsg_x call, ensuring the
total wait is bounded regardless of partial successes between
retries.

Dropping packets is correct because the helper implements an ethernet
link between the VM and the host. Like a physical network link under
congestion, packets may be lost. TCP retransmits lost segments, and
UDP applications are expected to tolerate loss.

Bounded retries are preferable to dropping immediately because TCP
retransmission is far more expensive than a 50µs sleep. Dropping
causes TCP to halve its congestion window and wait for retransmit,
taking milliseconds to seconds to recover. In testing, dropping
immediately on ENOBUFS caused throughput to collapse from 26 Gbps to
20 Gbps with 4 parallel streams as TCP repeatedly backed off.

ENOBUFS occurs when the VM cannot drain the vmnet buffer fast enough.
With a fast VMM (krunkit) and --busy-poll, the VM processes packets
fast enough that ENOBUFS never occurs even at 36 Gbps:

    krunkit, --busy-poll, 4 CPUs, 8 MiB buffer:

                     TX (Gbps)  drops   fast calls/sec
    before (retry)       36.4       0          14.5k
    after (drop)         36.4       0          14.5k

With a slower VMM (vfkit), the VM cannot keep up and ENOBUFS occurs.
The bounded retry mechanism absorbs most ENOBUFS pressure, but some
packets are dropped. The previous unbounded retry behavior achieved
slightly higher throughput for vfkit by naturally throttling the
sending rate:

    vfkit, --busy-poll, 4 CPUs, 4 MiB buffer:

                     TX (Gbps)  drops   fast calls/sec
    before (retry)        9.8       0          15-28k
    after (drop)          9.4     780          ~28k

The slight throughput regression for slow VMMs is acceptable because
head-of-line blocking is a correctness issue, not a performance
issue. Without bounded retries, a single blocked batch can stall all
traffic for an unbounded duration.

Based on #266 with the following changes:

- Tighter retry budget: 10 retries (500µs) vs 100 (5ms). Benchmarks
  show retries rarely exceed a few attempts.
- Change error logging in sendmsg_x() and write() from ERROR to DEBUG
  for ENOBUFS (normal backpressure signal) and WARN for other errors
  (unexpected but recoverable).
- Drops tracked via stats counters instead of a separate rate-limited
  warning log.

Thanks: Leo Lännenmäki <leo.lannenmaki@avrea.com>
Fixes: #267
nirs added a commit that referenced this pull request Jul 31, 2026
When the kernel's vmnet buffer is full, the helper retries
sendmsg_x() with a 50 microsecond sleep between attempts. Previously
the retries were unbounded, causing head-of-line blocking: a
high-priority small packet (e.g. an HTTP/2 PING or TCP ACK) queued
behind large data packets is delayed without bound while the helper
waits for buffer space. If the delay exceeds the protocol's timeout,
the connection breaks even though the network is otherwise healthy.

Limit retries to 10 attempts (500µs maximum wait) shared across the
entire batch. The fast path retries sendmsg_x() within this budget,
then falls back to the slow path which writes remaining packets one
at a time, also retrying within the same budget. Packets are dropped
only after the budget is exhausted or on non-ENOBUFS errors.

Before (ENOBUFS in fast path):

    sendmsg_x(batch) → ENOBUFS
    → sleep 50µs → retry → ENOBUFS
    → sleep 50µs → retry → ...
    (unbounded retries, blocks all packets in the batch)

After (ENOBUFS in fast path):

    sendmsg_x(batch) → ENOBUFS
    → sleep 50µs → retry (up to 10 times, shared budget)
    → fall back to slow path
    → write(packet₁) → ENOBUFS → retry (same budget)
    → write(packet₁) → success
    → write(packet₂) → ENOBUFS → budget exhausted → drop
    → write(packet₃) → success
    (bounded wait across both paths, then drop)

The retry limit is per batch, not per sendmsg_x call, ensuring the
total wait is bounded regardless of partial successes between
retries.

Dropping packets is correct because the helper implements an ethernet
link between the VM and the host. Like a physical network link under
congestion, packets may be lost. TCP retransmits lost segments, and
UDP applications are expected to tolerate loss.

Bounded retries are preferable to dropping immediately because TCP
retransmission is far more expensive than a 50µs sleep. Dropping
causes TCP to halve its congestion window and wait for retransmit,
taking milliseconds to seconds to recover. In testing, dropping
immediately on ENOBUFS caused throughput to collapse from 26 Gbps to
20 Gbps with 4 parallel streams as TCP repeatedly backed off.

ENOBUFS occurs when the VM cannot drain the vmnet buffer fast enough.
With a fast VMM (krunkit) and --busy-poll, the VM processes packets
fast enough that ENOBUFS never occurs even at 36 Gbps:

    krunkit, --busy-poll, 4 CPUs, 8 MiB buffer:

                     TX (Gbps)  drops   fast calls/sec
    before (retry)       36.4       0          14.5k
    after (drop)         36.4       0          14.5k

With a slower VMM (vfkit), the VM cannot keep up and ENOBUFS occurs.
The bounded retry mechanism absorbs most ENOBUFS pressure, but some
packets are dropped. The previous unbounded retry behavior achieved
slightly higher throughput for vfkit by naturally throttling the
sending rate:

    vfkit, --busy-poll, 4 CPUs, 4 MiB buffer:

                     TX (Gbps)  drops   fast calls/sec
    before (retry)        9.8       0          15-28k
    after (drop)          9.4     780          ~28k

The slight throughput regression for slow VMMs is acceptable because
head-of-line blocking is a correctness issue, not a performance
issue. Without bounded retries, a single blocked batch can stall all
traffic for an unbounded duration.

Based on #266 with the following changes:

- Tighter retry budget: 10 retries (500µs) vs 100 (5ms). Benchmarks
  show retries rarely exceed a few attempts.
- Change error logging in sendmsg_x() and write() from ERROR to DEBUG
  for ENOBUFS (normal backpressure signal) and WARN for other errors
  (unexpected but recoverable).
- Drops tracked via stats counters instead of a separate rate-limited
  warning log.

Thanks: Leo Lännenmäki <leo.lannenmaki@avrea.com>
Fixes: #267
nirs added a commit that referenced this pull request Jul 31, 2026
When the kernel's vmnet buffer is full, the helper retries
sendmsg_x() with a 50 microsecond sleep between attempts. Previously
the retries were unbounded, causing head-of-line blocking: a
high-priority small packet (e.g. an HTTP/2 PING or TCP ACK) queued
behind large data packets is delayed without bound while the helper
waits for buffer space. If the delay exceeds the protocol's timeout,
the connection breaks even though the network is otherwise healthy.

Limit retries to 10 attempts (500µs maximum wait) shared across the
entire batch. The fast path retries sendmsg_x() within this budget,
then falls back to the slow path which writes remaining packets one
at a time, also retrying within the same budget. Packets are dropped
only after the budget is exhausted or on non-ENOBUFS errors.

Before (ENOBUFS in fast path):

    sendmsg_x(batch) → ENOBUFS
    → sleep 50µs → retry → ENOBUFS
    → sleep 50µs → retry → ...
    (unbounded retries, blocks all packets in the batch)

After (ENOBUFS in fast path):

    sendmsg_x(batch) → ENOBUFS
    → sleep 50µs → retry (up to 10 times, shared budget)
    → fall back to slow path
    → write(packet₁) → ENOBUFS → retry (same budget)
    → write(packet₁) → success
    → write(packet₂) → ENOBUFS → budget exhausted → drop
    → write(packet₃) → success
    (bounded wait across both paths, then drop)

The retry limit is per batch, not per sendmsg_x call, ensuring the
total wait is bounded regardless of partial successes between
retries.

Dropping packets is correct because the helper implements an ethernet
link between the VM and the host. Like a physical network link under
congestion, packets may be lost. TCP retransmits lost segments, and
UDP applications are expected to tolerate loss.

Bounded retries are preferable to dropping immediately because TCP
retransmission is far more expensive than a 50µs sleep. Dropping
causes TCP to halve its congestion window and wait for retransmit,
taking milliseconds to seconds to recover. In testing, dropping
immediately on ENOBUFS caused throughput to collapse from 26 Gbps to
20 Gbps with 4 parallel streams as TCP repeatedly backed off.

ENOBUFS occurs when the VM cannot drain the vmnet buffer fast enough.
With a fast VMM (krunkit) and --busy-poll, the VM processes packets
fast enough that ENOBUFS never occurs even at 36 Gbps:

    krunkit, --busy-poll, 4 CPUs, 8 MiB buffer:

                     TX (Gbps)  drops   fast calls/sec
    before (retry)       36.4       0          14.5k
    after (drop)         36.4       0          14.5k

With a slower VMM (vfkit), the VM cannot keep up and ENOBUFS occurs.
The bounded retry mechanism absorbs most ENOBUFS pressure, but some
packets are dropped. The previous unbounded retry behavior achieved
slightly higher throughput for vfkit by naturally throttling the
sending rate:

    vfkit, --busy-poll, 4 CPUs, 4 MiB buffer:

                     TX (Gbps)  drops   fast calls/sec
    before (retry)        9.8       0          15-28k
    after (drop)          9.4     780          ~28k

The slight throughput regression for slow VMMs is acceptable because
head-of-line blocking is a correctness issue, not a performance
issue. Without bounded retries, a single blocked batch can stall all
traffic for an unbounded duration.

Based on #266 with the following changes:

- Tighter retry budget: 10 retries (500µs) vs 100 (5ms). Benchmarks
  show retries rarely exceed a few attempts.
- Change error logging in sendmsg_x() and write() from ERROR to DEBUG
  for ENOBUFS (normal backpressure signal) and WARN for other errors
  (unexpected but recoverable).
- Drops tracked via stats counters instead of a separate rate-limited
  warning log.

Thanks: Leo Lännenmäki <leo.lannenmaki@avrea.com>
Fixes: #267
nirs added a commit that referenced this pull request Jul 31, 2026
When the kernel's vmnet buffer is full, the helper retries
sendmsg_x() with a 50 microsecond sleep between attempts. Previously
the retries were unbounded, causing head-of-line blocking: a
high-priority small packet (e.g. an HTTP/2 PING or TCP ACK) queued
behind large data packets is delayed without bound while the helper
waits for buffer space. If the delay exceeds the protocol's timeout,
the connection breaks even though the network is otherwise healthy.

Limit retries to 10 attempts (500µs maximum wait) shared across the
entire batch. The fast path retries sendmsg_x() within this budget,
then falls back to the slow path which writes remaining packets one
at a time, also retrying within the same budget. Packets are dropped
only after the budget is exhausted or on non-ENOBUFS errors.

Before (ENOBUFS in fast path):

    sendmsg_x(batch) → ENOBUFS
    → sleep 50µs → retry → ENOBUFS
    → sleep 50µs → retry → ...
    (unbounded retries, blocks all packets in the batch)

After (ENOBUFS in fast path):

    sendmsg_x(batch) → ENOBUFS
    → sleep 50µs → retry (up to 10 times, shared budget)
    → fall back to slow path
    → write(packet₁) → ENOBUFS → retry (same budget)
    → write(packet₁) → success
    → write(packet₂) → ENOBUFS → budget exhausted → drop
    → write(packet₃) → success
    (bounded wait across both paths, then drop)

The retry limit is per batch, not per sendmsg_x call, ensuring the
total wait is bounded regardless of partial successes between
retries.

Dropping packets is correct because the helper implements an ethernet
link between the VM and the host. Like a physical network link under
congestion, packets may be lost. TCP retransmits lost segments, and
UDP applications are expected to tolerate loss.

Bounded retries are preferable to dropping immediately because TCP
retransmission is far more expensive than a 50µs sleep. Dropping
causes TCP to halve its congestion window and wait for retransmit,
taking milliseconds to seconds to recover. In testing, dropping
immediately on ENOBUFS caused throughput to collapse from 26 Gbps to
20 Gbps with 4 parallel streams as TCP repeatedly backed off.

ENOBUFS occurs when the VM cannot drain the vmnet buffer fast enough.
With a fast VMM (krunkit) and --busy-poll, the VM processes packets
fast enough that ENOBUFS never occurs even at 36 Gbps:

    krunkit, --busy-poll, 4 CPUs, 8 MiB buffer:

                     TX (Gbps)  drops   fast calls/sec
    before (retry)       36.4       0          14.5k
    after (drop)         36.4       0          14.5k

With a slower VMM (vfkit), the VM cannot keep up and ENOBUFS occurs.
The bounded retry mechanism absorbs most ENOBUFS pressure, but some
packets are dropped. The previous unbounded retry behavior achieved
slightly higher throughput for vfkit by naturally throttling the
sending rate:

    vfkit, --busy-poll, 4 CPUs, 4 MiB buffer:

                     TX (Gbps)  drops   fast calls/sec
    before (retry)        9.8       0          15-28k
    after (drop)          9.4     780          ~28k

The slight throughput regression for slow VMMs is acceptable because
head-of-line blocking is a correctness issue, not a performance
issue. Without bounded retries, a single blocked batch can stall all
traffic for an unbounded duration.

Based on #266 with the following changes:

- Tighter retry budget: 10 retries (500µs) vs 100 (5ms). Benchmarks
  show retries rarely exceed a few attempts.
- Change error logging in sendmsg_x() and write() from ERROR to DEBUG
  for ENOBUFS (normal backpressure signal) and WARN for other errors
  (unexpected but recoverable).
- Drops tracked via stats counters instead of a separate rate-limited
  warning log.

Thanks: Leo Lännenmäki <leo.lannenmaki@avrea.com>
Fixes: #267
nirs added a commit that referenced this pull request Aug 2, 2026
When the kernel's vmnet buffer is full, the helper retries
sendmsg_x() with a 50 microsecond sleep between attempts. Previously
the retries were unbounded, causing head-of-line blocking: a
high-priority small packet (e.g. an HTTP/2 PING or TCP ACK) queued
behind large data packets is delayed without bound while the helper
waits for buffer space. If the delay exceeds the protocol's timeout,
the connection breaks even though the network is otherwise healthy.

Limit retries to 10 attempts (500µs maximum wait) shared across the
entire batch. The fast path retries sendmsg_x() within this budget,
then falls back to the slow path which writes remaining packets one
at a time, also retrying within the same budget. Packets are dropped
only after the budget is exhausted or on non-ENOBUFS errors.

Before (ENOBUFS in fast path):

    sendmsg_x(batch) → ENOBUFS
    → sleep 50µs → retry → ENOBUFS
    → sleep 50µs → retry → ...
    (unbounded retries, blocks all packets in the batch)

After (ENOBUFS in fast path):

    sendmsg_x(batch) → ENOBUFS
    → sleep 50µs → retry (up to 10 times, shared budget)
    → fall back to slow path
    → write(packet₁) → ENOBUFS → retry (same budget)
    → write(packet₁) → success
    → write(packet₂) → ENOBUFS → budget exhausted → drop
    → write(packet₃) → success
    (bounded wait across both paths, then drop)

The retry limit is per batch, not per sendmsg_x call, ensuring the
total wait is bounded regardless of partial successes between
retries.

Dropping packets is correct because the helper implements an ethernet
link between the VM and the host. Like a physical network link under
congestion, packets may be lost. TCP retransmits lost segments, and
UDP applications are expected to tolerate loss.

Bounded retries are preferable to dropping immediately because TCP
retransmission is far more expensive than a 50µs sleep. Dropping
causes TCP to halve its congestion window and wait for retransmit,
taking milliseconds to seconds to recover. In testing, dropping
immediately on ENOBUFS caused throughput to collapse from 26 Gbps to
20 Gbps with 4 parallel streams as TCP repeatedly backed off.

ENOBUFS occurs when the VM cannot drain the vmnet buffer fast enough.
With a fast VMM (krunkit) and --busy-poll, the VM processes packets
fast enough that ENOBUFS never occurs even at 36 Gbps:

    krunkit, --busy-poll, 4 CPUs, 8 MiB buffer:

                     TX (Gbps)  drops   fast calls/sec
    before (retry)       36.4       0          14.5k
    after (drop)         36.4       0          14.5k

With a slower VMM (vfkit), the VM cannot keep up and ENOBUFS occurs.
The bounded retry mechanism absorbs most ENOBUFS pressure, but some
packets are dropped. The previous unbounded retry behavior achieved
slightly higher throughput for vfkit by naturally throttling the
sending rate:

    vfkit, --busy-poll, 4 CPUs, 4 MiB buffer:

                     TX (Gbps)  drops   fast calls/sec
    before (retry)        9.8       0          15-28k
    after (drop)          9.4     780          ~28k

The slight throughput regression for slow VMMs is acceptable because
head-of-line blocking is a correctness issue, not a performance
issue. Without bounded retries, a single blocked batch can stall all
traffic for an unbounded duration.

Based on #266 with the following changes:

- Tighter retry budget: 10 retries (500µs) vs 100 (5ms). Benchmarks
  show retries rarely exceed a few attempts.
- Change error logging in sendmsg_x() and write() from ERROR to DEBUG
  for ENOBUFS (normal backpressure signal) and WARN for other errors
  (unexpected but recoverable).
- Drops tracked via stats counters instead of a separate rate-limited
  warning log.

Thanks: Leo Lännenmäki <leo.lannenmaki@avrea.com>
Fixes: #267
@nirs

nirs commented Aug 2, 2026

Copy link
Copy Markdown
Owner

Fixed in #278

@nirs nirs closed this Aug 2, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants