Skip to content

fix(minvmd): coalesce libkrun vsock reads instead of emitting sub-skb packets - #921

Merged
norrietaylor merged 1 commit into
mainfrom
fix/libkrun-vsock-coalesce
Jul 22, 2026
Merged

fix(minvmd): coalesce libkrun vsock reads instead of emitting sub-skb packets#921
norrietaylor merged 1 commit into
mainfrom
fix/libkrun-vsock-coalesce

Conversation

@norrietaylor

@norrietaylor norrietaylor commented Jul 22, 2026

Copy link
Copy Markdown
Member

Carries a single libkrun patch on the macOS source build: fill the RX descriptor and, when the socket drains below a packet worth carrying, wait briefly on EAGAIN rather than emit a vsock packet too small to carry its own receive-queue accounting.

macOS only. Linux needs the equivalent through gominimal/pkgs (pkgs#506, unmerged).

Why the shipped fix is not enough

Current unstable (159f8839) carries the fill loop from #884 and still fails. Confirmed on Tom's machine and reproduced here — including on the real repo, a 141 MB compressed upload.

The fill loop stops the stream fragmenting when libkrun outruns the writer inside one wakeup. It does nothing when the writer is slower across wakeups: each notification finds a few hundred bytes buffered, the loop drains them, hits EAGAIN, and ships a packet a fraction of the descriptor's size.

Linux charges every queued packet SKB_TRUESIZE(0) against buf_alloc regardless of payload — 576 bytes on arm64 — so a stream whose mean packet falls below that exhausts the receiver's queue budget long before its byte budget. At the 256 KiB default window the queue caps at 455 packets, and since 6.12.92 ("vsock/virtio: reset connection on receiving queue overflow") the receiver answers the 456th with a reset (ENOBUFS) instead of a drop.

Where the small packets come from — measured in the VMM

An instrumented recv_to_pkt counted, per emitted packet, the descriptor size, credit, and emitted length across 86,016 packets on the failing stack:

  • descriptor size is a constant 3732 B and is never the cause of a small packet;
  • the credit clamp bounds a small packet only 14% of the time, and only in the final bytes of the window;
  • 86% of sub-576-byte packets come from the fill loop exiting on EAGAIN with descriptor space AND credit both free — the writer was simply behind.

That is the single lever, and it is exactly where this patch inserts a bounded poll() wait. It also explains why anything that slows the host path makes the bug vanish (a client sleep, RUST_LOG=debug, MINVMD_KRUN_LOG=debug) while tokio::task::yield_now() does not: it needs wall-clock time for bytes to accumulate, not scheduler fairness.

The guest-kernel side agrees: every reset was the skb-depth branch, never the credit branch, rx_qlen exactly 455, buf_alloc 262144.

VSOCKDBG reject A_credit=0 B_skbdepth=1 buf_used=254632 len=308
  buf_alloc=262144 rx_qlen=455 skb_overhead=262656 SKB_TRUESIZE_0=576

Measurement

libkrun the only variable — same host, same VMM binary, same installed guest, same fixture.

This patch, built in isolation (the exact single patch this PR carries, nothing else) against the current shipped unpatched dylib:

dylib patches pass / fail
94e9d72d none (current unstable) 0 / 5
3b26aeb1 only this patch 6 / 0

Earlier three-way run (before #923 removed the old patches from the tree), fill loop as the baseline:

dylib patches pass / fail
db6d2629 fill loop only (= then-shipped) 0 / 10
5ddaf39f fill loop + 4 KiB credit floor 0 / 10
01282c7e fill loop + this coalesce logic 16 / 0

The credit floor is refuted twice — once against stock, once on top of the fill loop. It should not be proposed again.

What changed since approval

#923 removed the old 0001/0002 libkrun patches from the tree (to test a client-side buffer in isolation). So this patch is now the first and only carried patch, and has been consolidated from a delta-on-the-fill-loop into a single self-contained patch (0001-vsock-coalesce-sub-skb.patch) that applies directly to the pinned libkrun commit 728df812. Verified: it git applys cleanly to stock and produces a tree byte-identical to the 16/0 build above. No behavioural change from the approved version — same emitted code.

It does not re-carry the separate signal-used-queue credit-request hang fix that #923 also removed; that is independent and worth its own PR.

On the client-side buffer (#923)

#923's 4 KiB BufWriter around the upload does not help — measured 0/6, with a socket write histogram identical to unbuffered. russh already batches at the transport, our writes average ~2.5 KB, and the fragmentation is created inside libkrun downstream of anything the client does. The fix has to live in the VMM.

Cost

poll() returns as soon as more data lands, so a fast writer pays nothing. A slow one costs at most COALESCE_ROUNDS × 1 ms = 4 ms per descriptor, and data is always delivered rather than held, so no stream can stall on this.

Upstreaming

Coalescing changes latency for every unix-backed vsock port, not just bulk uploads. Tom's read is that this "basically implements nagle's algorithm", which would break any consumer expecting send() to be 1:1 with recv() — not us (our protocol is stream-oriented), but worth putting behind an option if we upstream to containers/libkrun. Carrying it locally needs no such decision.

Companion PRs

macOS CI exercises this patch — #910 folded vendor/libkrun/patches/** into the setup-libkrun-macos cache key, so adding or changing a patch invalidates the cache and forces a rebuild.

Refs: #869

@coderabbitai

coderabbitai Bot commented Jul 22, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

The libkrun Unix vsock receive loop now coalesces partial reads after EAGAIN using a minimum packet threshold, bounded polling rounds, and a 1ms POLLIN wait.

Changes

Vsock receive coalescing

Layer / File(s) Summary
Partial-read coalescing
vendor/libkrun/patches/0003-vsock-coalesce-sub-skb.patch
Adds polling imports, coalescing constants, and bounded waits after partial socket reads; preserves spurious-wakeup handling when no bytes were read and returns once the threshold or wait limit is reached.

Estimated code review effort: 2 (Simple) | ~10 minutes

Possibly related PRs

  • gominimal/minimal#884: Changes the libkrun Unix vsock receive path to reduce fragmented packets through repeated reads.
  • gominimal/minimal#923: Updates related Unix vsock receive-loop buffering and EAGAIN handling.

Poem

A bunny reads the socket stream,
And gathers crumbs into a dream.
When EAGAIN says, “Not yet,”
A tiny poll helps packets knit.
Small skb shards now hop as one!

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Description check ⚠️ Warning The description is detailed, but it does not follow the required template and omits explicit Summary, Testing, and Checklist sections. Add ## Summary, ## Testing, and ## Checklist sections, include concrete test output, and note whether docs were updated or a breaking change exists.
✅ Passed checks (4 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title clearly summarizes the main change: coalescing libkrun vsock reads to avoid sub-SKB packets.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch

Comment @coderabbitai help to get the list of available commands.

@@ -0,0 +1,107 @@
From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Do we want to name this 0001-... as we removed the other patches?

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@vendor/libkrun/patches/0003-vsock-coalesce-sub-skb.patch`:
- Around line 102-104: Update the coalescing wait loop around waits and poll to
handle poll results: retry EINTR without incrementing waits, while routing all
other poll errors through the existing error path. Increment waits only after a
successful or non-interrupted poll, preserving the coalescing budget when
signals interrupt polling.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: da85891d-b656-4f25-a2b2-b4a74aeff47e

📥 Commits

Reviewing files that changed from the base of the PR and between 159f883 and 0096dc7.

📒 Files selected for processing (1)
  • vendor/libkrun/patches/0003-vsock-coalesce-sub-skb.patch

Comment thread vendor/libkrun/patches/0003-vsock-coalesce-sub-skb.patch Outdated
… packets

The unix-vsock proxy issues one recv() per RX descriptor and emits whatever
that single read found buffered. Against a bulk sender that hands the stream
over in small chunks, the muxer drains the socket faster than the writer
fills it, so descriptors carry a fraction of their capacity and the stream
fragments into sub-KiB packets while the peer's window is wide open.

Linux charges every queued packet SKB_TRUESIZE(0) against buf_alloc
regardless of payload -- 576 bytes on arm64 -- so a stream whose mean packet
falls below that exhausts the receiver's queue budget long before its byte
budget. At a 256 KiB window the queue caps at 455 packets, and since 6.12.92
the receiver resets the connection (ENOBUFS) on the 456th rather than
dropping it. That is the failure behind bulk host->guest uploads.

Fill the descriptor rather than emit one recv()'s worth, and -- because
draining alone does not help when the writer is slower than we are across
wakeups -- wait briefly on EAGAIN while still below a packet worth carrying.
poll() returns as soon as more data lands, so a fast writer pays nothing, a
slow one costs at most 4 ms, and data is always delivered rather than held.

Instrumented on both the guest rejection path and libkrun's emit path: 86%
of sub-576-byte packets came from the fill loop exiting on EAGAIN with
descriptor space and credit both free, not from the credit clamp (14%, tail
only) and never from the descriptor size (constant 3732 B). A/B with libkrun
the only variable: 0 of 10 uploads completed without this, 16 of 16 with it,
and the instrumented kernel logged no rejections.

This is a single self-contained patch against the pinned libkrun commit,
numbered 0001 because #923 removed the earlier fill-loop patches from the
tree. It subsumes their fill-loop mechanics; it does not re-carry the
separate signal-used-queue credit-request fix, which is worth its own PR.

Refs: #869
@norrietaylor
norrietaylor force-pushed the fix/libkrun-vsock-coalesce branch from 0096dc7 to 7eedbf9 Compare July 22, 2026 23:26
@norrietaylor

Copy link
Copy Markdown
Member Author

Rebased onto current main and consolidated the patch after #923 removed the fill-loop patches from the tree.

  • Renamed 0003-…0001-vsock-coalesce-sub-skb.patch (it is now the first and only carried patch).
  • Made it self-contained against the pinned libkrun commit: as a delta on the old 0002 fill loop it no longer applied, since Remove libkrun patches, buffer tarball writes for next unstable #923 deleted that. The new single patch takes stock recv_to_pkt straight to the coalescing version. Verified it git applys cleanly to 728df812 and produces a tree byte-identical to the 0002+0003 build that measured 16/0.
  • It does not re-carry the former 0001 signal-used-queue credit-request fix (a separate hang). That is worth its own PR.

No behavioural change from the approved version — same emitted code, same measurements.

@norrietaylor
norrietaylor merged commit 736120a into main Jul 22, 2026
29 checks passed
@norrietaylor
norrietaylor deleted the fix/libkrun-vsock-coalesce branch July 22, 2026 23:49
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