docs(spike): gvproxy v0.8.9 switch-attachment protocol for DM2 - #512
Conversation
Research spike resolving the open design question for U1-T2 (#496): - confirms /connect HTTP-hijack as the multi-client attachment path - corrects the SCM_RIGHTS assumption in spec R1.5 (no fd-passing) - documents HyperKit framing (2-byte LE length + raw Ethernet) - provides Rust relay sketch and netns L2/IP/route recipe - documents port-forward management API surface for Unit 2 Closes #511
|
Note Reviews pausedIt looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the Use the following commands to manage reviews:
Use the checkboxes below for quick actions:
📝 WalkthroughWalkthroughAdds a 588-line spike document ( Changesgvproxy v0.8.9 Attachment Spike
Estimated code review effort🎯 2 (Simple) | ⏱️ ~10 minutes Possibly related PRs
Suggested reviewers
Poem
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. Comment |
This comment has been minimized.
This comment has been minimized.
| { | ||
| let mut buf = vec![0u8; 1518 + 14]; // MTU + Ethernet header | ||
| loop { | ||
| let n = tap.read(&mut buf).await?; |
There was a problem hiding this comment.
MEDIUM — Correctness: tap_to_switch and switch_to_tap use ? but implicitly return (). The ? operator requires the enclosing function to return a type implementing FromResidual (i.e. Result or similar); () does not. These functions will not compile as written.
Fix — add an explicit Result return type and a terminal Ok(()):
async fn tap_to_switch<R, W>(mut tap: R, mut sock: W) -> anyhow::Result<()>
where
R: AsyncReadExt + Unpin,
W: AsyncWriteExt + Unpin,
{
let mut buf = vec![0u8; 1518 + 14];
loop {
let n = tap.read(&mut buf).await?;
if n == 0 { break; }
let len = (n as u16).to_le_bytes();
sock.write_all(&len).await?;
sock.write_all(&buf[..n]).await?;
}
Ok(())
}The same fix applies to switch_to_tap (line 280). With both functions returning anyhow::Result<()>, tokio::spawn yields JoinHandle<anyhow::Result<()>>, so tokio::try_join!(t1, t2)? in attach_to_switch must also unwrap the inner results:
let (r1, r2) = tokio::try_join!(t1, t2)?; // propagates JoinError
r1?; r2?; // propagates relay I/O errorsSince this sketch is the stated U1-T2 implementation target, the compile error will block the implementer immediately.
| alongside the PTask. | ||
|
|
||
| ```rust | ||
| use std::os::unix::net::UnixStream; |
There was a problem hiding this comment.
LOW — Correctness: std::os::unix::net::UnixStream is imported but never used — the code connects via tokio::net::UnixStream on line 239. This would produce an unused_imports warning. The import can be removed.
| sock.write_all(b"POST /connect HTTP/1.0\r\nHost: gvproxy\r\n\r\n").await?; | ||
|
|
||
| // 3. Wrap the TAP fd for async I/O. | ||
| let tap = unsafe { tokio::fs::File::from_raw_fd(tap_fd.into_raw_fd()) }; |
There was a problem hiding this comment.
LOW — Correctness: tokio::fs::File wraps all I/O through a blocking thread pool (spawn_blocking + pread/pwrite), not async I/O. A TAP character device does not support pread/pwrite (it requires plain read/write), and the thread-pool approach occupies a blocking thread per relay direction continuously.
For async-native TAP relay the correct abstraction is tokio::io::unix::AsyncFd with the fd set to non-blocking mode, then using readable()/writable() guards:
use tokio::io::unix::AsyncFd;
let tap_file = unsafe { std::fs::File::from_raw_fd(tap_fd.into_raw_fd()) };
tap_file.set_nonblocking(true)?;
let async_tap = AsyncFd::new(tap_file)?;This gives epoll-driven readiness notification rather than a blocking thread per fd. Worth noting for U1-T2 (#496) since the relay runs continuously for every OwnIp PTask.
…spike Three issues in the TAP relay sketch (U1-T2 implementation target): - Remove unused std::os::unix::net::UnixStream import (unused_imports warning) - Add anyhow::Result<()> return types and Ok(()) to tap_to_switch and switch_to_tap; propagate inner JoinHandle results from try_join! - Replace tokio::fs::File (uses pread/pwrite via blocking thread pool; TAP character devices do not support pread/pwrite) with tokio::io::unix::AsyncFd + non-blocking mode for epoll-driven I/O; relay functions use readable()/writable() guards + try_io; Arc shares the AsyncFd between the two relay tasks Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
|
Commit pushed:
|
This comment has been minimized.
This comment has been minimized.
| where | ||
| W: AsyncWriteExt + Unpin, | ||
| { | ||
| let mut buf = vec![0u8; 1518 + 14]; // MTU + Ethernet header |
There was a problem hiding this comment.
LOW · correctness — The comment // MTU + Ethernet header does not match the expression 1518 + 14.
- Max Ethernet frame a TAP device returns (kernel strips FCS, MTU=1500): 1514 bytes = 1500 (payload) + 14 (Ethernet header).
1518is the IEEE 802.3 wire maximum already including the 14-byte header; adding+ 14again gives 1532 and double-counts the header.
The over-sized buffer is harmless, but the comment misleads the implementer. When U1-T2 implements this relay, consider:
let mut buf = vec![0u8; 1518]; // max Ethernet frame excl. FCS; +4 covers 802.1Q VLANor 1514 if VLAN tagging is not expected on the gvproxy switch.
| sock.write_all(&len).await?; | ||
| sock.write_all(&buf[..n]).await?; |
There was a problem hiding this comment.
LOW · correctness — The 2-byte length prefix and frame payload are sent as two separate write_all calls.
If the unix socket closes between these two writes (e.g., gvproxy shuts down at the exact wrong moment), gvproxy will have consumed the 2-byte length but not the frame, permanently desynchronising its framing state for any subsequent reconnect on the same socket.
For the U1-T2 implementation, prefer a single vectored write or pre-allocate a combined buffer:
// Option A: vectored
sock.write_vectored(&[IoSlice::new(&len), IoSlice::new(&buf[..n])]).await?;
// Option B: prefix into buf
let mut framed = Vec::with_capacity(2 + n);
framed.extend_from_slice(&len);
framed.extend_from_slice(&buf[..n]);
sock.write_all(&framed).await?;This is a design note for the real implementation, not a bug in the spike sketch.
…y sketch - Correct 1518+14 to 1518 (the +14 double-counted the Ethernet header, which is already included in the 1518-byte IEEE 802.3 frame maximum) - Replace two-write send (length prefix then payload) with a single combined-buffer write_all to prevent framing desync if the gvproxy socket closes between the two writes Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
|
Commit pushed:
|
This comment has been minimized.
This comment has been minimized.
| -listen-bess string unixpacket socket (Bess-compatible; single client) | ||
| -listen-qemu string unix socket (Qemu protocol; single client) | ||
| -listen-vfkit string unixgram socket (vfkit-compatible; single client) | ||
| -listen-vpnkit string VPNKit socket (Hyperkit; multi-client accept loop) |
There was a problem hiding this comment.
MEDIUM — Correctness
The annotated flag table describes -listen-vpnkit as "VPNKit socket (Hyperkit; multi-client accept loop)", but the source code analysis directly below (line 133) correctly concludes that every non--listen interface — including -listen-vpnkit — accepts exactly one connection per gvproxy lifetime. The actual binary output in the Artifacts section also does not include "multi-client accept loop" in the flag description.
A reader scanning the flag table in isolation will form the wrong mental model: that -listen-vpnkit is multi-client when it is single-client and therefore unsuitable for a one-gvproxy-per-host design. Consider removing the "multi-client accept loop" parenthetical from the flag table and instead annotating it "(single-client: not suitable for DM2)" to match the analysis.
| ## 5. Port-forward / management API (gateway HTTP) | ||
|
|
||
| The HTTP API is served on the same unix socket as `/connect`. It is also served | ||
| internally within the virtual network on `http://<gatewayIP>:80` (accessible |
There was a problem hiding this comment.
MEDIUM — Security / Correctness
This paragraph states the management HTTP API is "served internally within the virtual network on (redacted) (accessible from within PTasks via the gateway's virtual IP)". However, 37 lines later (line 460) the document says the /leases, /cam, and /stats` endpoints are "accessible only from the host side (unix socket), not from within PTasks via the gateway."
These two claims contradict each other, and the contradiction is security-relevant: if POST /services/forwarder/expose or GET /leases is reachable from inside a PTask via (100.64.0.1/redacted) a rogue OwnIp` PTask could:
- expose arbitrary host ports to external traffic (via
/expose) - read the IP↔MAC mapping of every other PTask on the switch (via
/leases) - read switch topology (via
/cam)
Based on the gvproxy source cited (pkg/virtualnetwork/mux.go and services.go): Mux() registers /connect on the unix socket; ServicesMux() registers the management routes. The gateway's in-network HTTP on port 80 is separate from the unix-socket listener. The key question is whether the gateway's internal HTTP server (served by gvisor's netstack LinkEndpoint) routes to ServicesMux(). If it does not — if it only serves DNS/DHCP — then the unix-socket-only statement (line 460) is correct and the "accessible from within PTasks" claim here is wrong and should be removed.
This needs a concrete one-line clarification: either confirm which routes the gateway port-80 service actually serves, or remove the parenthetical "accessible from within PTasks via the gateway's virtual IP" and keep only the unix-socket statement.
| gateway, which does not own `100.64.0.3`). Relay-B receives the ARP reply | ||
| from PTask B and writes it to TAP-B (PTask B replies because it has that IP). | ||
| **Wait** — actually gvproxy's gateway (`LinkEndpoint`) is the ARP responder | ||
| for the gateway IP only. For PTask-to-PTask ARP: |
There was a problem hiding this comment.
LOW — Correctness
The UC6 ARP walkthrough has a false-start sentence that describes the wrong direction of frame delivery, then self-corrects with **Wait**. The initial sentence (lines 382–383) says "Relay-B receives the ARP reply from PTask B and writes it to TAP-B" — but relay-B writes to TAP-B to deliver frames inbound to PTask B, not to deliver PTask B's own replies. The ARP flow at this step is: relay-A delivers the ARP request to TAP-B (inbound to PTask B), PTask B then replies, and relay-B picks up that reply from TAP-B and forwards it to the switch — the opposite direction.
The corrected bullet list that follows is correct. But the presence of the false start before the **Wait** means a reader who stops at the first sentence of step 3 comes away with an inverted model of relay-B's role.
Suggestion: remove lines 382–385 (the false start and the **Wait** marker) and keep only the corrected bullet-point walkthrough, since it already covers the same content accurately.
- Fix -listen-vpnkit flag annotation: single-client, not multi-client - Clarify management API scope: unix-socket only, not reachable from within PTasks; the gateway's port-80 HTTP serves DNS/DHCP only and does not route ServicesMux() endpoints - Remove false-start sentence in UC6 ARP walkthrough (lines 382-385)
|
Commit pushed:
|
This comment has been minimized.
This comment has been minimized.
| } | ||
| } | ||
| } | ||
| Ok(()) |
There was a problem hiding this comment.
MEDIUM — Correctness
The outer loop in switch_to_tap (line 301) has no break statement — the only exits are via ? operators on lines 302, 305, and 310, which return Err(...) from the function, not from the loop. In Rust, a loop {} with no break has type ! (the "never" type). Code after a !-typed expression is unreachable.
This Ok(()) is therefore dead code and will trigger:
warning: unreachable code
Under cargo clippy --all-targets -- -D warnings (per CLAUDE.md), this becomes a hard error that will block U1-T2's build when this sketch is used as an implementation starting point.
Fix: remove Ok(()) and let the loop { } itself coerce to anyhow::Result<()> (the same pattern tap_to_switch uses — the outer loop there has a break and ends with Ok(()), but switch_to_tap has no break so Ok(()) is unreachable):
async fn switch_to_tap<R>(
mut sock: R,
tap: Arc<AsyncFd<std::fs::File>>,
) -> anyhow::Result<()>
where
R: AsyncReadExt + Unpin,
{
let mut size_buf = [0u8; 2];
loop {
sock.read_exact(&mut size_buf).await?;
let n = u16::from_le_bytes(size_buf) as usize;
let mut buf = vec![0u8; n];
sock.read_exact(&mut buf).await?;
loop {
let mut guard = tap.writable().await?;
match guard.try_io(|inner| inner.get_ref().write_all(&buf)) {
Ok(result) => { result?; break; }
Err(_) => continue,
}
}
}
// No Ok(()) here — loop has type `!`, coerces to anyhow::Result<()>
}| `tokio::net::UnixStream` handles the gvproxy socket. | ||
|
|
||
| 4. **IP allocation strategy** (R1.6): use `dhcpStaticLeases` with pre-assigned | ||
| MAC→IP entries rather than dynamic DHCP; minimald maintains the allocation |
There was a problem hiding this comment.
LOW — Correctness
Action item 4 says "pre-assigned MAC→IP entries" but the dhcpStaticLeases YAML format is keyed by IP (not MAC), as shown in the spike's own YAML example at lines 114–116:
dhcpStaticLeases:
"100.64.0.2": "52:54:00:00:00:02" # IP → MAC
"100.64.0.3": "52:54:00:00:00:03"This is also confirmed by the upstream source cited at the bottom of the spike: pkg/tap/ip_pool.go — Reserve(ip, mac) — where IP is the primary key.
An implementer reading "MAC→IP entries" may form the wrong mental model about which field is the YAML key when implementing the IP allocation table and YAML generation. Consider replacing "MAC→IP entries" with "IP→MAC entries (IP as key, MAC as value)" to match the actual dhcpStaticLeases schema.
Remove unreachable `Ok(())` from `switch_to_tap`: the outer `loop {}`
has no `break`, so its type is `!`; code after it is dead and would
be a hard clippy error under `-D warnings`. Replace with a comment
explaining the coercion.
Correct action item 4: `dhcpStaticLeases` is keyed by IP, not MAC;
change "MAC→IP entries" to "IP→MAC entries (IP as key, MAC as value)"
to match the YAML example and the upstream `Reserve(ip, mac)` API.
|
Commit pushed:
|
This comment has been minimized.
This comment has been minimized.
| // pread/pwrite; TAP character devices do not support pread/pwrite and | ||
| // require plain read/write with non-blocking mode + AsyncFd for epoll | ||
| // readiness notification. | ||
| let tap_file = unsafe { std::fs::File::from_raw_fd(tap_fd.into_raw_fd()) }; |
There was a problem hiding this comment.
LOW · correctness
The unsafe block is missing a // SAFETY: comment, which the project Rust coding standard requires (CLAUDE.md: "unsafe requires a // SAFETY: comment covering every caller invariant").
Since this sketch is the stated U1-T2 implementation target, it should model the project convention. The invariant to document is:
// SAFETY: tap_fd.into_raw_fd() transfers ownership of a valid, open, caller-owned
// file descriptor. File takes exclusive ownership and will close it on drop.
let tap_file = unsafe { std::fs::File::from_raw_fd(tap_fd.into_raw_fd()) };| where | ||
| W: AsyncWriteExt + Unpin, | ||
| { | ||
| let mut buf = vec![0u8; 1518]; // max Ethernet frame excl. FCS (+4 for 802.1Q VLAN) |
There was a problem hiding this comment.
LOW · correctness
The read buffer is hardcoded at 1518 bytes (max Ethernet frame at the default MTU of 1500 + 18 bytes of header/VLAN overhead). However, the documented binary flags include -mtu int (default 1500), so minimald could start gvproxy with a larger MTU.
If gvproxy is configured with a non-default MTU and the TAP device inherits that MTU, read() on the TAP fd may return frames larger than 1518 bytes. A buffer too small causes read() to return a truncated frame and discard the remainder (Linux silently drops the excess bytes for character devices).
For the implementation, derive the buffer size from the configured MTU:
// mtu: u16 passed from the gvproxy config (default 1500)
let mut buf = vec![0u8; mtu as usize + 18]; // +14 Ethernet header +4 VLAN tagIf the spike is intentionally scoped to the default MTU of 1500, a brief note here would clarify that assumption.
- Add // SAFETY: comment to the unsafe from_raw_fd call (CLAUDE.md requires a SAFETY comment on every unsafe block). - Replace inline buffer-size comment with a two-line block explaining the default-MTU assumption and noting that the real implementation should derive the size from the configured -mtu value. Addresses review comments r3448065115 and r3448065117.
|
Commit pushed:
|
This comment has been minimized.
This comment has been minimized.
| Additional management endpoints: | ||
|
|
||
| ``` | ||
| GET /leases → {ip: mac} DHCP lease table (MAC to IP mapping) |
There was a problem hiding this comment.
LOW / correctness
The GET /leases response is documented as {ip: mac} (IP as JSON key, MAC as value), but the parenthetical says "(MAC to IP mapping)" — these describe opposite key→value orderings.
Action Item 4 confirms that dhcpStaticLeases uses IP as key and MAC as value, which is consistent with {ip: mac}. If that is also the /leases JSON shape, the parenthetical should read "(IP to MAC mapping)" instead.
If the actual gvproxy /leases endpoint returns MAC as key and IP as value (the more conventional DHCP lease-table direction), then the format notation should be {mac: ip} and the parenthetical is correct. Either way, one of the two descriptions is inverted and should be reconciled when correcting R1.5 (Action Item 1) to avoid confusion in the U1-T2 implementation.
| the unix socket; queryable via `GET /services/forwarder/all`. DHCP leases | ||
| accessible via `GET /leases`. No per-client egress ACL in gvproxy v0.8.9. | ||
|
|
||
| # Action items |
There was a problem hiding this comment.
LOW / spec compliance
Issue #511's proof artifact specification listed five deliverables, the last being "residual risks / anything still needing a live trial." The spike answers all four questions via source reading and binary inspection (--help output), but does not explicitly enumerate what would need live netns/TAP testing before U1-T2 implementation begins.
Action Items 5 and 6 note design gaps (egress ACL, no -subnet flag) as forward work, but these are framed as "things to do next" rather than "findings that are source-confirmed but not live-verified." A reader handing this spike off to U1-T2 implementors has no clear list of what to smoke-test first.
Examples of things that are source-confirmed but not yet live-tested:
- The
dhcpStaticLeasesYAML format and static IP assignment behavior under an actual gvproxy process - The HyperKit frame relay timing / first-ARP exchange triggering correct CAM population
- Multi-client behaviour under concurrent PTask attachments
- The
-ssh-port -1suppression of the default forward when combined with-config
Adding a brief "Residual Risks" or "Live Trial Needed" section (even a short bulleted list) before or after the Action Items would complete the proof artifact as specified.
- Correct inverted key→value description on GET /leases: `{ip: mac}`
is IP to MAC mapping, not MAC to IP.
- Add "Residual Risks / Live Trial Needed" section enumerating four
source-confirmed but unverified behaviours (dhcpStaticLeases, first-ARP
CAM timing, multi-client concurrent attach, -ssh-port/-config interplay)
as requested by review comment r3448092754/r3448092755.
|
Commit pushed:
|
|
Commit pushed: Addressed two non-outdated review threads:
The SAFETY comment thread (r3448065115) was already resolved by commit
|
This comment has been minimized.
This comment has been minimized.
| 1. **Correct R1.5** in `docs/specs/03-spec-networking/03-spec-networking.md`: | ||
| replace "passing the tap file descriptor to the running gvproxy as a new | ||
| switch client. On DM2 the fd-pass is over a unix socket or SCM_RIGHTS" with | ||
| "minimald runs a relay goroutine per OwnIp PTask that bridges the netns TAP |
There was a problem hiding this comment.
LOW · Correctness
Action item 1 proposes adding the phrase "relay goroutine" to docs/specs/03-spec-networking/03-spec-networking.md. This is Go-specific terminology; minimald is a Rust codebase and the Rust sketch earlier in this document uses Tokio tasks, not goroutines.
When this action item is applied to the spec, downstream implementers reading the spec will see Go vocabulary in a Rust context. Suggested replacement for the proposed text:
"minimald runs an async relay task per OwnIp PTask that bridges the netns TAP
device to gvproxy via HTTP POST to `/connect` on the management unix socket,
using HyperKit framing (2-byte LE length prefix + raw Ethernet frames). No
SCM_RIGHTS or fd-passing is involved."
"Tokio task" or "async task" are the Rust equivalents of "goroutine".
| let mut sock = tokio::net::UnixStream::connect(api_sock).await?; | ||
|
|
||
| // 2. Send the HTTP "connect" request (raw, no response expected). | ||
| sock.write_all(b"POST /connect HTTP/1.0\r\nHost: gvproxy\r\n\r\n").await?; |
There was a problem hiding this comment.
LOW · Correctness
The Host header in the Rust sketch (Host: gvproxy) differs from the value shown in the prose handshake description at line 158 (Host: localhost). Since gvproxy ignores the host header on unix domain sockets, both are functionally equivalent — but the inconsistency between the descriptive example and the code sketch could mislead an implementer. Pick one value and use it consistently in both places.
- Host header: use `localhost` in both the prose handshake description and the Rust sketch (previously the sketch said `Host: gvproxy`). gvproxy ignores the Host header on unix sockets, so both are functionally equivalent, but the inconsistency was misleading. - Goroutine terminology: replace "relay goroutine" with "relay task" or "async relay task" throughout (diagram annotation, Conclusion, Action item 1 proposed spec text). minimald is Rust/Tokio, not Go; goroutine is Go-specific vocabulary. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
|
Commit pushed:
|
|
Commit pushed: Addressed two non-outdated review threads:
The threads for r3448065115 (SAFETY comment) and r3448092755 (Residual Risks section) were already addressed in commits
|
The handshake diagram showed "goroutine rx/tx" on minimald's side of the relay, but minimald is Rust and uses Tokio async tasks, not goroutines. Remaining two label instances were missed in the previous commit. Goroutine references to Go/gvproxy internals (e.g. httpServe accept loop, multi-goroutine concurrency note) are unchanged; those correctly describe the Go codebase being analysed. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
|
Commit pushed:
|
| create TAP device "tap-ptask-N" in netns | ||
| open TAP fd (O_RDWR on /dev/net/tun) | ||
| set MAC 52:54:00:00:00:NN on tap device | ||
| bring link up (SIOCNETIFFLAGS IFF_UP) |
There was a problem hiding this comment.
LOW — Correctness
The ioctl constant SIOCNETIFFLAGS does not exist in Linux. The correct constant for setting interface flags (to bring an interface up) is SIOCSIFFLAGS ("Set Interface FLAGS"). SIOCGIFFLAGS is the corresponding read-only getter.
A U1-T2 implementer using this handshake sketch to write the link-up sequence with nix or raw ioctl calls would find SIOCNETIFFLAGS undefined. Suggest correcting to SIOCSIFFLAGS IFF_UP.
| goroutine rx: read(tap_fd) → LE_u16(len) + frame → write(sock) | ||
| goroutine tx: read(sock) → LE_u16(len) → read(frame) → write(tap_fd) |
There was a problem hiding this comment.
LOW — Correctness
Commit d0b28fa ("unify Host header and replace goroutine with async task") updated the prose and topology diagram to use "async relay task" / "relay task" language, but these two labels in the step-by-step handshake diagram still use Go-specific "goroutine" terminology.
The rest of the document consistently uses "relay A", "relay B", "async relay task" — these two diagram lines are the only remaining occurrences of "goroutine". Suggest:
task rx: read(tap_fd) → LE_u16(len) + frame → write(sock)
task tx: read(sock) → LE_u16(len) → read(frame) → write(tap_fd)
| // readiness notification. | ||
| // SAFETY: tap_fd.into_raw_fd() transfers ownership of a valid, open, caller-owned | ||
| // file descriptor. File takes exclusive ownership and will close it on drop. | ||
| let tap_file = unsafe { std::fs::File::from_raw_fd(tap_fd.into_raw_fd()) }; |
There was a problem hiding this comment.
LOW — Correctness
The unsafe block calls std::fs::File::from_raw_fd() and OwnedFd::into_raw_fd(), both of which are trait methods (FromRawFd::from_raw_fd and IntoRawFd::into_raw_fd). Neither trait is in the sketch's use block (lines 227–230), so this block would fail to compile:
error[E0599]: no method named `into_raw_fd` found for struct `OwnedFd`
error[E0599]: no associated function `from_raw_fd` found for struct `File`
The real implementation will need:
use std::os::fd::{FromRawFd, IntoRawFd};(or the platform-specific std::os::unix::io::{FromRawFd, IntoRawFd} on older toolchains). Worth noting in the sketch so the U1-T2 implementer doesn't hit a confusing compile error.
This comment has been minimized.
This comment has been minimized.
…d imports
- Correct ioctl constant SIOCNETIFFLAGS → SIOCSIFFLAGS (IFF_UP), as
SIOCNETIFFLAGS does not exist in Linux; SIOCSIFFLAGS is the setter for
interface flags (r3448134147)
- Add missing `use std::os::fd::{FromRawFd, IntoRawFd}` to the Rust sketch
use block; without these trait imports `from_raw_fd` and `into_raw_fd`
cannot be resolved at compile time (r3448134149)
|
Commit pushed:
|
Validation findings — spike boundaryBoundary resolved: Spike ( Gate results
OutcomeSpike boundary passes clean. No Blockers, no Warnings. The final commit ( All four research questions are concretely answered with upstream source citations at v0.8.9 (commit
|
The gated step asserted an fd-pass that merged spike #512 proves does not exist (gvproxy attachment is an async TAP↔socket relay, no SCM_RIGHTS). The end-to-end relay proof + plumbing is split to #526; re-add the step there, redefined against the relay assertion. Unblocks minvmd-linux-kvm-e2e on #522. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…huttle (#522) * feat(minvmd): extend per-VM gvproxy to full PTask switch with vsock shuttle Establishes the DM1/DM3/DM4 network-mode type structure and the gvproxy switch supervisor in `minvmd` (Unit 1: R1.4, R1.5, R1.8). - VmConfig gains a `network_mode: NetworkMode` field (default `HostNet`) and a `with_network_mode` builder; the stale "no network device in v0.1" comment in `apply` is replaced with the switch-attachment note. - New `net` module supervises exactly one gvproxy process per host VM (`GvproxySwitch`), terminating it with the same SIGTERM -> timeout -> SIGKILL sequence as the vmm child (R1.4). Own-IP PTasks attach as switch clients, each assigned a unique IP from the switch subnet (default RFC-6598 100.64.0.0/16) over the per-PTask shuttle (R1.5). Every switch lifecycle event (spawn, stop, attach with IP, detach) is a structured `tracing` event; no `println!`/`eprintln!` (R1.8). - Adds a `VmEgressPolicy` stub aligned with the Unit 2 egress types. - Promotes the portable `minimald-rpc` wire-types crate from a dev-dependency to a dependency so `NetworkMode`/`IpProto` are usable in library code. The libkrun-hardware integration proof (booting a VM and asserting vsock IP assignment) is `#[ignore]` + env-gated per the spec's testing standard and requires `/dev/kvm`; the type-structure/supervisor proof runs in CI. Closes #497 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * ci(kvm): add gated DM1 vsock-shuttle proof step (U1-T3 #497) sdd-validate blocked PR #522: the declared DM1 hardware proof (libkrun VM + vsock-shuttle IP assignment) had no executable, observable CI path — the agent deferred the vsock fd-pass and ci-linux-kvm ran no such test. Add a MINVMD_INTEGRATION_TEST-gated step that runs `cargo test -p minvmd vsock -- --include-ignored` on this job's existing KVM+libkrun+kernel/rootfs setup. No-op (0 tests) until the #497 vsock fd-pass + proof test land; once they do, this step verifies them. Protected-path edit the execute agent cannot make. * fix(minvmd): guard switch subnet host address against u32 overflow Replace the unchecked u32::from(self.base) + index with checked_add and propagate None on overflow, matching the function's existing checked_shl and checked_sub style. Prevents a wrapped, incorrect IP for edge-case subnets with a high base address (e.g. a /8 near 255.0.0.0). Addresses a CodeRabbit review comment on #522. * ci(kvm): defer DM1 vsock proof step to #526 The gated step asserted an fd-pass that merged spike #512 proves does not exist (gvproxy attachment is an async TAP↔socket relay, no SCM_RIGHTS). The end-to-end relay proof + plumbing is split to #526; re-add the step there, redefined against the relay assertion. Unblocks minvmd-linux-kvm-e2e on #522. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(minvmd): log non-ESRCH kill failures during gvproxy teardown terminate_child discarded the libc::kill return value at both the SIGTERM and SIGKILL sites, so a failed signal delivery (e.g. EPERM, EINVAL) was silently swallowed with no log entry. Route both signals through a signal_child helper that checks the return value and emits a tracing::warn! on any errno other than the benign ESRCH (the already-exited race that is expected during teardown). Behaviour on the success and ESRCH paths is unchanged. Resolves the sdd-review libc::kill finding on net.rs. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(minvmd): make gvproxy switch teardown async and detect exit Address two validated review findings on the gvproxy switch supervisor. No blocking sleep in the teardown path: the SIGTERM -> grace -> SIGKILL sequence moves off the synchronous `terminate_child` (a `std::thread::sleep` poll loop reachable from `Drop`) into an async `GvproxySwitch::stop` that drives the grace period on the tokio timer (`tokio::time::timeout`). `Drop` is reduced to a non-blocking fire-and-forget fallback: it marks the exit intentional and SIGKILLs immediately, leaving the detached supervision task to reap the child. This satisfies the repo standard banning `std::thread::sleep` in async context (docs/rust-coding-standards.md). Detect unexpected gvproxy exit (R1.4 detection half): `spawn` now starts a background tokio supervision task that owns the child, awaits its exit, and on an unexpected exit emits `tracing::error!` and fires a `SwitchExit` notification returned from `spawn` for callers to react. Scope is detection + logging + the channel only; the PTask-teardown consumer of that signal is deferred to #526. Promotes `tokio` from a dev-dependency to a dependency for the async supervision. Confined to the `minvmd` crate. Refs: #497 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * docs(minvmd): clarify SwitchExit::recv None semantics The recv() doc claimed None means only an intentional teardown, but the supervise_switch wait()-failure path also drops exit_tx without sending, yielding None. Document that None covers both the intentional stop/Drop case and the rare supervision failure (no ExitStatus exists to report on a wait() error; that path is logged via tracing::error!). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: gominimal-aw-bot[bot] <281738952+gominimal-aw-bot[bot]@users.noreply.github.com> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> Co-authored-by: Norrie Taylor <norrie@minimal.dev>
…okio::fs minvmd/net.rs: - SwitchSubnet::new now returns Result<Self, InvalidPrefixError>; rejects prefix 0 and >32, both of which make every host() call return None silently - Add attached_count: Arc<AtomicU32> to GvproxySwitch; attach_ptask increments it and clones the Arc into each PtaskAttachment - PtaskAttachment::Drop decrements the count and fires SIGTERM when the last attachment is released (R1.4 "stop when the last own-IP PTask exits") - detach_ptask now consumes PtaskAttachment by value; Drop handles teardown - SwitchExit::recv doc already covered None-vs-supervision-failure semantics minimald/net/mod.rs: - Add NetError::InvalidPrefix(u8); SwitchSubnet::new returns it instead of SubnetExhausted for an out-of-range prefix (misconfig != runtime exhaustion) - write_config converted from sync fn to async fn using tokio::fs to avoid blocking the tokio worker thread (no-blocking-in-async rule) - Stale-socket removal in ensure_running converted to tokio::fs::remove_file - Control-socket cleanup in stop() converted to tokio::fs::remove_file Tests added for each validation path; proof artifacts: - cargo test -p minvmd -p minimald (new prefix-validation and auto-stop tests) - cargo clippy -p minvmd -p minimald --all-targets -- -D warnings (async paths) Refs #526, #535, #512
) The pin was 9 days and 62 commits stale (c854d6b1, 2026-07-20). The motivating change is gominimal/pkgs#534, which rebuilds microvm-rootfs from Alpine and drops the glibc closure — but the pin is linear, so this necessarily carries everything before it too. Materially in range: - microvm-rootfs from Alpine (#534) — 186 MB -> 45 MB - two libkrun vsock fixes: RX descriptor fill (#506) and packet-count backpressure (#512), both on the path minvmd depends on - rust 1.97.1 (#502) and glibc 2.44 (#529) in the build stack The rest is routine package version bumps. Pinned at the branch tip rather than at #534's commit: pinning mid-history buys nothing here, and the three commits after it are a bottom bump, a graphviz bump, and a license-metadata fix. Verified on this host: `mip materialize --arch aarch64 minvmd-rootfs` resolves from cache and yields a 47,212,544-byte ext4 image whose only interpreter is /lib/ld-musl-aarch64.so.1 — no glibc. The same image has already been booted and driven through the session e2e (cold activate 4296 ms, warm ls 16 ms, sandbox proof 8030 ms), and A/B'd for cold-boot latency against the outgoing rootfs: median 129 ms vs 154 ms, n=10 each, non-overlapping. This changes the guest for macOS as well, which already ships the payload; the VM lanes are the gate. Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Summary
/connect+ HyperKit-framed Ethernet relay, no fd-passingProof artifact
docs/spikes/2026-06-21-gvproxy-attachment.md— a committed spike file that exists only after this PR lands.Full findings:
-subnetCLI flag/connecton unix socket, hijacked, HyperKit framing (2-byte LE uint16 length + raw Ethernet frame)lodown givesENETUNREACHon all egressPOST /services/forwarder/expose+ JSON body on the management unix socketTest plan
docs/spikes/2026-06-21-gvproxy-attachment.mdexists with correct frontmatter (status: proved)cargo fmt && cargo test -- --include-ignoredis green (no code changes; docs-only diff)docs/specs/03-spec-networking/03-spec-networking.mdCloses #511
🤖 Generated with [Claude Code]((claude.com/redacted)
Summary by CodeRabbit
/leases,/cam,/stats) and notes the current lack of per-client egress ACL filtering.