Skip to content

fix(libkrun): patch vsock RX to fill the descriptor instead of fragmenting - #506

Merged
twitchyliquid64 merged 1 commit into
mainfrom
fix/libkrun-vsock-min-credit-floor
Jul 22, 2026
Merged

fix(libkrun): patch vsock RX to fill the descriptor instead of fragmenting#506
twitchyliquid64 merged 1 commit into
mainfrom
fix/libkrun-vsock-min-credit-floor

Conversation

@norrietaylor

@norrietaylor norrietaylor commented Jul 22, 2026

Copy link
Copy Markdown
Member

Carries libkrun vsock patches in packages/libkrun so the Linux build gets the same fixes the macOS source build is getting in gominimal/minimal#884. That PR patches libkrun at vendor/libkrun/patches/, which only covers the macOS source build — Linux takes libkrun from this package's release tarball, so it needs its own change.

Tracking issue: gominimal/minimal#869.

Important

This PR previously carried a different patch — a minimum-credit floor — which has been refuted and is now dropped. See Superseded approach below. If you reviewed the earlier revision, re-read from scratch; the mechanism is different.

Symptom

error: Failed to upload project files: copying tar stream to channel: channel closed — a bulk host→guest stream over a unix-backed vsock port dies mid-upload, taking the whole SSH session with it.

Mechanism

recv_to_pkt() issued one recv() per RX descriptor and emitted whatever that call happened to find 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 each descriptor carries a fraction of its capacity and the stream fragments into sub-KiB packets.

Fragmentation is expensive for the receiver: a Linux peer charges every queued skb SKB_TRUESIZE(0) (576 bytes on arm64) against buf_alloc regardless of payload. virtio_transport_inc_rx_pkt() rejects once (queue_len + 1) * SKB_TRUESIZE(0) > buf_alloc — a 455-skb ceiling at a 256 KiB window — so sub-KiB packets burn queue budget far faster than they deliver bytes, and the peer resets with ENOBUFS while credit is still outstanding.

Instrumented counters over 40,749 packets identified the constraint as socket occupancy at the instant of the read, not the peer's window:

Measurement Value
Reads that drained the socket with descriptor space still free 74.1%
Mean bytes available per read (FIONREAD before recv()) 2,199 B
Mean available peer credit 239,848 B of a 262,144 B window
Zero-credit path reached 10 times in 40,749 reads
Mean payload 1,895 B; 35.6% of reads under 1 KiB

Credit was essentially never the constraint.

What is carried

Two patches, applied by name in a fixed order0002 is written against the tree 0001 produces, so the sequence is load-bearing.

0001-vsock-signal-the-used-queue-when-requesting-credit.patch — the credit-request path sets push_credit_req but never signal_queue, and push_packet() alone does not raise the IRQ. A proxy that must wait for credit therefore disarms its own polling and never wakes the peer to send the update it is waiting on. Usually masked, because a batch normally pushes data packets first and those set signal_queue themselves; reachable when the first packet of a batch has to wait. Applies to both unix.rs and tsi_stream.rs.

Carried because it is a genuine independent bug, not because it is the fix — on its own it measured 0/6, identical to the stock control.

0002-vsock-fill-the-rx-descriptor-instead-of-one-recv-per-packet.patch — the actual fix. Loop until the descriptor is full or the socket returns EAGAIN, instead of one recv() per descriptor.

Validation

Runtime A/B on macOS: same dev stack, same minvmd, libkrun rebuilt from the same pinned commit with the same script, only the dylib differing.

Build Runs Pass Fail
stock control 6 0 6
credit floor (previously carried here) 6 0 6
IRQ fix + instrumentation 6 0 6
IRQ fix + fill-loop 6 6 0
IRQ fix + fill-loop (repeat) 6 6 0

Two caveats on those numbers, both important:

  • macOS only, and on a dev guest only. Nothing in that matrix exercises a Linux host, which is exactly what this PR changes.
  • Linux validation is pending. The fill-loop has not been run on Linux at all. That is the main reason this PR is a draft.

Superseded approach

The earlier revision of this branch carried 0001-vsock-don-t-shrink-rx-packets-to-the-last-bytes-of-peer-credit.patch, which waited for a credit update once credit fell below a 4 KiB floor. It was refuted by the A/B above — 0/6, indistinguishable from the stock control. The instrumentation explains why it could not have worked: the zero-credit path was reached 10 times in 40,749 reads and mean available credit was 239,848 bytes, so a credit floor was addressing a constraint that was not binding. It is dropped, not carried.

Wiring

Follows the existing local-patch convention (packages/abseil-cpp, packages/jemalloc, packages/zeromq) rather than the fetched-tarball shape packages/unzip uses:

  • build.ncl: two { file = "...patch" } | Local entries in build_deps, patch as a build dep, and a comment explaining what each patch does and when to drop them.
  • build.sh: two explicit patch -Np1 -i "<name>" lines, in order.

Ordering is explicit, not glob-derived. The patches are named individually so the sequence cannot depend on how a shell sorts a wildcard.

Failing loudly. build.sh already runs under set -ex. GNU patch exits non-zero on a rejected hunk, so a stale patch aborts the build. A silently-skipped patch would publish a libkrun that looks fixed and is not.

Verified vs assumed

Verified here

  • Byte-identity with gominimal/minimal#884, sha256 over both repos:
    • 0001…signal-the-used-queue…60044131235658786e699debfcc7469fdb4801d86644515741931e40041bd4a2
    • 0002…fill-the-rx-descriptor…9196245c5dc2c7319702372bb1877a1e07f3220023181c1522f2dc45b3b9aab2
  • The 1.19.4 tarball's sha256 is e8775fab2b460972a67ca6cd936296bb79cdb078d852d712a283cb290dd0b284, matching the pin in build.ncl, left unchanged.
  • That tarball's tree hash b2a84890000eeef60433712f99b95eab4141c845 is byte-identical to the tree of upstream commit 728df8125077d0db44265f6e997c72b81b65c015 — the commit the patches were written against. Settled by hash, not assumed.
  • Both patches apply cleanly, in sequence, to that tarball: git apply --check -p1 exit 0 for 0001 on the pristine tree, then exit 0 for 0002 on the 0001-patched tree. patch --dry-run -Np1 likewise. No fuzz, no offset warnings. 0002 was re-checked from scratch rather than assumed to apply because the dropped patch touched the same function region.
  • Full-layout simulation, driven by the literal patch -Np1 lines extracted from the committed build.sh (extract with strip_prefix = "libkrun-1.19.4", Local files at tree root): both apply, exit 0.
  • The resulting source contains the fill-loop (let mut total = 0; let ret = loop { … Err(Errno::EAGAIN) => …), update.signal_queue = true on the credit-request path in both unix.rs and tsi_stream.rs, zero occurrences of the dropped MIN_RX_PKT_PAYLOAD, and the original if max_len == 0 { return RecvPkt::WaitForCredit; } guard intact.
  • Errno is already imported in unix.rs (line 6, pre-existing), so 0002's Errno::EINTR/Errno::EAGAIN arms resolve.
  • File modes unchanged (build.sh 755, patches 644).

Not verified — assumed

  • Not built locally. This machine's min is the session CLI (0.5.0-rc2.dev.10), with no patched-build or check --packages; mip is not installed; no nickel binary, so build.ncl was not evaluated locally. CI is the gate.
  • Linux runtime validation is pending — the whole point of the draft status.
  • The A/B counts and the 40,749-packet histogram are carried over from gominimal/minimal#884; measured on macOS on a dev guest, not re-derived here.
  • SKB_TRUESIZE(0) == 576 on arm64 and the 455-skb ceiling come from the original investigation.
  • Adding build_deps entries changes the spec hash, so this forces a libkrun rebuild. Intended.

Status

Draft. Merging is the user's call — the fill-loop has not been validated on Linux, which is the platform this PR affects. Independent of gominimal/minimal#884; the two repos patch different build paths and can land in either order. Both files should be dropped once the fixes land in an upstream containers/libkrun release we can pin.

@coderabbitai

coderabbitai Bot commented Jul 22, 2026

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

You’ve reached a temporary PR review limit under our Fair Usage Limits Policy.

Your recent review volume is higher than typical usage, so adaptive limits are currently applied.

Next review available in: 10 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: b6127522-2372-4a61-8477-795f68e6af3b

📥 Commits

Reviewing files that changed from the base of the PR and between 817cf97 and 839db13.

📒 Files selected for processing (4)
  • packages/libkrun/0001-vsock-signal-the-used-queue-when-requesting-credit.patch
  • packages/libkrun/0002-vsock-fill-the-rx-descriptor-instead-of-one-recv-per-packet.patch
  • packages/libkrun/build.ncl
  • packages/libkrun/build.sh
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/libkrun-vsock-min-credit-floor

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

@norrietaylor
norrietaylor marked this pull request as draft July 22, 2026 03:33
@norrietaylor

Copy link
Copy Markdown
Member Author

Converting to draft — the patch this PR ports does not fix the bug.

Runtime validation of the same patch on macOS (gominimal/minimal#884, now also draft) came back negative. Same dev stack, same minvmd binary, libkrun built from the same pinned commit with the same script, only the dylib differing:

Build Runs Pass Fail
stock (control) 6 0 6
patched 6 0 6

The credit floor changes nothing. The likely reason the mechanism was misidentified: libkrun's packets are probably small because recv() returns only what is buffered in the host unix socket at that instant, not because peer_credit is clamping max_len — so gating on credit is the wrong lever. A fix would need to coalesce on available bytes, which is a different patch.

The build wiring in this PR (the { file = "…patch" } | Local convention, patch build-dep, and patch -Np1 in build.sh, all green in CI) is sound and worth keeping — it is only the patch content that is wrong. Holding this in draft until a validated patch exists to carry.

Tracked in gominimal/minimal#869.

…nting

A bulk host->guest stream over a unix-backed vsock port dies mid-upload
with ENOBUFS, surfacing as "Failed to upload project files: copying tar
stream to channel: channel closed".

recv_to_pkt() issued one recv() per RX descriptor and emitted whatever that
call happened to find buffered, so the muxer outran the writer and
fragmented the stream into sub-KiB packets. A Linux peer charges every
queued skb SKB_TRUESIZE(0) (576 bytes on arm64) against buf_alloc
regardless of payload, so those packets burn queue budget far faster than
they deliver bytes; virtio_transport_inc_rx_pkt() then rejects at
(queue_len + 1) * SKB_TRUESIZE(0) > buf_alloc and resets the connection
while credit is still outstanding.

Instrumented counters over 40,749 packets identified the constraint as
socket occupancy, not the peer's window: 74.1% of reads drained the socket
with descriptor space still free, mean 2,199 bytes available against mean
available credit of 239,848 of a 262,144-byte window, and the zero-credit
path was reached 10 times in 40,749 reads.

Carry two patches, applied by name in a fixed order because 0002 is written
against the tree 0001 produces:

  0001 sets signal_queue on the credit-request path. push_packet() places
  the request in the used ring but does not raise the IRQ, so a proxy that
  must wait for credit disarms its own polling and never wakes the peer to
  send the update it is waiting on. An independent bug worth fixing; it does
  not fix the upload on its own (measured 0/6).

  0002 loops until the RX descriptor is full or the socket returns EAGAIN.
  This is the fix.

An earlier revision of this branch carried a minimum-credit floor instead.
Runtime A/B refuted it -- 0/6 passes, identical to the stock control -- and
the instrumentation above explains why: credit was never the constraint.
That patch is dropped, not carried.

The patch texts are byte-identical to the ones the macOS source build
carries at vendor/libkrun/patches/ in gominimal/minimal. This change is the
Linux half: Linux takes libkrun from this package's release tarball, which
that repo cannot patch.

Verified here that both patches apply cleanly, in sequence, to the exact
pinned tarball (sha256 e8775fab..., whose tree is byte-identical to
upstream commit 728df812) under both `git apply --check -p1` and the
`patch -Np1` lines added to build.sh. Not built locally -- this machine has
neither `mip` nor a `min` with `patched-build`/`check`, so CI is the gate.

`patch` runs under the script's existing `set -e` and exits non-zero on a
rejected hunk, so a stale patch aborts the build rather than silently
publishing a libkrun that looks fixed and is not.

Refs: gominimal/minimal#869
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@norrietaylor
norrietaylor force-pushed the fix/libkrun-vsock-min-credit-floor branch from 4a4016f to 839db13 Compare July 22, 2026 03:59
@norrietaylor norrietaylor changed the title fix(libkrun): patch vsock RX to stop dribbling sub-KB packets fix(libkrun): patch vsock RX to fill the descriptor instead of fragmenting Jul 22, 2026
@norrietaylor
norrietaylor marked this pull request as ready for review July 22, 2026 04:49
@twitchyliquid64
twitchyliquid64 added this pull request to the merge queue Jul 22, 2026
Merged via the queue into main with commit 390f9d0 Jul 22, 2026
10 checks passed
@twitchyliquid64
twitchyliquid64 deleted the fix/libkrun-vsock-min-credit-floor branch July 22, 2026 05:20
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