Skip to content

fix(minvmd): carry libkrun vsock patches, fixing bulk host->guest uploads - #884

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

fix(minvmd): carry libkrun vsock patches, fixing bulk host->guest uploads#884
twitchyliquid64 merged 1 commit into
mainfrom
fix/libkrun-vsock-min-rx-credit

Conversation

@norrietaylor

@norrietaylor norrietaylor commented Jul 22, 2026

Copy link
Copy Markdown
Member

Takes the 12,784,144-byte project upload from 0/6 to 12/12. The credit-floor
patch this PR originally carried was refuted by measurement and has been
dropped; what replaces it is a different fix, aimed at the constraint that
actually binds.

Counts, per build

Every batch ran on the same dev stack (just up), the same minvmd binary, and
libkrun built from the same pinned commit with the same script — only the dylib
differed, swapped under a stopped VM and confirmed by lsof on the live VMM
process before and after each batch.

Build Runs Pass Fail
stock (control) 6 0 6
credit floor + IRQ fix (refuted, dropped) 6 0 6
IRQ fix + instrumentation 6 0 6
IRQ fix + descriptor fill-loop 6 6 0
IRQ fix + descriptor fill-loop (repeat) 6 6 0
stock, MINVMD_KRUN_LOG=debug 3 3 0
credit floor, MINVMD_KRUN_LOG=debug 3 3 0

The two debug rows are a confound, not a result: enabling libkrun's logger
perturbs timing enough to mask the bug on both builds. It is recorded because
it nearly produced a false positive, and because it disqualifies per-packet
logging as a diagnostic here.

Note the third row: the IRQ fix alone does not fix the upload. The fill-loop
does.

What the measurement showed

Per-packet logging dissolves the bug, so the discriminator was counters only —
no locks, allocation, or formatting on the data path, one summary per connection
at teardown, with FIONREAD sampled immediately before each recv() so
available bytes are directly comparable with max_len.

Instrument validated before being believed: it preserved the failure at 6/6.

Aggregate over the six failing 12.8 MB uploads — 40,749 packets:

Measure Value
mean payload 1895 B
reads under 1 KiB 14,515 (35.6%)
socket drained, descriptor space left 30,195 (74.1%)
descriptor filled, socket had more 10,530 (25.8%)
zero-credit path reached 10 of 40,749
mean bytes available (FIONREAD) 2,199 B
mean peer credit 239,848 B (of a 262,144 window)
in-flight at close 262,144 B — the entire window

Representative connection:

pkts=6828 bytes=12944350 min=16 max=3732 mean=1895
recv_calls=6828 under1k=2453 wait_credit=2
socket_limited=5069 credit_limited=1755 neither=4
avail_mean=2189 credit_mean=236528 inflight_at_close=262144 buf_alloc=262144
  payload_hist: 2^5:10 2^6:401 2^7:171 2^8:139 2^9:902 2^10:830 2^11:1492 2^12:2883
  avail_hist:   2^0:6  2^5:10  2^6:400 2^7:169 2^8:141 2^9:901 2^10:826 2^11:1492 2^12:1231 2^13:1652

The two histograms track each other almost exactly up to 2^11 (401/400,
171/169, 902/901, 830/826, 1492/1492): we read precisely what the socket
happened to hold.
That is the signature of socket-buffer-limited. They diverge
only at the top, where buf.len() (~4 KiB) caps a read that could have taken
4–8 KiB — the descriptor, still not credit.

Credit had ~109× more headroom than the socket had data, and the zero-credit
path fired 10 times in 40,749 reads. The peer's window was never the binding
constraint
, which is exactly why raising a credit floor did nothing.

One honest caveat on the counter names: credit_limited is computed against
max_len = min(buf.len(), peer_credit) and so conflates the two. Given
credit_mean ≈ 240 KB against a ~4 KiB buf.len(), that bucket is
descriptor-full, not credit-limited. Read it that way.

The fix (0002)

recv_to_pkt() issued one recv() per RX descriptor and shipped whatever that
call happened to find. Against a bulk sender delivering the stream in chunks,
the muxer outruns the writer and fragments it. Each fragment then costs the peer
a whole skb of queue accounting — a Linux receiver charges every queued packet
SKB_TRUESIZE(0) (576 B on arm64) against buf_alloc regardless of payload —
so sub-KiB packets burn queue budget far faster than they deliver bytes.

The patch loops until the descriptor is full or the socket returns EAGAIN.

On the level-triggered trap: polling registers EV_ADD without EV_CLEAR
(src/utils/src/macos/epoll.rs), so anything that declines to read available
data spins. A fill-loop is safe precisely because it never leaves readable data
behind on purpose: every exit either drained the socket or filled a descriptor,
so the next notification corresponds to genuinely new data or to a descriptor we
can immediately make progress on. A threshold-and-defer design would have spun —
which is what the dropped credit-floor patch did, and why it needed 0001 to stop
hanging.

EOF and errors encountered after some bytes are read deliver the data first and
re-report on the next call.

The IRQ fix (0001)

Independent of the upload bug and worth upstreaming on its own. A proxy that
runs out of credit sets WaitingCreditUpdate, emits VSOCK_OP_CREDIT_REQUEST,
and disarms polling on its host fd. But push_packet() only places the request
in the used ring — the IRQ comes solely from process_proxy_update() when
ProxyUpdate::signal_queue is set, and that is taken from recv_pkt()'s
have_used. When the first packet of a batch is the one that has to wait,
have_used is false: the peer is never woken, never sends the credit update,
and polling is already disarmed on our side.

Reachable on unpatched upstream, just rare — a batch normally pushes data
packets first, and those set signal_queue themselves. It was not rare under
the credit-floor patch, which turned it into a multi-minute hang; that hang is
how it was found. tsi_stream.rs has the identical construct and the identical
gap. Both fixed.

Build wiring

scripts/build-libkrun-macos.sh had no patch step. It now applies every
*.patch in vendor/libkrun/patches/, sorted, with git apply, after the
pinned commit is fetched and its SHA verified. Verified both directions: the
real patches apply cleanly and build; a deliberately broken patch aborts with
exit 1 and ::error::9999-bogus.patch does not apply to libkrun 728df812….
A silently-skipped patch is impossible.

Instrumentation: not in this PR, deliberately

The counter patch is a debugging aid, not shippable code. It writes to a file
path from an env var, bypasses log!/debug! on purpose (the macros are inert
unless the embedder installs a logger, and installing one masks the bug), and
adds a FIONREAD syscall per read. My recommendation is that it does not
belong in the repo
— it has no value except while this specific question is
open, and a checked-in debug patch in vendor/libkrun/patches/ would be applied
to every macOS build by the very mechanism this PR adds. It is preserved in the
investigation record; ask if you want it carried somewhere.

Trustworthiness of the A/B

  • Dev minvmd is ad-hoc signed (flags=0x2(adhoc), TeamIdentifier=not set) with
    no hardened runtime, so library validation does not apply — that is what makes
    swapping a locally built dylib possible at all.
  • LIBKRUN_PREFIX pointed at a private scratch dir. /opt/homebrew was never
    touched and never appeared in minvmd's rpath, and no libkrun* sat next to the
    binary, so neither a Homebrew copy nor @loader_path could mask a swap.
  • Every batch prints the live minvmd path and lsofs the dylib actually mapped
    into the __krun-vmm process, before and after; mapped size tracked every swap.
  • The stock control was rebuilt from the same pinned commit with the same script
    rather than reusing the shipped dylib, so arms differ only by the patches.
  • Dev stack provably distinct from installed: Server: minimald 0.5.0-rc1 vs
    0.5.0-rc2.dev.10.g4bfdcb09.
  • A concurrent process respawned the installed minvmd mid-run once, and the smoke
    test happily talked to it. Caught by checking the live binary; every batch now
    verifies before and after.

Verified vs assumed

Verified

  • The bridge port is served by UnixAcceptorProxyUnixProxy::new_reverse in
    unix.rs, not muxer.rs's UnixProxy::new path.
  • push_packet() does not raise the IRQ; only process_proxy_update does. The
    resulting stall was observed and the fix removed it.
  • Reads are socket-occupancy-limited, not credit-limited (numbers above).
  • Fill-loop: 12/12 pass across two independent batches, against 6/6 fail on
    stock and 6/6 on stock+IRQ-fix+instrumentation.
  • Instrument does not mask the bug (6/6 fail with it).
  • Both patches apply to 728df812… and build; a stale patch aborts the build.
  • Machine restored: installed dylib sha e2e8ebca… unchanged, installed stack
    live, Server: minimald 0.5.0-rc2.dev.10.g4bfdcb09, no MINVMD_* overrides,
    all validation sessions destroyed.

Assumed / not verified

  • SKB_TRUESIZE(0) == 576 on arm64 and the 455-skb ceiling — carried from the
    earlier investigation, not re-derived. The fix does not depend on the exact
    number, only on fragmentation being costly to the receiver.
  • No after-histogram. I did not rebuild instrumentation on top of the
    fill-loop to show packet sizes rising. The mechanism is inferred from the
    before-numbers plus the fix working, not directly observed post-fix.
  • Throughput impact unmeasured. The fill-loop should reduce syscalls and packets
    per byte, but no benchmark was run.
  • Only exercised on the dev guest (minimald 0.5.0-rc1, .scratch
    kernel/rootfs/initramfs). Not run against the installed guest, which cannot
    load an unsigned dylib.
  • 12/12 is a strong signal but this is a timing-sensitive failure; a soak would
    be more convincing than two batches of six.
  • tsi_dgram.rs shares the one-recv-per-descriptor shape; untouched, untested.
  • The fill-loop is applied only to UnixProxy. tsi_stream.rs has the same
    pattern and likely the same fragmentation, but it does not serve this port and
    was left alone to keep the change reviewable.

Note

Fix bulk host-to-guest vsock uploads by patching libkrun credit signaling and read aggregation

  • Adds a patch stage to scripts/build-libkrun-macos.sh that applies all patches under vendor/libkrun/patches/ to the pinned libkrun commit before building; fails the build if any patch does not apply.
  • Patch 0001 fixes a stall where a vsock proxy running out of credit would enqueue a credit request without signaling the used queue, leaving the peer waiting indefinitely.
  • Patch 0002 changes the vsock UnixProxy recv path to loop reads until the RX descriptor is full or the socket blocks, reducing packet fragmentation on bulk transfers.
  • Behavioral Change: vsock credit requests now always raise an interrupt, and host socket reads are batched per descriptor rather than one read per packet.

Macroscope summarized 5dc8cf0.

Summary by CodeRabbit

  • Bug Fixes

    • Improved macOS virtual socket reliability by ensuring queued updates trigger promptly.
    • Improved data transfer efficiency by filling receive buffers more effectively, reducing unnecessarily small packets.
    • Preserved correct handling of interruptions, drained sockets, connection closures, and receive errors.
  • Build

    • macOS builds now consistently apply required compatibility patches and fail with clear diagnostics if a patch cannot be applied.

@coderabbitai

coderabbitai Bot commented Jul 22, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 2be6d39f-feca-4553-a52d-79f875ac666e

📥 Commits

Reviewing files that changed from the base of the PR and between f03ced1 and 5dc8cf0.

📒 Files selected for processing (3)
  • scripts/build-libkrun-macos.sh
  • vendor/libkrun/patches/0001-vsock-signal-the-used-queue-when-requesting-credit.patch
  • vendor/libkrun/patches/0002-vsock-fill-the-rx-descriptor-instead-of-one-recv-per-packet.patch

📝 Walkthrough

Walkthrough

The macOS libkrun build now applies carried patches in sorted order. Two VSOCK patches improve credit-request signaling and RX descriptor filling, with explicit failure handling for rejected patches.

Changes

libkrun VSOCK patch integration

Layer / File(s) Summary
VSOCK credit signaling
vendor/libkrun/patches/0001-vsock-signal-the-used-queue-when-requesting-credit.patch
Credit requests now explicitly signal the used queue in both VSOCK proxy paths.
VSOCK RX descriptor filling
vendor/libkrun/patches/0002-vsock-fill-the-rx-descriptor-instead-of-one-recv-per-packet.patch
UnixProxy accumulates non-blocking reads into RX descriptors and preserves partial-read, retry, EOF, and error handling.
macOS patch application
scripts/build-libkrun-macos.sh
The build applies sorted patch files after commit verification and aborts with an error when application fails.

Estimated code review effort: 3 (Moderate) | ~25 minutes

Poem

A rabbit hops through patches neat,
Signals queues with twitching feet.
RX buffers fill instead of fray,
Rejected patches stop the way.
Dylibs bloom for launch-day cheer!

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title is concise, conventional, and accurately summarizes the main change: carrying libkrun vsock patches to fix bulk uploads.
Description check ✅ Passed The description covers the summary and testing in detail, with only the checklist/template formatting not fully mirrored.
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.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch

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

@norrietaylor
norrietaylor force-pushed the fix/libkrun-vsock-min-rx-credit branch from 7258ee5 to 5aa0de5 Compare July 22, 2026 03:30
@norrietaylor norrietaylor changed the title build(minvmd): patch libkrun's vsock RX to stop dribbling sub-KB packets build(minvmd): add a libkrun patch step, and an unproven vsock RX patch (negative result) Jul 22, 2026
@norrietaylor
norrietaylor marked this pull request as draft July 22, 2026 03:31
…oads

Adds the missing patch step to the macOS libkrun source build and carries
two vsock fixes against the pinned commit. Together they take the 12,784,144
byte project upload from 0/6 to 12/12 on the dev stack.

Build wiring: every *.patch in vendor/libkrun/patches/ is applied with
`git apply` after the pinned commit is fetched and its SHA verified. A patch
that no longer applies aborts the build; a silently-skipped patch would
stage a dylib that looks patched and is not. Drop a patch file once the fix
lands in a new pin.

0001 -- signal the used queue when requesting a credit update. A proxy that
runs out of credit emits VSOCK_OP_CREDIT_REQUEST and disarms polling on its
host fd, but push_packet() only places the request in the used ring; the IRQ
comes solely from process_proxy_update() when signal_queue is set, and
signal_queue is taken from recv_pkt()'s have_used. When the first packet of
a batch is the one that must wait, the peer is never woken, so it never
sends the credit update, and nothing on the host side wakes the connection
either. Reachable upstream today, just rare. tsi_stream.rs has the identical
gap; both are fixed.

0002 -- fill the RX descriptor instead of emitting one recv() per packet.
This is the fix for the upload failure. recv_to_pkt() issued a single recv()
per descriptor and shipped whatever happened to be buffered, so against a
bulk sender the muxer outran the writer and fragmented the stream. Measured
over a failing 12.8 MB transfer: 40,749 packets, mean payload 1895 B, 35.6%
of reads under 1 KiB, FIONREAD before each read averaging 2199 B while peer
credit averaged 239,848 of a 262,144-byte window, and the zero-credit path
reached 10 times in 40,749 reads. Socket occupancy was the binding
constraint on 74% of reads; the peer's window essentially never was.
Fragmentation is what kills the transfer, because a Linux receiver charges
every queued packet SKB_TRUESIZE(0) against buf_alloc regardless of payload.

An earlier credit-floor patch, written on the theory that the peer's window
was the constraint, was measured at 0/6 and is not carried -- the
instrumentation above is what refuted it.

macOS source build only. Linux takes libkrun from the gominimal/pkgs
packages/libkrun release tarball, which this repo cannot patch; that needs a
separate pkgs change.
@norrietaylor
norrietaylor force-pushed the fix/libkrun-vsock-min-rx-credit branch from 5aa0de5 to 5dc8cf0 Compare July 22, 2026 03:46
@norrietaylor norrietaylor changed the title build(minvmd): add a libkrun patch step, and an unproven vsock RX patch (negative result) fix(minvmd): carry libkrun vsock patches, fixing bulk host->guest uploads Jul 22, 2026
@norrietaylor
norrietaylor marked this pull request as ready for review July 22, 2026 03:47
@norrietaylor

Copy link
Copy Markdown
Member Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Jul 22, 2026

Copy link
Copy Markdown
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@norrietaylor

Copy link
Copy Markdown
Member Author

@coderabbitai full review

@coderabbitai

coderabbitai Bot commented Jul 22, 2026

Copy link
Copy Markdown
✅ Action performed

Full review finished.


You're currently rate limited under our Fair Usage Limits Policy. Your recent PR review activity is in the 95th percentile or higher among CodeRabbit users, so adaptive limits apply. Your next review will be available in 48 minutes.

@norrietaylor

Copy link
Copy Markdown
Member Author

@coderabbitai full review

@coderabbitai

coderabbitai Bot commented Jul 22, 2026

Copy link
Copy Markdown
✅ Action performed

Full review finished.

@twitchyliquid64
twitchyliquid64 merged commit 69b8776 into main Jul 22, 2026
29 checks passed
@twitchyliquid64
twitchyliquid64 deleted the fix/libkrun-vsock-min-rx-credit branch July 22, 2026 05:15
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