diff --git a/CHANGELOG.md b/CHANGELOG.md index 3feea9cbfe..39ceaaf943 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,10 +14,23 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). `hl_result_from_*` constructors. * Place virtqueue rings and pools in host-owned scratch before page tables. Snapshot ABI 3 rejects snapshots created with earlier layouts. +* Require guest logs and all host and guest function calls to use virtqueues. +* Keep registered Rust guest return values typed until transport encoding so + external byte results avoid intermediate FlatBuffer copies. +* Store canonical virtqueue rings in versioned OCI transport layers. Config v2 + rejects snapshots without transport state. +* Running snapshots checkpoint dirty virtqueues before capture. Ordinary calls + keep their deferred result path. +* Reject snapshot capture while guest-owned transport buffers are retained. +* Use the reclaimed stack pages to raise the default G2H and H2G pools to 12 + and 8 pages. ### Removed +* Remove legacy stack I/O, its `GuestHandle` methods, and its sandbox + configuration and builder options. ### Fixed +* Keep sandboxes usable after an H2G request exceeds available virtqueue capacity. ## [v0.17.0] - 2026-08-27 diff --git a/Justfile b/Justfile index 558cc62e01..8b608d0e78 100644 --- a/Justfile +++ b/Justfile @@ -240,7 +240,7 @@ test-loom: # runs tests that requires being run separately, for example due to global state test-isolated target=default-target features="" : {{ cargo-cmd }} test {{ if features =="" {''} else if features=="no-default-features" {"--no-default-features" } else {"--no-default-features -F " + features } }} --profile={{ if target == "debug" { "dev" } else { target } }} {{ target-triple-flag }} -p hyperlight-host --lib -- sandbox::uninitialized::tests::test_log_trace --exact --ignored - {{ cargo-cmd }} test {{ if features =="" {''} else if features=="no-default-features" {"--no-default-features" } else {"--no-default-features -F " + features } }} --profile={{ if target == "debug" { "dev" } else { target } }} {{ target-triple-flag }} -p hyperlight-host --lib -- sandbox::outb::tests::test_log_outb_log --exact --ignored + {{ cargo-cmd }} test {{ if features =="" {''} else if features=="no-default-features" {"--no-default-features" } else {"--no-default-features -F " + features } }} --profile={{ if target == "debug" { "dev" } else { target } }} {{ target-triple-flag }} -p hyperlight-host --lib -- sandbox::outb::tests::test_log_emit_guest_log --exact --ignored {{ cargo-cmd }} test {{ if features =="" {''} else if features=="no-default-features" {"--no-default-features" } else {"--no-default-features -F " + features } }} --profile={{ if target == "debug" { "dev" } else { target } }} {{ target-triple-flag }} -p hyperlight-host --test integration_test -- log_message --exact --ignored @# CPU vendor check, gated to known CI runner hardware {{ cargo-cmd }} test {{ if features =="" {''} else if features=="no-default-features" {"--no-default-features" } else {"--no-default-features -F " + features } }} --profile={{ if target == "debug" { "dev" } else { target } }} {{ target-triple-flag }} -p hyperlight-host --lib -- sandbox::snapshot::file::config::tests::cpu_vendor_current_is_recognized --exact --ignored @@ -524,7 +524,7 @@ coverage-run hypervisor="kvm": ensure-cargo-llvm-cov # isolated tests (require running separately due to global state) cargo +nightly test -p hyperlight-host --lib -- sandbox::uninitialized::tests::test_log_trace --exact --ignored - cargo +nightly test -p hyperlight-host --lib -- sandbox::outb::tests::test_log_outb_log --exact --ignored + cargo +nightly test -p hyperlight-host --lib -- sandbox::outb::tests::test_log_emit_guest_log --exact --ignored cargo +nightly test -p hyperlight-host --test integration_test -- log_message --exact --ignored cargo +nightly test -p hyperlight-host --no-default-features -F function_call_metrics,{{ if hypervisor == "mshv3" { "mshv3" } else { "kvm" } }} --lib -- metrics::tests::test_metrics_are_emitted --exact diff --git a/docs/README.md b/docs/README.md index d991737565..9682b92691 100644 --- a/docs/README.md +++ b/docs/README.md @@ -35,6 +35,7 @@ This project is composed internally of several components, depicted in the below * [Security guidance for developers](./security-guidance-for-developers.md) * [Paging Development Notes](./paging-development-notes.md) +* [Virtqueue host and guest communication](./virtio-host-guest-communication.md) * [How to debug a Hyperlight guest](./how-to-debug-a-hyperlight-guest.md) * [How to use Flatbuffers in Hyperlight](./how-to-use-flatbuffers.md) * [How to make a Hyperlight release](./how-to-make-releases.md) diff --git a/docs/paging-development-notes.md b/docs/paging-development-notes.md index da08f6f24b..7d2bbbdcd2 100644 --- a/docs/paging-development-notes.md +++ b/docs/paging-development-notes.md @@ -139,13 +139,10 @@ calls, i.e. there may be no calls in flight at the time of snapshotting. This is not enforced, but odd things may happen if it is violated. -Buffer management between the host and guest is needed to pass call -arguments and return values. Ideally, buffers would be dynamically -allocated from the scratch region as needed. - -Currently, I/O buffers are statically allocated at the bottom of the -scratch region. This is a stopgap pending improved -physical allocation and buffer management. +Host and guest calls use two virtqueues in a fixed transport arena at +the bottom of scratch. The arena contains both rings and their +fixed-slot buffer pools. Copied page tables follow the arena. Dynamic +scratch allocations begin after the copied page tables. The minimum scratch size is calculated by `min_scratch_size()` in the architecture-specific layout modules under `hyperlight_common`; see @@ -177,4 +174,3 @@ paging) and enables PAE. The guest is always entered in long mode. Hyperlight unconditionally uses 48-bit virtual addresses. Hyperlight presently only uses addresses in the lower (ttbr0) half of the address range. - diff --git a/docs/snapshot-oci-format.md b/docs/snapshot-oci-format.md index e77b892b94..c66a2d2b8c 100644 --- a/docs/snapshot-oci-format.md +++ b/docs/snapshot-oci-format.md @@ -24,21 +24,28 @@ path/ Hyperlight config JSON raw memory bytes (`memory_size` bytes) + canonical virtqueue rings ``` -Three blob kinds per tag: +Four blob kinds per tag: * **manifest** (`application/vnd.oci.image.manifest.v1+json`). Tiny JSON pointer record selected via `index.json`. References one config and - one layer by digest. + two layers by digest. * **config** (`application/vnd.hyperlight.snapshot.config.v2+json`). The snapshot descriptor: arch, hypervisor, CPU vendor, ABI version, resume address and captured registers, memory and transport layout, - registered host functions, snapshot generation counter. Loaded + registered host functions, and snapshot generation counter. Loaded eagerly and fully parsed. * **layer / memory** (`application/vnd.hyperlight.snapshot.memory.v1`). The raw guest memory image, exactly `memory_size` bytes. mmap'd on restore. +* **layer / transport** + (`application/vnd.hyperlight.snapshot.transport.v1`). A bounded + binary image of the canonical G2H and H2G rings. + +The runtime queue protocol and canonical checkpoint are described in +[Virtqueue host and guest communication](./virtio-host-guest-communication.md). Blob filenames are the sha256 of the blob bytes, so identical blobs across tags are stored once. @@ -55,8 +62,8 @@ A single saved `Snapshot` consists of exactly: config blob for tooling visibility, * one **manifest** blob (referenced by that index entry), * one **config** blob (referenced by the manifest's `config` field), -* one **layer** blob (the only entry in the manifest's `layers` - array, holding the raw memory image). +* one memory **layer** blob, +* one transport **layer** blob. Saving two snapshots under different tags into the same `path` produces two index entries and two manifests. Configs and layers are @@ -98,12 +105,12 @@ podman), `go-containerregistry` (crane), and `regclient`. ## Read semantics `Snapshot::load(path, reference)` reads a snapshot. It does not check -the manifest, config, or snapshot blobs against their sha256 digests. +the manifest, config, memory, or transport blobs against their sha256 digests. `reference` is an [`OciReference`], either a tag that matches the `org.opencontainers.image.ref.name` annotation or the manifest digest returned by `save`. `Snapshot::checked_load` adds the digest -check on those three blobs, catching accidental corruption on disk. +check on all four blobs, catching accidental corruption on disk. Both run every other check (OCI structure, descriptor sizes, schema versions, arch / hypervisor / CPU vendor / ABI tags, layout bounds, entrypoint bounds). The caller is responsible for trusting the source. diff --git a/docs/snapshot-versioning.md b/docs/snapshot-versioning.md index 783813bba7..b281e28f02 100644 --- a/docs/snapshot-versioning.md +++ b/docs/snapshot-versioning.md @@ -7,22 +7,26 @@ existing snapshots loadable, or while rejecting them with a clear error. ## What is versioned -A snapshot carries three independently evolvable version markers: +A snapshot carries four independently evolvable version markers: * **Memory blob ABI**, `SNAPSHOT_ABI_VERSION` (a `u32` inside the config blob, defined in [src/hyperlight_host/src/sandbox/snapshot/file/media_types.rs](../src/hyperlight_host/src/sandbox/snapshot/file/media_types.rs)). This is what the host reads back from a snapshot: the `OutBAction` - and `VmAction` port numbers, the input and output buffer stack - format, the offset and size of each memory region (including the - `HyperlightPEB` size), and the calling convention for guest function - entry. A change to any of these breaks older snapshots unless the - loader adds a compat path. + and `VmAction` port numbers, the virtqueue transport layout, the + offset and size of each memory region (including the `HyperlightPEB` + size), and the calling convention for guest function entry. A change + to any of these breaks older snapshots unless the loader adds a + compat path. * **Snapshot blob encoding**, `MT_SNAPSHOT_V1` (`application/vnd.hyperlight.snapshot.memory.v1`), aliased as `MT_SNAPSHOT_CURRENT`. This is the on-wire format of the snapshot blob: framing, section ordering, alignment, dirty/zero-page elision, anything about how the bytes are packed inside the OCI layer. +* **Transport blob encoding**, `MT_TRANSPORT_V1` + (`application/vnd.hyperlight.snapshot.transport.v1`), aliased as + `MT_TRANSPORT_CURRENT`. This is the binary encoding of canonical + virtqueue state stored outside the memory layer. * **Config schema**, `MT_CONFIG_V2` (`application/vnd.hyperlight.snapshot.config.v2+json`), aliased as `MT_CONFIG_CURRENT`. This is the JSON shape of the config blob: @@ -30,7 +34,7 @@ A snapshot carries three independently evolvable version markers: needs in order to reconstruct the sandbox (memory sizes, buffer sizes, `abi_version`, `hyperlight_version`, etc.). Renaming a field, changing its type, or adding a required field is a schema change and - bumps this constant. + bumps this constant. Version 2 requires a transport layer. The `OCI_LAYOUT_VERSION` constant is pinned by the OCI image-layout spec at `1.0.0`. diff --git a/docs/virtio-host-guest-communication.md b/docs/virtio-host-guest-communication.md new file mode 100644 index 0000000000..7e8f5df141 --- /dev/null +++ b/docs/virtio-host-guest-communication.md @@ -0,0 +1,444 @@ +# Virtqueue host and guest communication + +Hyperlight transports typed function calls over two shared memory VIRTIO +packed virtqueues. It uses the packed ring layout and ownership rules, but it +is not a discoverable VIRTIO device. Queue configuration, arena placement, and +notification behavior are part of the Hyperlight ABI. + +This document describes the runtime transport, snapshot checkpoint, retention +mailbox, and placement constraints. + +## Architecture + +The guest is the driver (producer) for both queues. The host is the device +(consumer) for both queues. + +```text + Guest Host + + G2H producer === G2H packed ring and buffer pool ===> G2H consumer + + H2G producer === H2G packed ring and buffer pool ===> H2G consumer +``` + +Producer ownership describes who publishes descriptors. It does not always +describe the direction in which payload bytes move. + +* **G2H** carries guest requests, guest function results, and logs. Guest + readable descriptors carry bytes to the host. A guest call to a host + function also includes writable descriptors in the same chain for the host + response. +* **H2G** carries host requests to guest functions and internal control + requests. The guest preposts writable buffers. The host fills and completes + them before entering the VM. + +Two queues keep directional validation and capacity independent. H2G always +contains uniform preposted receive buffers. G2H supports readable messages and +optional writable response capacity. + +## Transport arena + +Both rings, the checkpoint mailbox, and both pools occupy one fixed prefix of +guest scratch memory. + +```text + scratch base + | + v + +----------+-----+----------+-----+-----+-----+----------+----------+ + | G2H ring | pad | H2G ring | pad | mbx | pad | G2H pool | H2G pool | + +----------+-----+----------+-----+-----+-----+----------+----------+ +``` + +The host derives this layout from `SandboxConfiguration`. Ring starts follow +packed ring alignment rules. The mailbox is `u64` aligned. Pools are page +aligned. + +The default layout is: + +| Region | Default size or capacity | +|---|---:| +| G2H ring | 64 descriptors | +| H2G ring | 32 descriptors | +| Mailbox | one `u64` | +| G2H pool | 12 pages | +| H2G pool | 8 pages | +| Arena | 21 pages total | + +Offsets after the G2H ring depend on configured queue sizes and pool pages. +`TransportArena` addresses are GPAs. The guest converts them to scratch GVAs +when constructing rings and pools. Descriptor buffer addresses are GVAs. + +The configured upper buffer size is 4 KiB by default. The G2H pool uses two +slot tiers: + +* The first page contains sixteen 256 byte slots for control messages and + logs. +* Complete configured size slots occupy the remaining pages. + +The lower tier is a memory efficiency optimization. Most control messages, +scalar function arguments, and scalar results fit in a small slot. Giving each +of them a full upper slot would waste most of that slot and reduce the number +of concurrent allocations the pool can hold. + +G2H senders allocate the header and control prefix separately when the external +byte stream aligns to the upper slot size. A small prefix uses a lower slot +while the external payload fills complete upper slots. Unaligned streams stay +combined to avoid adding a descriptor. + +The H2G pool contains uniform configured size slots. The same tier selection +does not fit its preposted receive model. The guest publishes writable buffers +before it knows the size of the next host written payload. Uniform slots let +the host calculate how many buffers it needs without negotiating a size class +or searching the ring. + +Queue sizes, upper buffer sizes, and pool page counts are configurable when +the sandbox is created. + +### Initialization + +The host writes the normalized queue sizes, pool page counts, buffer sizes, +and arena GPA into fixed metadata at the top of scratch. It creates both +consumers at cursor zero without reading uninitialized ring contents. + +On the first VM entry, the guest: + +1. Reads the published configuration. +2. Reconstructs `TransportArena`. +3. Converts each transport GPA into its scratch GVA. +4. Creates both packed ring producers and slot pools. +5. Prefills H2G with one writable descriptor per available H2G slot, bounded + by queue size. +6. Publishes the resulting `GuestContext`. + +The host consumers observe the descriptors after guest initialization. + +## Wire format + +Every logical message has this byte layout: + +```text + +----------------+-----------------------------+---------------------+ + | MsgHeader | size-prefixed FlatBuffer | external byte data | + | 12 bytes | control data | zero or more values | + +----------------+-----------------------------+---------------------+ +``` + +`MsgHeader` contains: + +* `kind: u8` +* three reserved zero bytes +* `cid: u32` +* `payload_len: u32` + +`payload_len` covers the control data and all external bytes. RPC correlation +IDs are nonzero. Responses echo the request ID. Logs and snapshot checkpoints +use ID zero. + +The active message kinds are: + +* `Request` +* `Response` +* `Log` +* `SnapshotCheckpoint` + +The FlatBuffer holds the typed function call or result and the lengths of +external byte values. External bytes follow it in the same logical message. +A logical message may span several descriptors or several H2G receive buffers. + +### External byte values + +`ByteChunks` values stay outside the FlatBuffer. The FlatBuffer contains the +total logical value length and whether the value is chunked. The encoder can +then reference the caller's byte slices directly without first copying them into +one contiguous FlatBuffer. + +On the guest, completed shared memory allocations can become +`Bytes::from_owner` values. `ByteChunks` can therefore map transport storage +directly and keep its pool slots allocated until the final `Bytes` owner +drops. `VecBytes` deliberately copies into one contiguous `Vec`. The host +also copies every G2H external value before passing it to host code because +guest writable scratch is untrusted. External values remove intermediate +serialization copies. They do not guarantee that every direction is +end-to-end zero copy. + +C guest function parameters expose `ByteChunks` as a borrowed +`hl_ByteChunks` array. Each `hl_ByteChunk` contains a pointer and length. The +descriptor array is allocated, but its payload pointers reference the +underlying `Bytes` directly. The view is valid until the guest function +returns. `hl_get_host_return_value_as_ByteChunks` returns an owning view that +must be released with `hl_free_byte_chunks`. Chunk arrays produced by C are +copied by `hl_result_from_ByteChunks`. + +The wire format does not preserve the sender's `Vec` boundaries. It +records one total length, not each source chunk length. The receiver sees the +logical byte sequence split where it intersects transport buffers: + +```text + sender chunks: [------][----------][----] + logical byte stream: [------------------------] + transport buffers: [--][--------][--------][--------] + receiver chunks: [--][--------][--------][--------] +``` + +H2G chunking follows the preposted H2G slot size. G2H responses returned to +the guest follow the G2H writable slot size. The message header and FlatBuffer +can consume part of the first slot. The final slot can also be partial. + +## Host calls a guest function + +```text + Host H2G Guest + | | | + | encode Request(cid) | | + | fill posted buffers ----+---------------------->| + | complete buffers | poll and decode | + | | run guest call | + | | | + |<----------------------- G2H Response(cid) ------| + | poll after guest halt | +``` + +The complete flow is: + +1. The host encodes a `FunctionCall` and external values. +2. The host polls enough H2G receive buffers for the complete message. +3. The host writes the message and completes each buffer. +4. The host enters the VM. +5. The guest polls completed H2G buffers, reconstructs the message, and + invokes the registered guest function. +6. The guest submits a G2H `Response` with the same correlation ID. +7. The guest refills H2G and halts without notifying for the deferred response. +8. The host polls G2H, decodes the result, and completes the chain. + +An H2G request containing external bytes must leave one posted buffer +available. This reserve allows a later control call to release retained guest +values. + +## Guest calls a host function + +```text + Guest G2H Host + | | | + | Request(cid) | | + | readable request -------+---------------------->| + | writable reply buffers | copy and decode | + | OUT notification | run host function | + | | | + |<---------------- same chain completed ----------| + | poll and decode Response(cid) | +``` + +The complete flow is: + +1. The guest encodes a `FunctionCall`. The G2H producer allocates its readable + regions and reserves writable response capacity. +2. The guest submits one G2H chain. Its readable region contains the request. + Its writable region reserves the response. +3. The guest notifies the host through `OutBAction::VirtqNotify`. +4. The host polls G2H and copies all request data out of guest writable scratch. +5. The host invokes the registered host function. +6. The host writes a `Response` into the writable region and completes the + same chain. +7. The VM resumes. The guest polls the completion and checks its correlation + ID. + +The host never retains references into guest scratch. It verifies framing, +copies control and external data into host owned values, then invokes host +code. + +Logs use readable G2H chains without writable response capacity. The host +drains and acknowledges them during the same VM exit. + +## Buffer ownership + +Guest `SlotPool` instances own all transport buffers. Pool clones share one +allocation bitmap with each producer. + +```text + Free -> allocated -> published -> completed -> owner-backed Bytes -> Free +``` + +Some stages are skipped by one-way messages. Final ownership matters for +external `ByteChunks`: + +* H2G `ByteChunks` can retain host written receive slots after a guest function + returns. +* G2H host responses can become owner-backed guest `Bytes`. +* `VecBytes` values copy into a contiguous `Vec`. +* Multiple `Bytes` clones or slices backed by one owner keep one slot live. +* The slot returns to the pool when the final owner drops. + +Producer reset releases allocations still owned by queue bookkeeping. After +both producers reset and before H2G prefill, every live pool slot belongs to +guest retained `Bytes`. + +### Trust boundary + +The host treats guest rings, descriptors, headers, FlatBuffers, and payload +lengths as untrusted. + +* Ring and pool access use separately bounded memory views. +* H2G descriptors must be writable, single buffer chains of the configured + size before the host writes to them. +* G2H control and external values are copied into host owned storage before + host code receives them. +* Canonical snapshot validation checks descriptor structure, addresses, + lengths, alignment, pool bounds, uniqueness, and overlap. + +## Snapshot checkpoint + +The transport arena lives in scratch and is not captured as ordinary guest +memory. Guest producer and pool bookkeeping is normal guest state, while ring +and pool bytes live in scratch. Snapshot capture needs a canonical transport +state. + +`MultiUseSandbox` tracks whether queue traffic occurred after the last +canonical boundary. A cached or clean snapshot needs no VM entry. A dirty +snapshot uses this flow: + +```text + Host Guest + | | + | mailbox = u64::MAX | + | H2G SnapshotCheckpoint ------------>| + | enter VM | + | | reclaim completed G2H work + | | reset G2H producer + | | reset H2G producer + | | count live pool slots + | | publish mailbox count + | | prefill H2G + |<------------------------------------| halt + | reset both consumers | + | read mailbox | + | validate and capture rings | +``` + +The canonical state is: + +* G2H is empty at cursor zero. +* H2G starts at cursor zero with one writable descriptor per complete free + slot, bounded by queue size. +* Guest producer and pool bookkeeping matches the rings. +* Driver and device event suppression is normalized. +* Host consumers start at cursor zero. + +The snapshot stores normal guest memory plus the two canonical ring images. +The OCI representation places ring images in the +[transport layer](./snapshot-oci-format.md). Pool payload bytes, the mailbox, +and host consumer cursors are not stored. + +### Restore + +Restore validates the persisted queue configuration, scratch size, ring +lengths, canonical descriptor structure, H2G slot alignment, pool bounds, and +descriptor overlap before exposing either queue. + +It writes the arena GPA metadata and both ring images into fresh scratch, then +attaches new host consumers at cursor zero. Normal guest memory restores the +matching producer and pool bookkeeping. Restore does not need a preparatory VM +entry. + +## Retention mailbox + +The mailbox is one `u64` in the ring to pool alignment gap. It is outside both +rings and pools. Both sides derive its address from trusted arena geometry. +The host accesses it before VM entry and after guest halt. + +The mailbox avoids a G2H checkpoint response. G2H can remain empty in the +canonical image even when retained G2H slots reduce available capacity. + +Before a dirty checkpoint, the host writes `u64::MAX` as a pending marker. +After producer reset, the guest writes: + +```text +g2h_pool.num_live() + h2g_pool.num_live() +``` + +The host reads the value after a successful guest halt and after resetting +both consumers. + +* `u64::MAX` is a fatal incomplete checkpoint. +* Zero permits snapshot capture. +* A nonzero count rejects capture without poisoning the sandbox. + +A nonzero rejection leaves the queues usable and keeps transport dirty. +Guest code can release retained values and retry the snapshot. + +The count only answers whether retained slots exist. It does not contain pool +identity, addresses, or initialized lengths. Retained pool payloads cannot be +restored because pool bytes are absent from the snapshot. + +## Future guest allocated pools and retained snapshots + +Transport pools can leave the fixed arena and use guest allocated scratch. +The rings and mailbox remain at fixed host assigned addresses. At startup, the +guest allocates each complete pool with `alloc_phys_pages`. It allocates fresh +pools when the snapshot generation changes. + +The host accepts descriptor payloads anywhere in guest allocator scratch. It +validates complete ranges, writable H2G buffers, uniqueness, and overlap. Ring +access remains restricted to the fixed arena. + +Pool GVAs are transient and cannot back retained `Bytes` directly. Before +constructing owner backed `Bytes`, the guest maps the buffer's physical pages +at a stable GVA in a reserved alias region. The `Bytes` pointer uses that +alias. The final owner unmaps the alias before returning the slot to its pool. + +Stable aliases make retained payloads ordinary snapshot mappings. Snapshot +capture copies each mapped physical page into snapshot memory while preserving +its alias GVA. Multiple aliases to one physical page share one copied page. +Owner construction clears the unused slot tail. Checkpointing clears free +slots in pools with retained owners, so captured pages contain retained bytes +and zeros. + +Pool backing belongs to one snapshot generation. Retained owners keep the old +pool metadata and stable aliases. After restore, the host enters the guest +without an H2G request. The guest resets both producers, allocates fresh pools, +prefills H2G, and returns before the host uses the restored queues. + +## Placement and relocation limitations + +Arena placement is host owned. `SandboxMemoryLayout` places it at the scratch +base, publishes the GPA, and requires the guest to reconstruct that exact +layout. Host attachment rejects any published arena base that differs from the +configured address. The guest cannot choose placement around its other scratch +allocations. + +The transport also stores absolute guest virtual addresses in descriptors, +pool owners, and guest producer state. Restore adopts the snapshot's scratch +size, queue geometry, transport addresses, and scratch mapping. The target +sandbox may begin with a different layout. + +### Retained virtual addresses + +Pool relocation cannot transparently change a retained buffer's GVA. +`Bytes::from_owner` stores an absolute data pointer. Its clones and slices can +exist anywhere in guest state. Unsafe Rust and C guests can also retain raw +pointers derived from a live value. The host cannot discover and rebase every +such pointer during restore. + +Wrapping `Bytes` does not solve this because the wrapped `Bytes` still contains +an absolute pointer. A relocatable value would need to replace `Bytes` with an +arena relative handle that resolves its address on every access and does not +promise a stable borrowed slice. That would be a different guest API and would +not constrain pointers created by unsafe code. + +Snapshots containing retained transport values must restore each pool at the +same GVA. The GPA or host backing may move only if page tables and host memory +access preserve that GVA. Restore must fail if it cannot reserve or recreate +the original virtual range. + +Transport capacity is also fixed when the sandbox is created. Runtime queue +resize and VIRTIO feature negotiation are not supported. + +## Source map + +* Shared framing: [`src/hyperlight_common/src/transport.rs`](../src/hyperlight_common/src/transport.rs) +* Packed rings and pools: [`src/hyperlight_common/src/virtq`](../src/hyperlight_common/src/virtq) +* Arena layout: [`src/hyperlight_common/src/layout.rs`](../src/hyperlight_common/src/layout.rs) +* Guest transport: [`src/hyperlight_guest/src/transport`](../src/hyperlight_guest/src/transport) +* Guest initialization: [`src/hyperlight_guest_bin/src/transport.rs`](../src/hyperlight_guest_bin/src/transport.rs) +* Host runtime transport: [`src/hyperlight_host/src/mem/mgr.rs`](../src/hyperlight_host/src/mem/mgr.rs) +* Host validation and snapshots: [`src/hyperlight_host/src/mem/virtq`](../src/hyperlight_host/src/mem/virtq) diff --git a/fuzz/fuzz_targets/guest_trace.rs b/fuzz/fuzz_targets/guest_trace.rs index 9cb05050fb..a76828fd49 100644 --- a/fuzz/fuzz_targets/guest_trace.rs +++ b/fuzz/fuzz_targets/guest_trace.rs @@ -53,9 +53,7 @@ impl<'a> Arbitrary<'a> for FuzzInput { // Any unexpected errors from the guest should be reported. fuzz_target!( init: { - // In local tests, 256 KiB seemed sufficient for deep recursion let mu_sbox = SandboxBuilder::from_file(simple_guest_for_fuzzing_as_pathbuf()) - .scratch_size(256 * 1024) .build() .unwrap(); diff --git a/fuzz/fuzz_targets/host_call.rs b/fuzz/fuzz_targets/host_call.rs index 6dba3ec466..c32fb18165 100644 --- a/fuzz/fuzz_targets/host_call.rs +++ b/fuzz/fuzz_targets/host_call.rs @@ -7,7 +7,8 @@ use std::sync::{Mutex, OnceLock}; use hyperlight_common::flatbuffer_wrappers::guest_error::ErrorCode; use hyperlight_host::func::{ParameterValue, ReturnType}; -use hyperlight_host::{HyperlightError, MultiUseSandbox, SandboxBuilder}; +use hyperlight_host::sandbox::SandboxConfiguration; +use hyperlight_host::{GuestBinary, HyperlightError, MultiUseSandbox, UninitializedSandbox}; use hyperlight_testing::simple_guest_for_fuzzing_as_pathbuf; use libfuzzer_sys::fuzz_target; @@ -17,12 +18,17 @@ static SANDBOX: OnceLock> = OnceLock::new(); // For fuzzing efficiency, we create one Sandbox and reuse it for all fuzzing iterations. fuzz_target!( init: { - let mu_sbox = SandboxBuilder::from_file(simple_guest_for_fuzzing_as_pathbuf()) - .output_data_size(64 * 1024) // 64 KB output buffer - .input_data_size(64 * 1024) // 64 KB input buffer - .scratch_size(512 * 1024) // large scratch region to contain those buffers, any data copies, etc. - .build() - .unwrap(); + let mut cfg = SandboxConfiguration::default(); + cfg.set_heap_size(512 * 1024); + cfg.set_g2h_pool_pages(16); + cfg.set_h2g_pool_pages(16); + cfg.set_scratch_size(512 * 1024); + let u_sbox = UninitializedSandbox::new( + GuestBinary::FilePath(simple_guest_for_fuzzing_as_pathbuf()), + Some(cfg) + ) + .unwrap(); + let mu_sbox: MultiUseSandbox = u_sbox.evolve().unwrap(); SANDBOX.set(Mutex::new(mu_sbox)).unwrap(); }, @@ -37,6 +43,7 @@ fuzz_target!( // to call with. HyperlightError::HostFunctionNotFound(_) => {} HyperlightError::GuestError(ErrorCode::HostFunctionError, msg) if msg == format!("HostFunction {} was not found", host_func_name) => {} + HyperlightError::GuestError(ErrorCode::HostFunctionError, msg) if msg == "Host response exceeds virtqueue capacity" => {} HyperlightError::UnexpectedNoOfArguments(_, _) => {}, HyperlightError::GuestError(ErrorCode::HostFunctionError, msg) if msg.contains("The number of arguments to the function is wrong") => {} HyperlightError::ParameterValueConversionFailure(_, _) => {}, diff --git a/src/hyperlight_common/src/arch/aarch64/layout.rs b/src/hyperlight_common/src/arch/aarch64/layout.rs index d466455e8f..307bccae7f 100644 --- a/src/hyperlight_common/src/arch/aarch64/layout.rs +++ b/src/hyperlight_common/src/arch/aarch64/layout.rs @@ -15,9 +15,6 @@ pub const fn io_page() -> Option<(crate::vmem::PhysAddr, crate::vmem::VirtAddr)> Some((IO_PAGE_GPA, IO_PAGE_GVA)) } -pub(super) fn min_scratch_size(input_data_size: usize, output_data_size: usize) -> Option { - input_data_size - .checked_add(output_data_size)? - .checked_next_multiple_of(crate::vmem::PAGE_SIZE)? - .checked_add(12 * crate::vmem::PAGE_SIZE) +pub(super) fn min_scratch_size() -> Option { + 12usize.checked_mul(crate::vmem::PAGE_SIZE) } diff --git a/src/hyperlight_common/src/arch/amd64/layout.rs b/src/hyperlight_common/src/arch/amd64/layout.rs index d6c0c98951..c4759d0048 100644 --- a/src/hyperlight_common/src/arch/amd64/layout.rs +++ b/src/hyperlight_common/src/arch/amd64/layout.rs @@ -28,10 +28,6 @@ pub fn io_page() -> Option<(u64, u64)> { /// - A page for the smallest possible non-exception stack /// - (up to) 3 pages for mapping that /// - Two pages for the exception stack and metadata -/// - A page-aligned amount of memory for I/O buffers -pub(super) fn min_scratch_size(input_data_size: usize, output_data_size: usize) -> Option { - input_data_size - .checked_add(output_data_size)? - .checked_next_multiple_of(crate::vmem::PAGE_SIZE)? - .checked_add(12 * crate::vmem::PAGE_SIZE) +pub(super) fn min_scratch_size() -> Option { + 12usize.checked_mul(crate::vmem::PAGE_SIZE) } diff --git a/src/hyperlight_common/src/flatbuffer_wrappers/function_call.rs b/src/hyperlight_common/src/flatbuffer_wrappers/function_call.rs index 6cb272bc58..849820f52d 100644 --- a/src/hyperlight_common/src/flatbuffer_wrappers/function_call.rs +++ b/src/hyperlight_common/src/flatbuffer_wrappers/function_call.rs @@ -4,21 +4,20 @@ use alloc::string::{String, ToString}; use alloc::vec::Vec; -use anyhow::{Error, Result, bail}; +use anyhow::{Result, bail}; use flatbuffers::{FlatBufferBuilder, WIPOffset, size_prefixed_root}; #[cfg(feature = "tracing")] use tracing::{Span, instrument}; use super::codec::{ExternalValueSink, ExternalValueSource}; -use super::function_types::{ParameterValue, ReturnType, decode_external_parameter_value}; -use super::util::{byte_chunks_to_bytes, try_byte_chunks_len}; +use super::function_types::{ParameterValue, ReturnType, decode_parameter_value}; +use super::util::try_byte_chunks_len; use crate::flatbuffers::hyperlight::generated::{ FunctionCall as FbFunctionCall, FunctionCallArgs as FbFunctionCallArgs, FunctionCallType as FbFunctionCallType, Parameter, ParameterArgs, - ParameterValue as FbParameterValue, hlbool, hlboolArgs, hlbytechunks, hlbytechunksArgs, - hldouble, hldoubleArgs, hlexternalbytes, hlexternalbytesArgs, hlfloat, hlfloatArgs, hlint, - hlintArgs, hllong, hllongArgs, hlstring, hlstringArgs, hluint, hluintArgs, hlulong, - hlulongArgs, hlvecbytes, hlvecbytesArgs, + ParameterValue as FbParameterValue, hlbool, hlboolArgs, hldouble, hldoubleArgs, + hlexternalbytes, hlexternalbytesArgs, hlfloat, hlfloatArgs, hlint, hlintArgs, hllong, + hllongArgs, hlstring, hlstringArgs, hluint, hluintArgs, hlulong, hlulongArgs, }; /// The type of function call. @@ -63,166 +62,8 @@ impl FunctionCall { self.function_call_type.clone() } - /// Encodes self into the given builder and returns the encoded data. - /// - /// # Notes - /// - /// The builder should not be reused after a call to encode, since this function - /// does not reset the state of the builder. If you want to reuse the builder, - /// you'll need to reset it first. - pub fn encode<'a>(&self, builder: &'a mut FlatBufferBuilder) -> &'a [u8] { - let function_name = builder.create_string(&self.function_name); - - let function_call_type = match self.function_call_type { - FunctionCallType::Guest => FbFunctionCallType::guest, - FunctionCallType::Host => FbFunctionCallType::host, - }; - - let expected_return_type = self.expected_return_type.into(); - - let parameters = match &self.parameters { - Some(p) if !p.is_empty() => { - let parameter_offsets: Vec> = p - .iter() - .map(|param| match param { - ParameterValue::Int(i) => { - let hlint = hlint::create(builder, &hlintArgs { value: *i }); - Parameter::create( - builder, - &ParameterArgs { - value_type: FbParameterValue::hlint, - value: Some(hlint.as_union_value()), - }, - ) - } - ParameterValue::UInt(ui) => { - let hluint = hluint::create(builder, &hluintArgs { value: *ui }); - Parameter::create( - builder, - &ParameterArgs { - value_type: FbParameterValue::hluint, - value: Some(hluint.as_union_value()), - }, - ) - } - ParameterValue::Long(l) => { - let hllong = hllong::create(builder, &hllongArgs { value: *l }); - Parameter::create( - builder, - &ParameterArgs { - value_type: FbParameterValue::hllong, - value: Some(hllong.as_union_value()), - }, - ) - } - ParameterValue::ULong(ul) => { - let hlulong = hlulong::create(builder, &hlulongArgs { value: *ul }); - Parameter::create( - builder, - &ParameterArgs { - value_type: FbParameterValue::hlulong, - value: Some(hlulong.as_union_value()), - }, - ) - } - ParameterValue::Float(f) => { - let hlfloat = hlfloat::create(builder, &hlfloatArgs { value: *f }); - Parameter::create( - builder, - &ParameterArgs { - value_type: FbParameterValue::hlfloat, - value: Some(hlfloat.as_union_value()), - }, - ) - } - ParameterValue::Double(d) => { - let hldouble = hldouble::create(builder, &hldoubleArgs { value: *d }); - Parameter::create( - builder, - &ParameterArgs { - value_type: FbParameterValue::hldouble, - value: Some(hldouble.as_union_value()), - }, - ) - } - ParameterValue::Bool(b) => { - let hlbool = hlbool::create(builder, &hlboolArgs { value: *b }); - Parameter::create( - builder, - &ParameterArgs { - value_type: FbParameterValue::hlbool, - value: Some(hlbool.as_union_value()), - }, - ) - } - ParameterValue::String(s) => { - let val = builder.create_string(s.as_str()); - let hlstring = - hlstring::create(builder, &hlstringArgs { value: Some(val) }); - Parameter::create( - builder, - &ParameterArgs { - value_type: FbParameterValue::hlstring, - value: Some(hlstring.as_union_value()), - }, - ) - } - ParameterValue::VecBytes(v) => { - let vec_bytes = builder.create_vector(v); - let hlvecbytes = hlvecbytes::create( - builder, - &hlvecbytesArgs { - value: Some(vec_bytes), - }, - ); - Parameter::create( - builder, - &ParameterArgs { - value_type: FbParameterValue::hlvecbytes, - value: Some(hlvecbytes.as_union_value()), - }, - ) - } - ParameterValue::ByteChunks(v) => { - let value = byte_chunks_to_bytes(v); - let vec_bytes = builder.create_vector(value.as_ref()); - let hlbytechunks = hlbytechunks::create( - builder, - &hlbytechunksArgs { - value: Some(vec_bytes), - }, - ); - Parameter::create( - builder, - &ParameterArgs { - value_type: FbParameterValue::hlbytechunks, - value: Some(hlbytechunks.as_union_value()), - }, - ) - } - }) - .collect(); - Some(builder.create_vector(¶meter_offsets)) - } - _ => None, - }; - - let function_call = FbFunctionCall::create( - builder, - &FbFunctionCallArgs { - function_name: Some(function_name), - parameters, - function_call_type, - expected_return_type, - }, - ); - builder.finish_size_prefixed(function_call, None); - builder.finished_data() - } - - /// Encodes byte parameters as external markers and sends their payloads to - /// `external_values` in parameter order. - pub fn encode_external<'a, 'b, S>( + /// Encode control data and collect byte parameters as external values. + pub fn encode<'a, 'b, S>( &'a self, builder: &'b mut FlatBufferBuilder, external_values: &mut S, @@ -399,9 +240,8 @@ impl FunctionCall { Ok(builder.finished_data()) } - /// Decodes a function call using `external_values` for external byte - /// markers. - pub fn decode_external(value: &[u8], external_values: &mut S) -> Result + /// Decode control data and consume external byte parameters. + pub fn decode(value: &[u8], external_values: &mut S) -> Result where S: ExternalValueSource + ?Sized, { @@ -422,7 +262,7 @@ impl FunctionCall { .map(|parameters| { parameters .iter() - .map(|parameter| decode_external_parameter_value(parameter, external_values)) + .map(|parameter| decode_parameter_value(parameter, external_values)) .collect::>>() }) .transpose()?; @@ -437,64 +277,6 @@ impl FunctionCall { } } -#[cfg_attr(feature = "tracing", instrument(err(Debug), skip_all, parent = Span::current(), level= "Trace"))] -pub fn validate_guest_function_call_buffer(function_call_buffer: &[u8]) -> Result<()> { - let guest_function_call_fb = size_prefixed_root::(function_call_buffer) - .map_err(|e| anyhow::anyhow!("Error reading function call buffer: {:?}", e))?; - match guest_function_call_fb.function_call_type() { - FbFunctionCallType::guest => Ok(()), - other => { - bail!("Invalid function call type: {:?}", other); - } - } -} - -#[cfg_attr(feature = "tracing", instrument(err(Debug), skip_all, parent = Span::current(), level= "Trace"))] -pub fn validate_host_function_call_buffer(function_call_buffer: &[u8]) -> Result<()> { - let host_function_call_fb = size_prefixed_root::(function_call_buffer) - .map_err(|e| anyhow::anyhow!("Error reading function call buffer: {:?}", e))?; - match host_function_call_fb.function_call_type() { - FbFunctionCallType::host => Ok(()), - other => { - bail!("Invalid function call type: {:?}", other); - } - } -} - -impl TryFrom<&[u8]> for FunctionCall { - type Error = Error; - #[cfg_attr(feature = "tracing", instrument(err(Debug), skip_all, parent = Span::current(), level= "Trace"))] - fn try_from(value: &[u8]) -> Result { - let function_call_fb = size_prefixed_root::(value) - .map_err(|e| anyhow::anyhow!("Error reading function call buffer: {:?}", e))?; - let function_name = function_call_fb.function_name(); - let function_call_type = match function_call_fb.function_call_type() { - FbFunctionCallType::guest => FunctionCallType::Guest, - FbFunctionCallType::host => FunctionCallType::Host, - other => { - bail!("Invalid function call type: {:?}", other); - } - }; - let expected_return_type = function_call_fb.expected_return_type().try_into()?; - - let parameters = function_call_fb - .parameters() - .map(|v| { - v.iter() - .map(|p| p.try_into()) - .collect::>>() - }) - .transpose()?; - - Ok(Self { - function_name: function_name.to_string(), - parameters, - function_call_type, - expected_return_type, - }) - } -} - #[cfg(test)] mod tests { use alloc::collections::VecDeque; @@ -569,6 +351,7 @@ mod tests { #[test] fn read_from_flatbuffer() -> Result<()> { let mut builder = FlatBufferBuilder::new(); + let mut external_values = TestExternalValues::default(); let test_data = FunctionCall::new( "PrintTwelveArgs".to_string(), Some(vec![ @@ -588,9 +371,9 @@ mod tests { FunctionCallType::Guest, ReturnType::Int, ) - .encode(&mut builder); + .encode(&mut builder, &mut external_values)?; - let function_call = FunctionCall::try_from(test_data)?; + let function_call = FunctionCall::decode(test_data, &mut external_values)?; assert_eq!(function_call.function_name, "PrintTwelveArgs"); assert!(function_call.parameters.is_some()); let parameters = function_call.parameters.unwrap(); @@ -616,32 +399,7 @@ mod tests { } #[test] - fn embedded_byte_parameters_round_trip_as_distinct_logical_types() { - let mut builder = FlatBufferBuilder::new(); - let parameters = vec![ - ParameterValue::VecBytes(vec![1, 2, 3]), - ParameterValue::ByteChunks(vec![Bytes::from_static(&[4, 5]), Bytes::from_static(&[6])]), - ]; - let encoded = FunctionCall::new( - "bytes".to_string(), - Some(parameters), - FunctionCallType::Host, - ReturnType::VecBytes, - ) - .encode(&mut builder); - - let decoded = FunctionCall::try_from(encoded).unwrap(); - assert_eq!( - decoded.parameters, - Some(vec![ - ParameterValue::VecBytes(vec![1, 2, 3]), - ParameterValue::ByteChunks(vec![Bytes::from_static(&[4, 5, 6])]), - ]) - ); - } - - #[test] - fn external_byte_parameters_round_trip_in_parameter_order() { + fn byte_parameters_round_trip_in_external_value_order() { let mut builder = FlatBufferBuilder::new(); let expected_parameters = vec![ ParameterValue::Int(7), @@ -667,9 +425,7 @@ mod tests { ReturnType::ByteChunks, ); let mut external_values = TestExternalValues::default(); - let encoded = call - .encode_external(&mut builder, &mut external_values) - .unwrap(); + let encoded = call.encode(&mut builder, &mut external_values).unwrap(); assert!(encoded.len() < 4096); assert_eq!( @@ -695,13 +451,12 @@ mod tests { ] { let parameter = encoded_parameters.get(index); assert_eq!(parameter.value_type(), FbParameterValue::hlexternalbytes); - let marker = parameter.value_as_hlexternalbytes().unwrap(); - assert_eq!(marker.length(), length); - assert_eq!(marker.chunked(), chunked); + let metadata = parameter.value_as_hlexternalbytes().unwrap(); + assert_eq!(metadata.length(), length); + assert_eq!(metadata.chunked(), chunked); } - assert!(FunctionCall::try_from(encoded).is_err()); - let decoded = FunctionCall::decode_external(encoded, &mut external_values).unwrap(); + let decoded = FunctionCall::decode(encoded, &mut external_values).unwrap(); assert_eq!(decoded.function_name, "external_bytes"); assert_eq!(decoded.parameters, Some(expected_parameters)); assert_eq!(decoded.function_call_type(), FunctionCallType::Host); @@ -710,7 +465,7 @@ mod tests { } #[test] - fn external_encoding_matches_embedded_encoding_without_byte_parameters() { + fn scalar_call_uses_no_external_values() { let call = FunctionCall::new( "scalars".to_string(), Some(vec![ @@ -720,17 +475,13 @@ mod tests { FunctionCallType::Guest, ReturnType::Bool, ); - let mut embedded_builder = FlatBufferBuilder::new(); - let embedded = call.encode(&mut embedded_builder).to_vec(); - - let mut external_builder = FlatBufferBuilder::new(); + let mut builder = FlatBufferBuilder::new(); let mut external_values = TestExternalValues::default(); - let external = call - .encode_external(&mut external_builder, &mut external_values) - .unwrap(); + let encoded = call.encode(&mut builder, &mut external_values).unwrap(); - assert_eq!(external, embedded); assert!(external_values.values.is_empty()); + let decoded = FunctionCall::decode(encoded, &mut external_values).unwrap(); + assert_eq!(decoded.function_name, "scalars"); } #[test] @@ -743,27 +494,25 @@ mod tests { ReturnType::Void, ); let mut encoded_values = TestExternalValues::default(); - let encoded = call - .encode_external(&mut builder, &mut encoded_values) - .unwrap(); + let encoded = call.encode(&mut builder, &mut encoded_values).unwrap(); let mut missing = TestExternalValues::default(); - assert!(FunctionCall::decode_external(encoded, &mut missing).is_err()); + assert!(FunctionCall::decode(encoded, &mut missing).is_err()); let mut wrong_type = TestExternalValues::from_values([TestExternalValue::ByteChunks(vec![ Bytes::from_static(b"123"), ])]); - assert!(FunctionCall::decode_external(encoded, &mut wrong_type).is_err()); + assert!(FunctionCall::decode(encoded, &mut wrong_type).is_err()); let mut wrong_length = TestExternalValues::from_values([TestExternalValue::VecBytes(vec![1, 2])]); - assert!(FunctionCall::decode_external(encoded, &mut wrong_length).is_err()); + assert!(FunctionCall::decode(encoded, &mut wrong_length).is_err()); let mut extra = TestExternalValues::from_values([ TestExternalValue::VecBytes(vec![1, 2, 3]), TestExternalValue::VecBytes(Vec::new()), ]); - assert!(FunctionCall::decode_external(encoded, &mut extra).is_err()); + assert!(FunctionCall::decode(encoded, &mut extra).is_err()); } } diff --git a/src/hyperlight_common/src/flatbuffer_wrappers/function_types.rs b/src/hyperlight_common/src/flatbuffer_wrappers/function_types.rs index 409115f594..3824759472 100644 --- a/src/hyperlight_common/src/flatbuffer_wrappers/function_types.rs +++ b/src/hyperlight_common/src/flatbuffer_wrappers/function_types.rs @@ -14,29 +14,29 @@ use super::codec::{ExternalValueSink, ExternalValueSource}; use super::guest_error::GuestError; #[cfg(feature = "fuzzing")] use super::util::arbitrary_byte_chunks; -use super::util::{byte_chunks_from_bytes, byte_chunks_to_bytes, try_byte_chunks_len}; +use super::util::try_byte_chunks_len; use crate::flatbuffers::hyperlight::generated::{ FunctionCallResult as FbFunctionCallResult, FunctionCallResultArgs as FbFunctionCallResultArgs, FunctionCallResultType, Parameter, ParameterType as FbParameterType, ParameterValue as FbParameterValue, ReturnType as FbReturnType, ReturnValue as FbReturnValue, ReturnValueBox, ReturnValueBoxArgs, hlbool, hlboolArgs, hldouble, hldoubleArgs, hlexternalbytes, hlexternalbytesArgs, hlfloat, hlfloatArgs, hlint, hlintArgs, hllong, - hllongArgs, hlsizeprefixedbuffer, hlsizeprefixedbufferArgs, hlsizeprefixedbytechunks, - hlsizeprefixedbytechunksArgs, hlstring, hlstringArgs, hluint, hluintArgs, hlulong, hlulongArgs, - hlvoid, hlvoidArgs, + hllongArgs, hlstring, hlstringArgs, hluint, hluintArgs, hlulong, hlulongArgs, hlvoid, + hlvoidArgs, }; pub struct FunctionCallResult(core::result::Result); impl FunctionCallResult { - /// Encodes self into the given builder and returns the encoded data. - /// - /// # Notes - /// - /// The builder should not be reused after a call to encode, since this function - /// does not reset the state of the builder. If you want to reuse the builder, - /// you'll need to reset it first. - pub fn encode<'a>(&self, builder: &'a mut flatbuffers::FlatBufferBuilder) -> &'a [u8] { + /// Encode control data and collect a byte return as an external value. + pub fn encode<'a, 'b, S>( + &'a self, + builder: &'b mut flatbuffers::FlatBufferBuilder, + external_values: &mut S, + ) -> Result<&'b [u8]> + where + S: ExternalValueSink<'a> + ?Sized, + { match &self.0 { Ok(rv) => { // Encode ReturnValue as ReturnValueBox @@ -75,33 +75,36 @@ impl FunctionCallResult { (Some(off.as_union_value()), FbReturnValue::hlstring) } ReturnValue::VecBytes(v) => { - let val = builder.create_vector(v); - let off = hlsizeprefixedbuffer::create( + let length = u64::try_from(v.len()) + .map_err(|_| anyhow!("External VecBytes length does not fit in u64"))?; + + external_values.push_bytes(v)?; + let off = hlexternalbytes::create( builder, - &hlsizeprefixedbufferArgs { - value: Some(val), - size: v.len() as i32, + &hlexternalbytesArgs { + length, + chunked: false, }, ); - ( - Some(off.as_union_value()), - FbReturnValue::hlsizeprefixedbuffer, - ) + (Some(off.as_union_value()), FbReturnValue::hlexternalbytes) } ReturnValue::ByteChunks(v) => { - let value = byte_chunks_to_bytes(v); - let val = builder.create_vector(value.as_ref()); - let off = hlsizeprefixedbytechunks::create( + let length = try_byte_chunks_len(v) + .ok_or_else(|| anyhow!("External ByteChunks length overflow"))?; + + let length = u64::try_from(length).map_err(|_| { + anyhow!("External ByteChunks length does not fit in u64") + })?; + + external_values.push_chunks(v)?; + let off = hlexternalbytes::create( builder, - &hlsizeprefixedbytechunksArgs { - value: Some(val), - size: value.len() as i32, + &hlexternalbytesArgs { + length, + chunked: true, }, ); - ( - Some(off.as_union_value()), - FbReturnValue::hlsizeprefixedbytechunks, - ) + (Some(off.as_union_value()), FbReturnValue::hlexternalbytes) } ReturnValue::Void(()) => { let off = hlvoid::create(builder, &hlvoidArgs {}); @@ -118,7 +121,7 @@ impl FunctionCallResult { }, ); builder.finish_size_prefixed(fcr, None); - builder.finished_data() + Ok(builder.finished_data()) } Err(ge) => { // Encode GuestError @@ -139,65 +142,11 @@ impl FunctionCallResult { }, ); builder.finish_size_prefixed(fcr, None); - builder.finished_data() + Ok(builder.finished_data()) } } } - /// Encodes byte returns as external markers and sends their payload to - /// `external_values`. - /// - /// Non-byte returns and guest errors retain their existing embedded - /// encoding. - pub fn encode_external<'a, 'b, S>( - &'a self, - builder: &'b mut flatbuffers::FlatBufferBuilder, - external_values: &mut S, - ) -> Result<&'b [u8]> - where - S: ExternalValueSink<'a> + ?Sized, - { - let Ok(return_value) = &self.0 else { - return Ok(self.encode(builder)); - }; - - let (length, chunked) = match return_value { - ReturnValue::VecBytes(value) => { - let length = u64::try_from(value.len()) - .map_err(|_| anyhow!("External VecBytes length does not fit in u64"))?; - external_values.push_bytes(value)?; - (length, false) - } - ReturnValue::ByteChunks(value) => { - let length = try_byte_chunks_len(value) - .ok_or_else(|| anyhow!("External ByteChunks length overflow"))?; - let length = u64::try_from(length) - .map_err(|_| anyhow!("External ByteChunks length does not fit in u64"))?; - external_values.push_chunks(value)?; - (length, true) - } - _ => return Ok(self.encode(builder)), - }; - - let value = hlexternalbytes::create(builder, &hlexternalbytesArgs { length, chunked }); - let return_value = ReturnValueBox::create( - builder, - &ReturnValueBoxArgs { - value: Some(value.as_union_value()), - value_type: FbReturnValue::hlexternalbytes, - }, - ); - let result = FbFunctionCallResult::create( - builder, - &FbFunctionCallResultArgs { - result: Some(return_value.as_union_value()), - result_type: FunctionCallResultType::ReturnValueBox, - }, - ); - builder.finish_size_prefixed(result, None); - Ok(builder.finished_data()) - } - pub fn new(value: core::result::Result) -> Self { FunctionCallResult(value) } @@ -206,9 +155,8 @@ impl FunctionCallResult { self.0 } - /// Decodes a function-call result using `external_values` for external byte - /// markers. - pub fn decode_external(value: &[u8], external_values: &mut S) -> Result + /// Decode control data and consume an external byte return. + pub fn decode(value: &[u8], external_values: &mut S) -> Result where S: ExternalValueSource + ?Sized, { @@ -222,7 +170,7 @@ impl FunctionCallResult { .ok_or_else(|| { anyhow!("Failed to get ReturnValueBox from function call result") })?; - Ok(decode_external_return_value(boxed, external_values)?) + Ok(decode_return_value(boxed, external_values)?) } FunctionCallResultType::GuestError => { let guest_error_table = function_call_result_fb @@ -245,44 +193,6 @@ impl FunctionCallResult { } } -impl TryFrom<&[u8]> for FunctionCallResult { - type Error = Error; - - fn try_from(value: &[u8]) -> Result { - let function_call_result_fb = size_prefixed_root::(value) - .map_err(|e| anyhow!("Failed to get FunctionCallResult from bytes: {:?}", e))?; - - match function_call_result_fb.result_type() { - FunctionCallResultType::ReturnValueBox => { - let boxed = function_call_result_fb - .result_as_return_value_box() - .ok_or_else(|| { - anyhow!("Failed to get ReturnValueBox from function call result") - })?; - let return_value = ReturnValue::try_from(boxed)?; - Ok(FunctionCallResult(Ok(return_value))) - } - FunctionCallResultType::GuestError => { - let guest_error_table = function_call_result_fb - .result_as_guest_error() - .ok_or_else(|| anyhow!("Failed to get GuestError from function call result"))?; - let code = guest_error_table.code(); - let message = guest_error_table - .message() - .map(|s| s.to_string()) - .unwrap_or_default(); - Ok(FunctionCallResult(Err(GuestError::new( - code.into(), - message, - )))) - } - other => { - bail!("Unexpected function call result type: {:?}", other) - } - } - } -} - /// Supported parameter types with values for function calling. #[cfg_attr(feature = "fuzzing", derive(arbitrary::Arbitrary))] #[derive(Debug, Clone, PartialEq)] @@ -400,95 +310,94 @@ pub enum ReturnType { ByteChunks, } -pub(crate) fn decode_external_parameter_value( - parameter: Parameter<'_>, - external_values: &mut S, -) -> Result +enum DecodedExternalBytes { + VecBytes(Vec), + ByteChunks(Vec), +} + +fn decode_external_bytes( + metadata: hlexternalbytes<'_>, + externals: &mut S, +) -> Result where S: ExternalValueSource + ?Sized, { - if parameter.value_type() != FbParameterValue::hlexternalbytes { - return parameter.try_into(); - } - - let marker = parameter - .value_as_hlexternalbytes() - .ok_or_else(|| anyhow!("Failed to get external byte parameter marker"))?; - let length = usize::try_from(marker.length()).map_err(|_| { + // The length delimits this value in the ordered external payload stream. + let length = usize::try_from(metadata.length()).map_err(|_| { anyhow!( - "External byte parameter length {} does not fit in usize", - marker.length() + "External byte length {} does not fit in usize", + metadata.length() ) })?; - if marker.chunked() { - let value = external_values.take_chunks(length)?; - let actual_length = try_byte_chunks_len(&value) - .ok_or_else(|| anyhow!("External ByteChunks parameter length overflow"))?; - if actual_length != length { - bail!( - "External ByteChunks parameter length mismatch: declared {}, received {}", - length, - actual_length - ); + // `chunked` selects the logical API type. Sources define chunk boundaries. + if metadata.chunked() { + let value = externals.take_chunks(length)?; + let actual = try_byte_chunks_len(&value) + .ok_or_else(|| anyhow!("External ByteChunks length overflow"))?; + + if actual != length { + bail!("External ByteChunks length mismatch: declared {length}, received {actual}",); } - Ok(ParameterValue::ByteChunks(value)) + Ok(DecodedExternalBytes::ByteChunks(value)) } else { - let value = external_values.take_bytes(length)?; - if value.len() != length { - bail!( - "External VecBytes parameter length mismatch: declared {}, received {}", - length, - value.len() - ); + let value = externals.take_bytes(length)?; + let value_len = value.len(); + + if value_len != length { + bail!("External VecBytes length mismatch: declared {length}, received {value_len}",); } - Ok(ParameterValue::VecBytes(value)) + Ok(DecodedExternalBytes::VecBytes(value)) } } -fn decode_external_return_value( - return_value: ReturnValueBox<'_>, - external_values: &mut S, -) -> Result +pub(crate) fn decode_parameter_value( + param: Parameter<'_>, + externals: &mut S, +) -> Result where S: ExternalValueSource + ?Sized, { - if return_value.value_type() != FbReturnValue::hlexternalbytes { - return return_value.try_into(); + match param.value_type() { + FbParameterValue::hlexternalbytes => { + let Some(metadata) = param.value_as_hlexternalbytes() else { + bail!("External byte parameter metadata is missing"); + }; + + match decode_external_bytes(metadata, externals)? { + DecodedExternalBytes::VecBytes(value) => Ok(ParameterValue::VecBytes(value)), + DecodedExternalBytes::ByteChunks(value) => Ok(ParameterValue::ByteChunks(value)), + } + } + FbParameterValue::hlvecbytes => { + bail!("Embedded byte parameters are not supported") + } + _ => param.try_into(), } +} - let marker = return_value - .value_as_hlexternalbytes() - .ok_or_else(|| anyhow!("Failed to get external byte return marker"))?; - let length = usize::try_from(marker.length()).map_err(|_| { - anyhow!( - "External byte return length {} does not fit in usize", - marker.length() - ) - })?; +fn decode_return_value( + return_value: ReturnValueBox<'_>, + externals: &mut S, +) -> Result +where + S: ExternalValueSource + ?Sized, +{ + match return_value.value_type() { + FbReturnValue::hlexternalbytes => { + let Some(metadata) = return_value.value_as_hlexternalbytes() else { + bail!("External byte parameter metadata is missing"); + }; - if marker.chunked() { - let value = external_values.take_chunks(length)?; - let actual_length = try_byte_chunks_len(&value) - .ok_or_else(|| anyhow!("External ByteChunks return length overflow"))?; - if actual_length != length { - bail!( - "External ByteChunks return length mismatch: declared {}, received {}", - length, - actual_length - ); + match decode_external_bytes(metadata, externals)? { + DecodedExternalBytes::VecBytes(value) => Ok(ReturnValue::VecBytes(value)), + DecodedExternalBytes::ByteChunks(value) => Ok(ReturnValue::ByteChunks(value)), + } } - Ok(ReturnValue::ByteChunks(value)) - } else { - let value = external_values.take_bytes(length)?; - if value.len() != length { - bail!( - "External VecBytes return length mismatch: declared {}, received {}", - length, - value.len() - ); + FbReturnValue::hlsizeprefixedbuffer => { + bail!("Embedded byte returns are not supported") } - Ok(ReturnValue::VecBytes(value)) + _ => return_value.try_into(), } } @@ -541,14 +450,9 @@ impl TryFrom> for ParameterValue { FbParameterValue::hlstring => param.value_as_hlstring().map(|hlstring| { ParameterValue::String(hlstring.value().unwrap_or_default().to_string()) }), - FbParameterValue::hlvecbytes => param.value_as_hlvecbytes().map(|hlvecbytes| { - ParameterValue::VecBytes(hlvecbytes.value().unwrap_or_default().bytes().to_vec()) - }), - FbParameterValue::hlbytechunks => param.value_as_hlbytechunks().map(|hlbytechunks| { - ParameterValue::ByteChunks(byte_chunks_from_bytes(Bytes::copy_from_slice( - hlbytechunks.value().unwrap_or_default().bytes(), - ))) - }), + FbParameterValue::hlvecbytes => { + bail!("Embedded byte parameters are not supported") + } FbParameterValue::hlexternalbytes => { bail!("External byte parameter requires an external value source") } @@ -967,21 +871,7 @@ impl TryFrom> for ReturnValue { } FbReturnValue::hlvoid => Ok(ReturnValue::Void(())), FbReturnValue::hlsizeprefixedbuffer => { - let hlvecbytes = match return_value_box.value_as_hlsizeprefixedbuffer() { - Some(hlvecbytes) => hlvecbytes - .value() - .map(|val| val.iter().collect::>()), - None => None, - }; - Ok(ReturnValue::VecBytes(hlvecbytes.unwrap_or(Vec::new()))) - } - FbReturnValue::hlsizeprefixedbytechunks => { - let value = return_value_box - .value_as_hlsizeprefixedbytechunks() - .and_then(|value| value.value()) - .map(|value| byte_chunks_from_bytes(Bytes::copy_from_slice(value.bytes()))) - .unwrap_or_default(); - Ok(ReturnValue::ByteChunks(value)) + bail!("Embedded byte returns are not supported") } FbReturnValue::hlexternalbytes => { bail!("External byte return requires an external value source") @@ -993,249 +883,6 @@ impl TryFrom> for ReturnValue { } } -impl TryFrom<&ReturnValue> for Vec { - type Error = Error; - #[cfg_attr(feature = "tracing", instrument(err(Debug), skip_all, parent = Span::current(), level= "Trace"))] - fn try_from(value: &ReturnValue) -> Result> { - let mut builder = flatbuffers::FlatBufferBuilder::new(); - let result_bytes = match value { - ReturnValue::Int(i) => { - let hlint_off = hlint::create(&mut builder, &hlintArgs { value: *i }); - let rv_box = ReturnValueBox::create( - &mut builder, - &ReturnValueBoxArgs { - value: Some(hlint_off.as_union_value()), - value_type: FbReturnValue::hlint, - }, - ); - let fcr = FbFunctionCallResult::create( - &mut builder, - &FbFunctionCallResultArgs { - result: Some(rv_box.as_union_value()), - result_type: FunctionCallResultType::ReturnValueBox, - }, - ); - builder.finish_size_prefixed(fcr, None); - builder.finished_data().to_vec() - } - ReturnValue::UInt(ui) => { - let off = hluint::create(&mut builder, &hluintArgs { value: *ui }); - let rv_box = ReturnValueBox::create( - &mut builder, - &ReturnValueBoxArgs { - value: Some(off.as_union_value()), - value_type: FbReturnValue::hluint, - }, - ); - let fcr = FbFunctionCallResult::create( - &mut builder, - &FbFunctionCallResultArgs { - result: Some(rv_box.as_union_value()), - result_type: FunctionCallResultType::ReturnValueBox, - }, - ); - builder.finish_size_prefixed(fcr, None); - builder.finished_data().to_vec() - } - ReturnValue::Long(l) => { - let off = hllong::create(&mut builder, &hllongArgs { value: *l }); - let rv_box = ReturnValueBox::create( - &mut builder, - &ReturnValueBoxArgs { - value: Some(off.as_union_value()), - value_type: FbReturnValue::hllong, - }, - ); - let fcr = FbFunctionCallResult::create( - &mut builder, - &FbFunctionCallResultArgs { - result: Some(rv_box.as_union_value()), - result_type: FunctionCallResultType::ReturnValueBox, - }, - ); - builder.finish_size_prefixed(fcr, None); - builder.finished_data().to_vec() - } - ReturnValue::ULong(ul) => { - let off = hlulong::create(&mut builder, &hlulongArgs { value: *ul }); - let rv_box = ReturnValueBox::create( - &mut builder, - &ReturnValueBoxArgs { - value: Some(off.as_union_value()), - value_type: FbReturnValue::hlulong, - }, - ); - let fcr = FbFunctionCallResult::create( - &mut builder, - &FbFunctionCallResultArgs { - result: Some(rv_box.as_union_value()), - result_type: FunctionCallResultType::ReturnValueBox, - }, - ); - builder.finish_size_prefixed(fcr, None); - builder.finished_data().to_vec() - } - ReturnValue::Float(f) => { - let off = hlfloat::create(&mut builder, &hlfloatArgs { value: *f }); - let rv_box = ReturnValueBox::create( - &mut builder, - &ReturnValueBoxArgs { - value: Some(off.as_union_value()), - value_type: FbReturnValue::hlfloat, - }, - ); - let fcr = FbFunctionCallResult::create( - &mut builder, - &FbFunctionCallResultArgs { - result: Some(rv_box.as_union_value()), - result_type: FunctionCallResultType::ReturnValueBox, - }, - ); - builder.finish_size_prefixed(fcr, None); - builder.finished_data().to_vec() - } - ReturnValue::Double(d) => { - let off = hldouble::create(&mut builder, &hldoubleArgs { value: *d }); - let rv_box = ReturnValueBox::create( - &mut builder, - &ReturnValueBoxArgs { - value: Some(off.as_union_value()), - value_type: FbReturnValue::hldouble, - }, - ); - let fcr = FbFunctionCallResult::create( - &mut builder, - &FbFunctionCallResultArgs { - result: Some(rv_box.as_union_value()), - result_type: FunctionCallResultType::ReturnValueBox, - }, - ); - builder.finish_size_prefixed(fcr, None); - builder.finished_data().to_vec() - } - ReturnValue::Bool(b) => { - let off = hlbool::create(&mut builder, &hlboolArgs { value: *b }); - let rv_box = ReturnValueBox::create( - &mut builder, - &ReturnValueBoxArgs { - value: Some(off.as_union_value()), - value_type: FbReturnValue::hlbool, - }, - ); - let fcr = FbFunctionCallResult::create( - &mut builder, - &FbFunctionCallResultArgs { - result: Some(rv_box.as_union_value()), - result_type: FunctionCallResultType::ReturnValueBox, - }, - ); - builder.finish_size_prefixed(fcr, None); - builder.finished_data().to_vec() - } - ReturnValue::String(s) => { - let off = { - let val = builder.create_string(s.as_str()); - hlstring::create(&mut builder, &hlstringArgs { value: Some(val) }) - }; - let rv_box = ReturnValueBox::create( - &mut builder, - &ReturnValueBoxArgs { - value: Some(off.as_union_value()), - value_type: FbReturnValue::hlstring, - }, - ); - let fcr = FbFunctionCallResult::create( - &mut builder, - &FbFunctionCallResultArgs { - result: Some(rv_box.as_union_value()), - result_type: FunctionCallResultType::ReturnValueBox, - }, - ); - builder.finish_size_prefixed(fcr, None); - builder.finished_data().to_vec() - } - ReturnValue::VecBytes(v) => { - let off = { - let val = builder.create_vector(v.as_slice()); - hlsizeprefixedbuffer::create( - &mut builder, - &hlsizeprefixedbufferArgs { - value: Some(val), - size: v.len() as i32, - }, - ) - }; - let rv_box = ReturnValueBox::create( - &mut builder, - &ReturnValueBoxArgs { - value: Some(off.as_union_value()), - value_type: FbReturnValue::hlsizeprefixedbuffer, - }, - ); - let fcr = FbFunctionCallResult::create( - &mut builder, - &FbFunctionCallResultArgs { - result: Some(rv_box.as_union_value()), - result_type: FunctionCallResultType::ReturnValueBox, - }, - ); - builder.finish_size_prefixed(fcr, None); - builder.finished_data().to_vec() - } - ReturnValue::ByteChunks(v) => { - let off = { - let value = byte_chunks_to_bytes(v); - let val = builder.create_vector(value.as_ref()); - hlsizeprefixedbytechunks::create( - &mut builder, - &hlsizeprefixedbytechunksArgs { - value: Some(val), - size: value.len() as i32, - }, - ) - }; - let rv_box = ReturnValueBox::create( - &mut builder, - &ReturnValueBoxArgs { - value: Some(off.as_union_value()), - value_type: FbReturnValue::hlsizeprefixedbytechunks, - }, - ); - let fcr = FbFunctionCallResult::create( - &mut builder, - &FbFunctionCallResultArgs { - result: Some(rv_box.as_union_value()), - result_type: FunctionCallResultType::ReturnValueBox, - }, - ); - builder.finish_size_prefixed(fcr, None); - builder.finished_data().to_vec() - } - ReturnValue::Void(()) => { - let off = hlvoid::create(&mut builder, &hlvoidArgs {}); - let rv_box = ReturnValueBox::create( - &mut builder, - &ReturnValueBoxArgs { - value: Some(off.as_union_value()), - value_type: FbReturnValue::hlvoid, - }, - ); - let fcr = FbFunctionCallResult::create( - &mut builder, - &FbFunctionCallResultArgs { - result: Some(rv_box.as_union_value()), - result_type: FunctionCallResultType::ReturnValueBox, - }, - ); - builder.finish_size_prefixed(fcr, None); - builder.finished_data().to_vec() - } - }; - - Ok(result_bytes) - } -} - #[cfg(test)] mod tests { use alloc::collections::VecDeque; @@ -1244,7 +891,6 @@ mod tests { use flatbuffers::FlatBufferBuilder; use super::super::guest_error::ErrorCode; - use super::super::util::{byte_chunks_to_vec, get_flatbuffer_result}; use super::*; use crate::flatbuffers::hyperlight::generated::{hlexternalbytes, hlexternalbytesArgs}; @@ -1314,9 +960,15 @@ mod tests { #[test] fn encode_success_result() { let mut builder = FlatBufferBuilder::new(); - let test_data = FunctionCallResult::new(Ok(ReturnValue::Int(42))).encode(&mut builder); + let mut external_values = TestExternalValues::default(); + + let test_data = FunctionCallResult::new(Ok(ReturnValue::Int(42))) + .encode(&mut builder, &mut external_values) + .unwrap(); + + let function_call_result = + FunctionCallResult::decode(test_data, &mut external_values).unwrap(); - let function_call_result = FunctionCallResult::try_from(test_data).unwrap(); let result = function_call_result.into_inner().unwrap(); assert_eq!(result, ReturnValue::Int(42)); } @@ -1328,45 +980,20 @@ mod tests { ErrorCode::GuestFunctionNotFound, "Function not found".to_string(), ); - let test_data = FunctionCallResult::new(Err(test_error.clone())).encode(&mut builder); - let function_call_result = FunctionCallResult::try_from(test_data).unwrap(); + let mut external_values = TestExternalValues::default(); + let test_data = FunctionCallResult::new(Err(test_error.clone())) + .encode(&mut builder, &mut external_values) + .unwrap(); + + let function_call_result = + FunctionCallResult::decode(test_data, &mut external_values).unwrap(); + let error = function_call_result.into_inner().unwrap_err(); assert_eq!(error.code, test_error.code); assert_eq!(error.message, test_error.message); } - #[test] - fn embedded_byte_chunks_return_round_trips() { - let mut builder = FlatBufferBuilder::new(); - let expected = vec![Bytes::from_static(b"hello"), Bytes::from_static(b" world")]; - let encoded = - FunctionCallResult::new(Ok(ReturnValue::ByteChunks(expected))).encode(&mut builder); - - let decoded = FunctionCallResult::try_from(encoded) - .unwrap() - .into_inner() - .unwrap(); - let ReturnValue::ByteChunks(decoded) = decoded else { - panic!("expected byte chunks return value"); - }; - assert_eq!(byte_chunks_to_vec(&decoded), b"hello world"); - } - - #[test] - fn direct_byte_chunks_return_encoding_preserves_logical_type() { - let encoded = get_flatbuffer_result(vec![ - Bytes::from_static(b"hello"), - Bytes::from_static(b" world"), - ]); - - let decoded = FunctionCallResult::try_from(encoded.as_slice()) - .unwrap() - .into_inner() - .unwrap(); - assert!(matches!(decoded, ReturnValue::ByteChunks(_))); - } - #[test] fn external_bytes_marks_chunked_values_only() { fn round_trip(chunked: bool) -> bool { @@ -1390,7 +1017,7 @@ mod tests { } #[test] - fn external_byte_returns_round_trip_without_embedding_payloads() { + fn byte_returns_round_trip_as_external_values() { for expected in [ ReturnValue::VecBytes(vec![0xa5; 4096]), ReturnValue::ByteChunks(vec![ @@ -1402,28 +1029,32 @@ mod tests { ] { let mut builder = FlatBufferBuilder::new(); let mut external_values = TestExternalValues::default(); + let encoded = FunctionCallResult::new(Ok(expected.clone())) - .encode_external(&mut builder, &mut external_values) + .encode(&mut builder, &mut external_values) .unwrap(); assert!(encoded.len() < 4096); let encoded_result = size_prefixed_root::(encoded).unwrap(); let return_value = encoded_result.result_as_return_value_box().unwrap(); + assert_eq!(return_value.value_type(), FbReturnValue::hlexternalbytes); - let marker = return_value.value_as_hlexternalbytes().unwrap(); + + let metadata = return_value.value_as_hlexternalbytes().unwrap(); let (length, chunked) = match &expected { ReturnValue::VecBytes(value) => (value.len(), false), ReturnValue::ByteChunks(value) => (try_byte_chunks_len(value).unwrap(), true), _ => unreachable!(), }; - assert_eq!(marker.length(), length as u64); - assert_eq!(marker.chunked(), chunked); - assert!(FunctionCallResult::try_from(encoded).is_err()); - let decoded = FunctionCallResult::decode_external(encoded, &mut external_values) + assert_eq!(metadata.length(), length as u64); + assert_eq!(metadata.chunked(), chunked); + + let decoded = FunctionCallResult::decode(encoded, &mut external_values) .unwrap() .into_inner() .unwrap(); + assert_eq!(decoded, expected); assert!(external_values.values.is_empty()); } @@ -1437,40 +1068,36 @@ mod tests { FunctionCallResult::new(Ok(ReturnValue::ByteChunks(vec![Bytes::from_static( b"123", )]))) - .encode_external(&mut builder, &mut encoded_values) + .encode(&mut builder, &mut encoded_values) .unwrap(); let mut missing = TestExternalValues::default(); - assert!(FunctionCallResult::decode_external(encoded, &mut missing).is_err()); + assert!(FunctionCallResult::decode(encoded, &mut missing).is_err()); let mut wrong_type = TestExternalValues::from_values([TestExternalValue::VecBytes(vec![1, 2, 3])]); - assert!(FunctionCallResult::decode_external(encoded, &mut wrong_type).is_err()); + + assert!(FunctionCallResult::decode(encoded, &mut wrong_type).is_err()); let mut wrong_length = TestExternalValues::from_values([TestExternalValue::ByteChunks(vec![ Bytes::from_static(b"12"), ])]); - assert!(FunctionCallResult::decode_external(encoded, &mut wrong_length).is_err()); + + assert!(FunctionCallResult::decode(encoded, &mut wrong_length).is_err()); } #[test] fn external_result_decoder_rejects_unused_values() { let result = FunctionCallResult::new(Ok(ReturnValue::Int(42))); - let mut embedded_builder = FlatBufferBuilder::new(); - let embedded = result.encode(&mut embedded_builder).to_vec(); - - let mut external_builder = FlatBufferBuilder::new(); + let mut builder = FlatBufferBuilder::new(); let mut external_values = TestExternalValues::default(); - let external = result - .encode_external(&mut external_builder, &mut external_values) - .unwrap(); - assert_eq!(external, embedded); + let encoded = result.encode(&mut builder, &mut external_values).unwrap(); assert!(external_values.values.is_empty()); external_values .values .push_back(TestExternalValue::VecBytes(Vec::new())); - assert!(FunctionCallResult::decode_external(external, &mut external_values).is_err()); + assert!(FunctionCallResult::decode(encoded, &mut external_values).is_err()); } } diff --git a/src/hyperlight_common/src/flatbuffer_wrappers/guest_log_level.rs b/src/hyperlight_common/src/flatbuffer_wrappers/guest_log_level.rs index d791900f5f..e6edb4c7c3 100644 --- a/src/hyperlight_common/src/flatbuffer_wrappers/guest_log_level.rs +++ b/src/hyperlight_common/src/flatbuffer_wrappers/guest_log_level.rs @@ -70,7 +70,7 @@ impl From<&LogLevel> for FbLogLevel { } impl From<&LogLevel> for Level { - // There is a test (sandbox::outb::tests::test_log_outb_log) which emits trace record as logs + // There is a test (sandbox::outb::tests::test_log_emit_guest_log) which emits trace record as logs // which causes a panic when this function is instrumented as the logger is contained in refcell and // instrumentation ends up causing a double mutborrow. So this is not instrumented. //TODO: instrument this once we fix the test diff --git a/src/hyperlight_common/src/flatbuffer_wrappers/util.rs b/src/hyperlight_common/src/flatbuffer_wrappers/util.rs index dc9134e6fe..ac2b078870 100644 --- a/src/hyperlight_common/src/flatbuffer_wrappers/util.rs +++ b/src/hyperlight_common/src/flatbuffer_wrappers/util.rs @@ -5,251 +5,10 @@ use alloc::vec; use alloc::vec::Vec; use bytes::Bytes; -use flatbuffers::FlatBufferBuilder; use crate::flatbuffer_wrappers::function_types::ParameterValue; -use crate::flatbuffers::hyperlight::generated::{ - FunctionCallResult as FbFunctionCallResult, FunctionCallResultArgs as FbFunctionCallResultArgs, - FunctionCallResultType as FbFunctionCallResultType, ReturnValue as FbReturnValue, - ReturnValueBox, ReturnValueBoxArgs, hlbool as Fbhlbool, hlboolArgs as FbhlboolArgs, - hldouble as Fbhldouble, hldoubleArgs as FbhldoubleArgs, hlfloat as Fbhlfloat, - hlfloatArgs as FbhlfloatArgs, hlint as Fbhlint, hlintArgs as FbhlintArgs, hllong as Fbhllong, - hllongArgs as FbhllongArgs, hlsizeprefixedbuffer as Fbhlsizeprefixedbuffer, - hlsizeprefixedbufferArgs as FbhlsizeprefixedbufferArgs, - hlsizeprefixedbytechunks as Fbhlsizeprefixedbytechunks, - hlsizeprefixedbytechunksArgs as FbhlsizeprefixedbytechunksArgs, hlstring as Fbhlstring, - hlstringArgs as FbhlstringArgs, hluint as Fbhluint, hluintArgs as FbhluintArgs, - hlulong as Fbhlulong, hlulongArgs as FbhlulongArgs, hlvoid as Fbhlvoid, - hlvoidArgs as FbhlvoidArgs, -}; - -/// Flatbuffer-encodes the given value -pub fn get_flatbuffer_result(val: T) -> Vec { - let mut builder = FlatBufferBuilder::new(); - let res = T::serialize(&val, &mut builder); - let result_offset = FbFunctionCallResult::create(&mut builder, &res); - - builder.finish_size_prefixed(result_offset, None); - - builder.finished_data().to_vec() -} - -pub trait FlatbufferSerializable { - fn serialize(&self, builder: &mut FlatBufferBuilder) -> FbFunctionCallResultArgs; -} - -// Implementations for basic types below - -impl FlatbufferSerializable for () { - fn serialize(&self, builder: &mut FlatBufferBuilder) -> FbFunctionCallResultArgs { - let void_off = Fbhlvoid::create(builder, &FbhlvoidArgs {}); - let rv_box = ReturnValueBox::create( - builder, - &ReturnValueBoxArgs { - value_type: FbReturnValue::hlvoid, - value: Some(void_off.as_union_value()), - }, - ); - FbFunctionCallResultArgs { - result_type: FbFunctionCallResultType::ReturnValueBox, - result: Some(rv_box.as_union_value()), - } - } -} - -impl FlatbufferSerializable for &str { - fn serialize(&self, builder: &mut FlatBufferBuilder) -> FbFunctionCallResultArgs { - let string_offset = builder.create_string(self); - let str_off = Fbhlstring::create( - builder, - &FbhlstringArgs { - value: Some(string_offset), - }, - ); - let rv_box = ReturnValueBox::create( - builder, - &ReturnValueBoxArgs { - value_type: FbReturnValue::hlstring, - value: Some(str_off.as_union_value()), - }, - ); - FbFunctionCallResultArgs { - result_type: FbFunctionCallResultType::ReturnValueBox, - result: Some(rv_box.as_union_value()), - } - } -} - -impl FlatbufferSerializable for &[u8] { - fn serialize(&self, builder: &mut FlatBufferBuilder) -> FbFunctionCallResultArgs { - let vec_off = builder.create_vector(self); - let buf_off = Fbhlsizeprefixedbuffer::create( - builder, - &FbhlsizeprefixedbufferArgs { - size: self.len() as i32, - value: Some(vec_off), - }, - ); - let rv_box = ReturnValueBox::create( - builder, - &ReturnValueBoxArgs { - value_type: FbReturnValue::hlsizeprefixedbuffer, - value: Some(buf_off.as_union_value()), - }, - ); - FbFunctionCallResultArgs { - result_type: FbFunctionCallResultType::ReturnValueBox, - result: Some(rv_box.as_union_value()), - } - } -} - -impl FlatbufferSerializable for Vec { - fn serialize(&self, builder: &mut FlatBufferBuilder) -> FbFunctionCallResultArgs { - let value = byte_chunks_to_bytes(self); - let vec_off = builder.create_vector(value.as_ref()); - let buf_off = Fbhlsizeprefixedbytechunks::create( - builder, - &FbhlsizeprefixedbytechunksArgs { - size: value.len() as i32, - value: Some(vec_off), - }, - ); - let rv_box = ReturnValueBox::create( - builder, - &ReturnValueBoxArgs { - value_type: FbReturnValue::hlsizeprefixedbytechunks, - value: Some(buf_off.as_union_value()), - }, - ); - FbFunctionCallResultArgs { - result_type: FbFunctionCallResultType::ReturnValueBox, - result: Some(rv_box.as_union_value()), - } - } -} - -impl FlatbufferSerializable for f32 { - fn serialize(&self, builder: &mut FlatBufferBuilder) -> FbFunctionCallResultArgs { - let off = Fbhlfloat::create(builder, &FbhlfloatArgs { value: *self }); - let rv_box = ReturnValueBox::create( - builder, - &ReturnValueBoxArgs { - value_type: FbReturnValue::hlfloat, - value: Some(off.as_union_value()), - }, - ); - FbFunctionCallResultArgs { - result_type: FbFunctionCallResultType::ReturnValueBox, - result: Some(rv_box.as_union_value()), - } - } -} - -impl FlatbufferSerializable for f64 { - fn serialize(&self, builder: &mut FlatBufferBuilder) -> FbFunctionCallResultArgs { - let off = Fbhldouble::create(builder, &FbhldoubleArgs { value: *self }); - let rv_box = ReturnValueBox::create( - builder, - &ReturnValueBoxArgs { - value_type: FbReturnValue::hldouble, - value: Some(off.as_union_value()), - }, - ); - FbFunctionCallResultArgs { - result_type: FbFunctionCallResultType::ReturnValueBox, - result: Some(rv_box.as_union_value()), - } - } -} - -impl FlatbufferSerializable for i32 { - fn serialize(&self, builder: &mut FlatBufferBuilder) -> FbFunctionCallResultArgs { - let off = Fbhlint::create(builder, &FbhlintArgs { value: *self }); - let rv_box = ReturnValueBox::create( - builder, - &ReturnValueBoxArgs { - value_type: FbReturnValue::hlint, - value: Some(off.as_union_value()), - }, - ); - FbFunctionCallResultArgs { - result_type: FbFunctionCallResultType::ReturnValueBox, - result: Some(rv_box.as_union_value()), - } - } -} - -impl FlatbufferSerializable for i64 { - fn serialize(&self, builder: &mut FlatBufferBuilder) -> FbFunctionCallResultArgs { - let off = Fbhllong::create(builder, &FbhllongArgs { value: *self }); - let rv_box = ReturnValueBox::create( - builder, - &ReturnValueBoxArgs { - value_type: FbReturnValue::hllong, - value: Some(off.as_union_value()), - }, - ); - FbFunctionCallResultArgs { - result_type: FbFunctionCallResultType::ReturnValueBox, - result: Some(rv_box.as_union_value()), - } - } -} - -impl FlatbufferSerializable for u32 { - fn serialize(&self, builder: &mut FlatBufferBuilder) -> FbFunctionCallResultArgs { - let off = Fbhluint::create(builder, &FbhluintArgs { value: *self }); - let rv_box = ReturnValueBox::create( - builder, - &ReturnValueBoxArgs { - value_type: FbReturnValue::hluint, - value: Some(off.as_union_value()), - }, - ); - FbFunctionCallResultArgs { - result_type: FbFunctionCallResultType::ReturnValueBox, - result: Some(rv_box.as_union_value()), - } - } -} -impl FlatbufferSerializable for u64 { - fn serialize(&self, builder: &mut FlatBufferBuilder) -> FbFunctionCallResultArgs { - let off = Fbhlulong::create(builder, &FbhlulongArgs { value: *self }); - let rv_box = ReturnValueBox::create( - builder, - &ReturnValueBoxArgs { - value_type: FbReturnValue::hlulong, - value: Some(off.as_union_value()), - }, - ); - FbFunctionCallResultArgs { - result_type: FbFunctionCallResultType::ReturnValueBox, - result: Some(rv_box.as_union_value()), - } - } -} - -impl FlatbufferSerializable for bool { - fn serialize(&self, builder: &mut FlatBufferBuilder) -> FbFunctionCallResultArgs { - let off = Fbhlbool::create(builder, &FbhlboolArgs { value: *self }); - let rv_box = ReturnValueBox::create( - builder, - &ReturnValueBoxArgs { - value_type: FbReturnValue::hlbool, - value: Some(off.as_union_value()), - }, - ); - FbFunctionCallResultArgs { - result_type: FbFunctionCallResultType::ReturnValueBox, - result: Some(rv_box.as_union_value()), - } - } -} - -/// Estimates the required buffer capacity for encoding a FunctionCall with the given parameters. -/// This helps avoid reallocation during FlatBuffer encoding when passing large slices and strings. +/// Estimate the control-buffer capacity for encoding a function call. /// /// The function aims to be lightweight and fast and run in O(1) as long as the number of parameters is limited /// (which it is since hyperlight only currently supports up to 12). @@ -260,7 +19,7 @@ impl FlatbufferSerializable for bool { /// /// The estimations are numbers used are empirically derived based on the tests below and vaguely based /// on https://flatbuffers.dev/internals/ and https://github.com/dvidelabs/flatcc/blob/f064cefb2034d1e7407407ce32a6085c322212a7/doc/binary-format.md#flatbuffers-binary-format -#[inline] // allow cross-crate inlining (for hyperlight-host calls) +#[inline] pub fn estimate_flatbuffer_capacity(function_name: &str, args: &[ParameterValue]) -> usize { let mut estimated_capacity = 20; @@ -275,8 +34,7 @@ pub fn estimate_flatbuffer_capacity(function_name: &str, args: &[ParameterValue] estimated_capacity += 16; // Base parameter structure estimated_capacity += match arg { ParameterValue::String(s) => s.len() + 20, - ParameterValue::VecBytes(v) => v.len() + 20, - ParameterValue::ByteChunks(v) => byte_chunks_len(v) + 20, + ParameterValue::VecBytes(_) | ParameterValue::ByteChunks(_) => 20, ParameterValue::Int(_) | ParameterValue::UInt(_) => 16, ParameterValue::Long(_) | ParameterValue::ULong(_) => 20, ParameterValue::Float(_) => 16, @@ -350,9 +108,12 @@ mod tests { use alloc::vec; use alloc::vec::Vec; + use flatbuffers::FlatBufferBuilder; + use super::*; use crate::flatbuffer_wrappers::function_call::{FunctionCall, FunctionCallType}; use crate::flatbuffer_wrappers::function_types::{ParameterValue, ReturnType}; + use crate::transport::ExternalValues; /// Helper function to check that estimation is within reasonable bounds (±25%) fn assert_estimation_accuracy( @@ -371,7 +132,8 @@ mod tests { ); // Important that this FlatBufferBuilder is created with capacity 0 so it grows to its needed capacity let mut builder = FlatBufferBuilder::new(); - let _buffer = fc.encode(&mut builder); + let mut external_values = ExternalValues::new(); + let _buffer = fc.encode(&mut builder, &mut external_values).unwrap(); let actual = builder.collapse().0.capacity(); let lower_bound = (actual as f64 * 0.75) as usize; @@ -400,6 +162,17 @@ mod tests { ); } + #[test] + fn capacity_ignores_external_byte_payload_length() { + let small = [ParameterValue::VecBytes(vec![0])]; + let large = [ParameterValue::VecBytes(vec![0; 1024 * 1024])]; + + assert_eq!( + estimate_flatbuffer_capacity("call", &small), + estimate_flatbuffer_capacity("call", &large) + ); + } + #[test] fn test_estimate_single_int_parameter() { assert_estimation_accuracy( diff --git a/src/hyperlight_common/src/flatbuffers/hyperlight/generated/hlbytechunks_generated.rs b/src/hyperlight_common/src/flatbuffers/hyperlight/generated/hlbytechunks_generated.rs deleted file mode 100644 index d4f1940fd9..0000000000 --- a/src/hyperlight_common/src/flatbuffers/hyperlight/generated/hlbytechunks_generated.rs +++ /dev/null @@ -1,124 +0,0 @@ -// automatically generated by the FlatBuffers compiler, do not modify -// @generated -extern crate alloc; -extern crate flatbuffers; -use alloc::boxed::Box; -use alloc::string::{String, ToString}; -use alloc::vec::Vec; -use core::cmp::Ordering; -use core::mem; - -use self::flatbuffers::{EndianScalar, Follow}; -use super::*; -pub enum hlbytechunksOffset {} -#[derive(Copy, Clone, PartialEq)] - -pub struct hlbytechunks<'a> { - pub _tab: flatbuffers::Table<'a>, -} - -impl<'a> flatbuffers::Follow<'a> for hlbytechunks<'a> { - type Inner = hlbytechunks<'a>; - #[inline] - unsafe fn follow(buf: &'a [u8], loc: usize) -> Self::Inner { - Self { - _tab: unsafe { flatbuffers::Table::new(buf, loc) }, - } - } -} - -impl<'a> hlbytechunks<'a> { - pub const VT_VALUE: flatbuffers::VOffsetT = 4; - - #[inline] - pub unsafe fn init_from_table(table: flatbuffers::Table<'a>) -> Self { - hlbytechunks { _tab: table } - } - #[allow(unused_mut)] - pub fn create<'bldr: 'args, 'args: 'mut_bldr, 'mut_bldr, A: flatbuffers::Allocator + 'bldr>( - _fbb: &'mut_bldr mut flatbuffers::FlatBufferBuilder<'bldr, A>, - args: &'args hlbytechunksArgs<'args>, - ) -> flatbuffers::WIPOffset> { - let mut builder = hlbytechunksBuilder::new(_fbb); - if let Some(x) = args.value { - builder.add_value(x); - } - builder.finish() - } - - #[inline] - pub fn value(&self) -> Option> { - // Safety: - // Created from valid Table for this object - // which contains a valid value in this slot - unsafe { - self._tab - .get::>>( - hlbytechunks::VT_VALUE, - None, - ) - } - } -} - -impl flatbuffers::Verifiable for hlbytechunks<'_> { - #[inline] - fn run_verifier( - v: &mut flatbuffers::Verifier, - pos: usize, - ) -> Result<(), flatbuffers::InvalidFlatbuffer> { - use self::flatbuffers::Verifiable; - v.visit_table(pos)? - .visit_field::>>( - "value", - Self::VT_VALUE, - false, - )? - .finish(); - Ok(()) - } -} -pub struct hlbytechunksArgs<'a> { - pub value: Option>>, -} -impl<'a> Default for hlbytechunksArgs<'a> { - #[inline] - fn default() -> Self { - hlbytechunksArgs { value: None } - } -} - -pub struct hlbytechunksBuilder<'a: 'b, 'b, A: flatbuffers::Allocator + 'a> { - fbb_: &'b mut flatbuffers::FlatBufferBuilder<'a, A>, - start_: flatbuffers::WIPOffset, -} -impl<'a: 'b, 'b, A: flatbuffers::Allocator + 'a> hlbytechunksBuilder<'a, 'b, A> { - #[inline] - pub fn add_value(&mut self, value: flatbuffers::WIPOffset>) { - self.fbb_ - .push_slot_always::>(hlbytechunks::VT_VALUE, value); - } - #[inline] - pub fn new( - _fbb: &'b mut flatbuffers::FlatBufferBuilder<'a, A>, - ) -> hlbytechunksBuilder<'a, 'b, A> { - let start = _fbb.start_table(); - hlbytechunksBuilder { - fbb_: _fbb, - start_: start, - } - } - #[inline] - pub fn finish(self) -> flatbuffers::WIPOffset> { - let o = self.fbb_.end_table(self.start_); - flatbuffers::WIPOffset::new(o.value()) - } -} - -impl core::fmt::Debug for hlbytechunks<'_> { - fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { - let mut ds = f.debug_struct("hlbytechunks"); - ds.field("value", &self.value()); - ds.finish() - } -} diff --git a/src/hyperlight_common/src/flatbuffers/hyperlight/generated/hlsizeprefixedbytechunks_generated.rs b/src/hyperlight_common/src/flatbuffers/hyperlight/generated/hlsizeprefixedbytechunks_generated.rs deleted file mode 100644 index 54661a89f0..0000000000 --- a/src/hyperlight_common/src/flatbuffers/hyperlight/generated/hlsizeprefixedbytechunks_generated.rs +++ /dev/null @@ -1,150 +0,0 @@ -// automatically generated by the FlatBuffers compiler, do not modify -// @generated -extern crate alloc; -extern crate flatbuffers; -use alloc::boxed::Box; -use alloc::string::{String, ToString}; -use alloc::vec::Vec; -use core::cmp::Ordering; -use core::mem; - -use self::flatbuffers::{EndianScalar, Follow}; -use super::*; -pub enum hlsizeprefixedbytechunksOffset {} -#[derive(Copy, Clone, PartialEq)] - -pub struct hlsizeprefixedbytechunks<'a> { - pub _tab: flatbuffers::Table<'a>, -} - -impl<'a> flatbuffers::Follow<'a> for hlsizeprefixedbytechunks<'a> { - type Inner = hlsizeprefixedbytechunks<'a>; - #[inline] - unsafe fn follow(buf: &'a [u8], loc: usize) -> Self::Inner { - Self { - _tab: unsafe { flatbuffers::Table::new(buf, loc) }, - } - } -} - -impl<'a> hlsizeprefixedbytechunks<'a> { - pub const VT_SIZE: flatbuffers::VOffsetT = 4; - pub const VT_VALUE: flatbuffers::VOffsetT = 6; - - #[inline] - pub unsafe fn init_from_table(table: flatbuffers::Table<'a>) -> Self { - hlsizeprefixedbytechunks { _tab: table } - } - #[allow(unused_mut)] - pub fn create<'bldr: 'args, 'args: 'mut_bldr, 'mut_bldr, A: flatbuffers::Allocator + 'bldr>( - _fbb: &'mut_bldr mut flatbuffers::FlatBufferBuilder<'bldr, A>, - args: &'args hlsizeprefixedbytechunksArgs<'args>, - ) -> flatbuffers::WIPOffset> { - let mut builder = hlsizeprefixedbytechunksBuilder::new(_fbb); - if let Some(x) = args.value { - builder.add_value(x); - } - builder.add_size(args.size); - builder.finish() - } - - #[inline] - pub fn size(&self) -> i32 { - // Safety: - // Created from valid Table for this object - // which contains a valid value in this slot - unsafe { - self._tab - .get::(hlsizeprefixedbytechunks::VT_SIZE, Some(0)) - .unwrap() - } - } - #[inline] - pub fn value(&self) -> Option> { - // Safety: - // Created from valid Table for this object - // which contains a valid value in this slot - unsafe { - self._tab - .get::>>( - hlsizeprefixedbytechunks::VT_VALUE, - None, - ) - } - } -} - -impl flatbuffers::Verifiable for hlsizeprefixedbytechunks<'_> { - #[inline] - fn run_verifier( - v: &mut flatbuffers::Verifier, - pos: usize, - ) -> Result<(), flatbuffers::InvalidFlatbuffer> { - use self::flatbuffers::Verifiable; - v.visit_table(pos)? - .visit_field::("size", Self::VT_SIZE, false)? - .visit_field::>>( - "value", - Self::VT_VALUE, - false, - )? - .finish(); - Ok(()) - } -} -pub struct hlsizeprefixedbytechunksArgs<'a> { - pub size: i32, - pub value: Option>>, -} -impl<'a> Default for hlsizeprefixedbytechunksArgs<'a> { - #[inline] - fn default() -> Self { - hlsizeprefixedbytechunksArgs { - size: 0, - value: None, - } - } -} - -pub struct hlsizeprefixedbytechunksBuilder<'a: 'b, 'b, A: flatbuffers::Allocator + 'a> { - fbb_: &'b mut flatbuffers::FlatBufferBuilder<'a, A>, - start_: flatbuffers::WIPOffset, -} -impl<'a: 'b, 'b, A: flatbuffers::Allocator + 'a> hlsizeprefixedbytechunksBuilder<'a, 'b, A> { - #[inline] - pub fn add_size(&mut self, size: i32) { - self.fbb_ - .push_slot::(hlsizeprefixedbytechunks::VT_SIZE, size, 0); - } - #[inline] - pub fn add_value(&mut self, value: flatbuffers::WIPOffset>) { - self.fbb_.push_slot_always::>( - hlsizeprefixedbytechunks::VT_VALUE, - value, - ); - } - #[inline] - pub fn new( - _fbb: &'b mut flatbuffers::FlatBufferBuilder<'a, A>, - ) -> hlsizeprefixedbytechunksBuilder<'a, 'b, A> { - let start = _fbb.start_table(); - hlsizeprefixedbytechunksBuilder { - fbb_: _fbb, - start_: start, - } - } - #[inline] - pub fn finish(self) -> flatbuffers::WIPOffset> { - let o = self.fbb_.end_table(self.start_); - flatbuffers::WIPOffset::new(o.value()) - } -} - -impl core::fmt::Debug for hlsizeprefixedbytechunks<'_> { - fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { - let mut ds = f.debug_struct("hlsizeprefixedbytechunks"); - ds.field("size", &self.size()); - ds.field("value", &self.value()); - ds.finish() - } -} diff --git a/src/hyperlight_common/src/flatbuffers/hyperlight/generated/parameter_generated.rs b/src/hyperlight_common/src/flatbuffers/hyperlight/generated/parameter_generated.rs index 33f7c98190..400766b5d6 100644 --- a/src/hyperlight_common/src/flatbuffers/hyperlight/generated/parameter_generated.rs +++ b/src/hyperlight_common/src/flatbuffers/hyperlight/generated/parameter_generated.rs @@ -212,20 +212,6 @@ impl<'a> Parameter<'a> { None } } - - #[inline] - #[allow(non_snake_case)] - pub fn value_as_hlbytechunks(&self) -> Option> { - if self.value_type() == ParameterValue::hlbytechunks { - let u = self.value(); - // Safety: - // Created from a valid Table for this object - // Which contains a valid union in this slot - Some(unsafe { hlbytechunks::init_from_table(u) }) - } else { - None - } - } } impl flatbuffers::Verifiable for Parameter<'_> { @@ -293,11 +279,6 @@ impl flatbuffers::Verifiable for Parameter<'_> { "ParameterValue::hlexternalbytes", pos, ), - ParameterValue::hlbytechunks => v - .verify_union_variant::>( - "ParameterValue::hlbytechunks", - pos, - ), _ => Ok(()), }, )? @@ -458,16 +439,6 @@ impl core::fmt::Debug for Parameter<'_> { ) } } - ParameterValue::hlbytechunks => { - if let Some(x) = self.value_as_hlbytechunks() { - ds.field("value", &x) - } else { - ds.field( - "value", - &"InvalidFlatbuffer: Union discriminant does not match value.", - ) - } - } _ => { let x: Option<()> = None; ds.field("value", &x) diff --git a/src/hyperlight_common/src/flatbuffers/hyperlight/generated/parameter_value_generated.rs b/src/hyperlight_common/src/flatbuffers/hyperlight/generated/parameter_value_generated.rs index 5ddab887fb..1dffa794bb 100644 --- a/src/hyperlight_common/src/flatbuffers/hyperlight/generated/parameter_value_generated.rs +++ b/src/hyperlight_common/src/flatbuffers/hyperlight/generated/parameter_value_generated.rs @@ -19,13 +19,13 @@ pub const ENUM_MIN_PARAMETER_VALUE: u8 = 0; since = "2.0.0", note = "Use associated constants instead. This will no longer be generated in 2021." )] -pub const ENUM_MAX_PARAMETER_VALUE: u8 = 11; +pub const ENUM_MAX_PARAMETER_VALUE: u8 = 10; #[deprecated( since = "2.0.0", note = "Use associated constants instead. This will no longer be generated in 2021." )] #[allow(non_camel_case_types)] -pub const ENUM_VALUES_PARAMETER_VALUE: [ParameterValue; 12] = [ +pub const ENUM_VALUES_PARAMETER_VALUE: [ParameterValue; 11] = [ ParameterValue::NONE, ParameterValue::hlint, ParameterValue::hluint, @@ -37,7 +37,6 @@ pub const ENUM_VALUES_PARAMETER_VALUE: [ParameterValue; 12] = [ ParameterValue::hlbool, ParameterValue::hlvecbytes, ParameterValue::hlexternalbytes, - ParameterValue::hlbytechunks, ]; #[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Default)] @@ -56,10 +55,9 @@ impl ParameterValue { pub const hlbool: Self = Self(8); pub const hlvecbytes: Self = Self(9); pub const hlexternalbytes: Self = Self(10); - pub const hlbytechunks: Self = Self(11); pub const ENUM_MIN: u8 = 0; - pub const ENUM_MAX: u8 = 11; + pub const ENUM_MAX: u8 = 10; pub const ENUM_VALUES: &'static [Self] = &[ Self::NONE, Self::hlint, @@ -72,7 +70,6 @@ impl ParameterValue { Self::hlbool, Self::hlvecbytes, Self::hlexternalbytes, - Self::hlbytechunks, ]; /// Returns the variant's name or "" if unknown. pub fn variant_name(self) -> Option<&'static str> { @@ -88,7 +85,6 @@ impl ParameterValue { Self::hlbool => Some("hlbool"), Self::hlvecbytes => Some("hlvecbytes"), Self::hlexternalbytes => Some("hlexternalbytes"), - Self::hlbytechunks => Some("hlbytechunks"), _ => None, } } diff --git a/src/hyperlight_common/src/flatbuffers/hyperlight/generated/return_value_box_generated.rs b/src/hyperlight_common/src/flatbuffers/hyperlight/generated/return_value_box_generated.rs index 854879a0e2..66586dfff9 100644 --- a/src/hyperlight_common/src/flatbuffers/hyperlight/generated/return_value_box_generated.rs +++ b/src/hyperlight_common/src/flatbuffers/hyperlight/generated/return_value_box_generated.rs @@ -226,20 +226,6 @@ impl<'a> ReturnValueBox<'a> { None } } - - #[inline] - #[allow(non_snake_case)] - pub fn value_as_hlsizeprefixedbytechunks(&self) -> Option> { - if self.value_type() == ReturnValue::hlsizeprefixedbytechunks { - let u = self.value(); - // Safety: - // Created from a valid Table for this object - // Which contains a valid union in this slot - Some(unsafe { hlsizeprefixedbytechunks::init_from_table(u) }) - } else { - None - } - } } impl flatbuffers::Verifiable for ReturnValueBox<'_> { @@ -250,24 +236,72 @@ impl flatbuffers::Verifiable for ReturnValueBox<'_> { ) -> Result<(), flatbuffers::InvalidFlatbuffer> { use self::flatbuffers::Verifiable; v.visit_table(pos)? - .visit_union::("value_type", Self::VT_VALUE_TYPE, "value", Self::VT_VALUE, true, |key, v, pos| { - match key { - ReturnValue::hlint => v.verify_union_variant::>("ReturnValue::hlint", pos), - ReturnValue::hluint => v.verify_union_variant::>("ReturnValue::hluint", pos), - ReturnValue::hllong => v.verify_union_variant::>("ReturnValue::hllong", pos), - ReturnValue::hlulong => v.verify_union_variant::>("ReturnValue::hlulong", pos), - ReturnValue::hlfloat => v.verify_union_variant::>("ReturnValue::hlfloat", pos), - ReturnValue::hldouble => v.verify_union_variant::>("ReturnValue::hldouble", pos), - ReturnValue::hlstring => v.verify_union_variant::>("ReturnValue::hlstring", pos), - ReturnValue::hlbool => v.verify_union_variant::>("ReturnValue::hlbool", pos), - ReturnValue::hlvoid => v.verify_union_variant::>("ReturnValue::hlvoid", pos), - ReturnValue::hlsizeprefixedbuffer => v.verify_union_variant::>("ReturnValue::hlsizeprefixedbuffer", pos), - ReturnValue::hlexternalbytes => v.verify_union_variant::>("ReturnValue::hlexternalbytes", pos), - ReturnValue::hlsizeprefixedbytechunks => v.verify_union_variant::>("ReturnValue::hlsizeprefixedbytechunks", pos), - _ => Ok(()), - } - })? - .finish(); + .visit_union::( + "value_type", + Self::VT_VALUE_TYPE, + "value", + Self::VT_VALUE, + true, + |key, v, pos| match key { + ReturnValue::hlint => v + .verify_union_variant::>( + "ReturnValue::hlint", + pos, + ), + ReturnValue::hluint => v + .verify_union_variant::>( + "ReturnValue::hluint", + pos, + ), + ReturnValue::hllong => v + .verify_union_variant::>( + "ReturnValue::hllong", + pos, + ), + ReturnValue::hlulong => v + .verify_union_variant::>( + "ReturnValue::hlulong", + pos, + ), + ReturnValue::hlfloat => v + .verify_union_variant::>( + "ReturnValue::hlfloat", + pos, + ), + ReturnValue::hldouble => v + .verify_union_variant::>( + "ReturnValue::hldouble", + pos, + ), + ReturnValue::hlstring => v + .verify_union_variant::>( + "ReturnValue::hlstring", + pos, + ), + ReturnValue::hlbool => v + .verify_union_variant::>( + "ReturnValue::hlbool", + pos, + ), + ReturnValue::hlvoid => v + .verify_union_variant::>( + "ReturnValue::hlvoid", + pos, + ), + ReturnValue::hlsizeprefixedbuffer => v + .verify_union_variant::>( + "ReturnValue::hlsizeprefixedbuffer", + pos, + ), + ReturnValue::hlexternalbytes => v + .verify_union_variant::>( + "ReturnValue::hlexternalbytes", + pos, + ), + _ => Ok(()), + }, + )? + .finish(); Ok(()) } } @@ -436,16 +470,6 @@ impl core::fmt::Debug for ReturnValueBox<'_> { ) } } - ReturnValue::hlsizeprefixedbytechunks => { - if let Some(x) = self.value_as_hlsizeprefixedbytechunks() { - ds.field("value", &x) - } else { - ds.field( - "value", - &"InvalidFlatbuffer: Union discriminant does not match value.", - ) - } - } _ => { let x: Option<()> = None; ds.field("value", &x) diff --git a/src/hyperlight_common/src/flatbuffers/hyperlight/generated/return_value_generated.rs b/src/hyperlight_common/src/flatbuffers/hyperlight/generated/return_value_generated.rs index 6a6e619f66..79ae25f3f6 100644 --- a/src/hyperlight_common/src/flatbuffers/hyperlight/generated/return_value_generated.rs +++ b/src/hyperlight_common/src/flatbuffers/hyperlight/generated/return_value_generated.rs @@ -19,13 +19,13 @@ pub const ENUM_MIN_RETURN_VALUE: u8 = 0; since = "2.0.0", note = "Use associated constants instead. This will no longer be generated in 2021." )] -pub const ENUM_MAX_RETURN_VALUE: u8 = 12; +pub const ENUM_MAX_RETURN_VALUE: u8 = 11; #[deprecated( since = "2.0.0", note = "Use associated constants instead. This will no longer be generated in 2021." )] #[allow(non_camel_case_types)] -pub const ENUM_VALUES_RETURN_VALUE: [ReturnValue; 13] = [ +pub const ENUM_VALUES_RETURN_VALUE: [ReturnValue; 12] = [ ReturnValue::NONE, ReturnValue::hlint, ReturnValue::hluint, @@ -38,7 +38,6 @@ pub const ENUM_VALUES_RETURN_VALUE: [ReturnValue; 13] = [ ReturnValue::hlvoid, ReturnValue::hlsizeprefixedbuffer, ReturnValue::hlexternalbytes, - ReturnValue::hlsizeprefixedbytechunks, ]; #[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Default)] @@ -58,10 +57,9 @@ impl ReturnValue { pub const hlvoid: Self = Self(9); pub const hlsizeprefixedbuffer: Self = Self(10); pub const hlexternalbytes: Self = Self(11); - pub const hlsizeprefixedbytechunks: Self = Self(12); pub const ENUM_MIN: u8 = 0; - pub const ENUM_MAX: u8 = 12; + pub const ENUM_MAX: u8 = 11; pub const ENUM_VALUES: &'static [Self] = &[ Self::NONE, Self::hlint, @@ -75,7 +73,6 @@ impl ReturnValue { Self::hlvoid, Self::hlsizeprefixedbuffer, Self::hlexternalbytes, - Self::hlsizeprefixedbytechunks, ]; /// Returns the variant's name or "" if unknown. pub fn variant_name(self) -> Option<&'static str> { @@ -92,7 +89,6 @@ impl ReturnValue { Self::hlvoid => Some("hlvoid"), Self::hlsizeprefixedbuffer => Some("hlsizeprefixedbuffer"), Self::hlexternalbytes => Some("hlexternalbytes"), - Self::hlsizeprefixedbytechunks => Some("hlsizeprefixedbytechunks"), _ => None, } } diff --git a/src/hyperlight_common/src/flatbuffers/mod.rs b/src/hyperlight_common/src/flatbuffers/mod.rs index 6e8f1125b6..f162e0abc7 100644 --- a/src/hyperlight_common/src/flatbuffers/mod.rs +++ b/src/hyperlight_common/src/flatbuffers/mod.rs @@ -40,14 +40,10 @@ pub mod hyperlight { pub use self::hlbool_generated::*; mod hlvecbytes_generated; pub use self::hlvecbytes_generated::*; - mod hlbytechunks_generated; - pub use self::hlbytechunks_generated::*; mod hlexternalbytes_generated; pub use self::hlexternalbytes_generated::*; mod hlsizeprefixedbuffer_generated; pub use self::hlsizeprefixedbuffer_generated::*; - mod hlsizeprefixedbytechunks_generated; - pub use self::hlsizeprefixedbytechunks_generated::*; mod hlvoid_generated; pub use self::hlvoid_generated::*; mod guest_error_generated; diff --git a/src/hyperlight_common/src/layout.rs b/src/hyperlight_common/src/layout.rs index 3e8dfd6c8c..1ab41046c9 100644 --- a/src/hyperlight_common/src/layout.rs +++ b/src/hyperlight_common/src/layout.rs @@ -1,7 +1,7 @@ // SPDX-License-Identifier: Apache-2.0 // Copyright 2025 The Hyperlight Authors. -use core::mem::{offset_of, size_of}; +use core::mem::{align_of, offset_of, size_of}; use core::num::{NonZeroU16, NonZeroUsize}; #[cfg_attr(target_arch = "x86_64", path = "arch/amd64/layout.rs")] @@ -114,14 +114,12 @@ pub fn scratch_base_gva(size: usize) -> u64 { /// The fixed transport prefix contains one page-backed ring arena and both /// page-backed buffer pools. The result saturates at [`usize::MAX`]. pub fn min_scratch_size( - input_data_size: usize, - output_data_size: usize, g2h_queue_size: usize, h2g_queue_size: usize, g2h_pool_pages: usize, h2g_pool_pages: usize, ) -> usize { - let size = arch::min_scratch_size(input_data_size, output_data_size).and_then(|fixed| { + let size = arch::min_scratch_size().and_then(|fixed| { let g2h = QueueDims::new(g2h_queue_size, g2h_pool_pages)?; let h2g = QueueDims::new(h2g_queue_size, h2g_pool_pages)?; @@ -150,6 +148,10 @@ impl QueueDims { } let pool_pages = NonZeroUsize::new(pool_pages)?; + pool_pages.get().checked_mul(crate::vmem::PAGE_SIZE)?; + + virtq::Layout::checked_query_size(usize::from(size.get()))?; + Some(Self { size, pool_pages }) } @@ -163,26 +165,26 @@ impl QueueDims { self.pool_pages } - /// Compute the ring length, returning `None` on arithmetic overflow. - pub fn checked_ring_len(&self) -> Option { - virtq::Layout::checked_query_size(usize::from(self.size.get())) + /// Ring length in bytes. + pub const fn ring_len(&self) -> usize { + virtq::Layout::query_size(self.size.get() as usize) } - /// Compute the pool length, returning `None` on arithmetic overflow. - pub fn checked_pool_len(&self) -> Option { - self.pool_pages.get().checked_mul(crate::vmem::PAGE_SIZE) + /// Buffer pool length in bytes. + pub const fn pool_len(&self) -> usize { + self.pool_pages.get() * crate::vmem::PAGE_SIZE } } -/// Addresses of both rings and pools in one fixed transport arena. +/// Addresses of both rings, the checkpoint mailbox, and pools in one fixed arena. /// /// The G2H ring begins at the arena base. The H2G ring is descriptor aligned. -/// Both pools are page aligned. +/// The mailbox is `u64` aligned. Both pools are page aligned. /// /// ```text -/// +----------+------------+----------+-----+----------+----------+ -/// | G2H ring | align pad | H2G ring | pad | G2H pool | H2G pool | -/// +----------+------------+----------+-----+----------+----------+ +/// +----------+-----+----------+-----+-----+-----+----------+----------+ +/// | G2H ring | pad | H2G ring | pad | mbx | pad | G2H pool | H2G pool | +/// +----------+-----+----------+-----+-----+-----+----------+----------+ /// ``` #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub struct TransportArena { @@ -190,11 +192,13 @@ pub struct TransportArena { g2h_ring_addr: u64, /// Address of the H2G ring. h2g_ring_addr: u64, + /// Address of the snapshot checkpoint mailbox. + mbx_addr: u64, /// Address of the G2H pool. g2h_pool_addr: u64, /// Address of the H2G pool. h2g_pool_addr: u64, - /// Page-aligned length occupied by both rings. + /// Page-aligned length occupied by both rings and the mailbox. ring_span_len: usize, /// Total page-aligned arena length. len: usize, @@ -208,18 +212,20 @@ impl TransportArena { } let h2g_ring_offset = g2h - .checked_ring_len()? + .ring_len() .checked_next_multiple_of(virtq::Descriptor::ALIGN)?; - let g2h_pool_offset = h2g_ring_offset - .checked_add(h2g.checked_ring_len()?)? + let mbx_offset = h2g_ring_offset + .checked_add(h2g.ring_len())? + .checked_next_multiple_of(align_of::())?; + + let g2h_pool_offset = mbx_offset + .checked_add(size_of::())? .checked_next_multiple_of(crate::vmem::PAGE_SIZE)?; - let g2h_pool_len = g2h.checked_pool_len()?; - let h2g_pool_offset = g2h_pool_offset.checked_add(g2h_pool_len)?; + let h2g_pool_offset = g2h_pool_offset.checked_add(g2h.pool_len())?; - let h2g_pool_len = h2g.checked_pool_len()?; - let len = h2g_pool_offset.checked_add(h2g_pool_len)?; + let len = h2g_pool_offset.checked_add(h2g.pool_len())?; let addr = |offset: usize| base_addr.checked_add(u64::try_from(offset).ok()?); let _end_addr = addr(len)?; @@ -227,6 +233,7 @@ impl TransportArena { Some(Self { g2h_ring_addr: base_addr, h2g_ring_addr: addr(h2g_ring_offset)?, + mbx_addr: addr(mbx_offset)?, g2h_pool_addr: addr(g2h_pool_offset)?, h2g_pool_addr: addr(h2g_pool_offset)?, ring_span_len: g2h_pool_offset, @@ -254,6 +261,11 @@ impl TransportArena { self.h2g_ring_addr } + /// Address of the snapshot checkpoint mailbox. + pub const fn mbx_addr(&self) -> u64 { + self.mbx_addr + } + /// Address of the G2H pool. pub const fn g2h_pool_addr(&self) -> u64 { self.g2h_pool_addr @@ -264,7 +276,7 @@ impl TransportArena { self.h2g_pool_addr } - /// Page-aligned length occupied by both rings. + /// Page-aligned length occupied by both rings and the mailbox. pub const fn ring_span_len(&self) -> usize { self.ring_span_len } @@ -280,12 +292,13 @@ impl TransportArena { } /// Convert the arena's absolute addresses into offsets from the arena base. - pub fn to_offsets(&self) -> (usize, usize, usize, usize) { + pub fn to_offsets(&self) -> (usize, usize, usize, usize, usize) { #[allow(clippy::unwrap_used)] // `new` proves every stored offset fits in `usize`. let to_offset = |addr| usize::try_from(addr - self.g2h_ring_addr).unwrap(); ( to_offset(self.h2g_ring_addr), + to_offset(self.mbx_addr), to_offset(self.g2h_pool_addr), to_offset(self.h2g_pool_addr), self.len, @@ -304,12 +317,15 @@ mod tests { let h2g = QueueDims::new(32, 4).unwrap(); let arena = TransportArena::new(base, g2h, h2g).unwrap(); + assert_eq!(g2h.ring_len(), virtq::Layout::query_size(64)); + assert_eq!(g2h.pool_len(), 8 * crate::vmem::PAGE_SIZE); assert_eq!(arena.g2h_ring_addr(), base); assert!( arena .h2g_ring_addr() .is_multiple_of(virtq::Descriptor::ALIGN as u64) ); + assert!(arena.mbx_addr().is_multiple_of(align_of::() as u64)); assert!( arena .g2h_pool_addr() @@ -324,6 +340,7 @@ mod tests { arena.to_offsets(), ( 0x410, + 0x618, crate::vmem::PAGE_SIZE, 9 * crate::vmem::PAGE_SIZE, 13 * crate::vmem::PAGE_SIZE, @@ -339,8 +356,7 @@ mod tests { assert_eq!(QueueDims::new(3, 8), None); assert_eq!(QueueDims::new(64, 0), None); assert_eq!(QueueDims::new(usize::MAX, 8), None); - let oversized = QueueDims::new(64, usize::MAX).unwrap(); - assert_eq!(TransportArena::new(base, oversized, h2g), None); + assert_eq!(QueueDims::new(64, usize::MAX), None); assert_eq!( TransportArena::new(u64::MAX - crate::vmem::PAGE_SIZE as u64 + 1, g2h, h2g,), None @@ -349,18 +365,18 @@ mod tests { #[test] fn minimum_scratch_includes_ring_arena_and_pools() { - let fixed = arch::min_scratch_size(0, 0).unwrap(); + let fixed = arch::min_scratch_size().unwrap(); let transport_pages = 1 + 8 + 4; assert_eq!( fixed + transport_pages * crate::vmem::PAGE_SIZE, - min_scratch_size(0, 0, 64, 32, 8, 4) + min_scratch_size(64, 32, 8, 4) ); } #[test] fn minimum_scratch_saturates_on_overflow() { - assert_eq!(usize::MAX, min_scratch_size(0, 0, 64, 32, usize::MAX, 4)); - assert_eq!(usize::MAX, min_scratch_size(0, 0, usize::MAX, 32, 8, 4)); + assert_eq!(usize::MAX, min_scratch_size(64, 32, usize::MAX, 4)); + assert_eq!(usize::MAX, min_scratch_size(usize::MAX, 32, 8, 4)); } } diff --git a/src/hyperlight_common/src/lib.rs b/src/hyperlight_common/src/lib.rs index 7450e48007..dcf791fec3 100644 --- a/src/hyperlight_common/src/lib.rs +++ b/src/hyperlight_common/src/lib.rs @@ -33,6 +33,10 @@ pub mod outb; /// cbindgen:ignore pub mod resource; +/// Shared guest and host transport protocol. +// cbindgen:ignore +pub mod transport; + /// cbindgen:ignore pub mod func; diff --git a/src/hyperlight_common/src/mem.rs b/src/hyperlight_common/src/mem.rs index 66db532475..2f89ba2b76 100644 --- a/src/hyperlight_common/src/mem.rs +++ b/src/hyperlight_common/src/mem.rs @@ -14,8 +14,6 @@ pub struct GuestMemoryRegion { #[derive(Debug, Clone, Copy, PartialEq, bytemuck::Pod, bytemuck::Zeroable)] #[repr(C)] pub struct HyperlightPEB { - pub input_stack: GuestMemoryRegion, - pub output_stack: GuestMemoryRegion, pub init_data: GuestMemoryRegion, pub guest_heap: GuestMemoryRegion, } @@ -27,22 +25,14 @@ mod tests { #[test] fn peb_round_trip() { let peb = HyperlightPEB { - input_stack: GuestMemoryRegion { + init_data: GuestMemoryRegion { size: 0x1111, ptr: 0x2222, }, - output_stack: GuestMemoryRegion { + guest_heap: GuestMemoryRegion { size: 0x3333, ptr: 0x4444, }, - init_data: GuestMemoryRegion { - size: 0x5555, - ptr: 0x6666, - }, - guest_heap: GuestMemoryRegion { - size: 0x7777, - ptr: 0x8888, - }, }; let bytes = bytemuck::bytes_of(&peb); let peb2 = *bytemuck::from_bytes::(bytes); diff --git a/src/hyperlight_common/src/outb.rs b/src/hyperlight_common/src/outb.rs index f33f4d5c8a..5396eef465 100644 --- a/src/hyperlight_common/src/outb.rs +++ b/src/hyperlight_common/src/outb.rs @@ -74,16 +74,13 @@ impl TryFrom for Exception { /// Supported actions when issuing an OUTB actions by Hyperlight. /// These are handled by the sandbox-level outb dispatcher. -/// - Log: for logging, -/// - CallFunction: makes a call to a host function, /// - Abort: aborts the execution of the guest, /// - DebugPrint: prints a message to the host /// - TraceBatch: reports a batch of spans and events from the guest /// - TraceMemoryAlloc: records memory allocation events /// - TraceMemoryFree: records memory deallocation events +/// - VirtqNotify: reports newly available virtqueue work pub enum OutBAction { - Log = 99, - CallFunction = 101, Abort = 102, DebugPrint = 103, #[cfg(feature = "trace_guest")] @@ -92,6 +89,7 @@ pub enum OutBAction { TraceMemoryAlloc = 105, #[cfg(feature = "mem_profile")] TraceMemoryFree = 106, + VirtqNotify = 109, } /// IO-port actions intercepted at the hypervisor level (in `run_vcpu`) @@ -114,8 +112,6 @@ impl TryFrom for OutBAction { type Error = anyhow::Error; fn try_from(val: u16) -> anyhow::Result { match val { - 99 => Ok(OutBAction::Log), - 101 => Ok(OutBAction::CallFunction), 102 => Ok(OutBAction::Abort), 103 => Ok(OutBAction::DebugPrint), #[cfg(feature = "trace_guest")] @@ -124,6 +120,7 @@ impl TryFrom for OutBAction { 105 => Ok(OutBAction::TraceMemoryAlloc), #[cfg(feature = "mem_profile")] 106 => Ok(OutBAction::TraceMemoryFree), + 109 => Ok(OutBAction::VirtqNotify), _ => Err(anyhow::anyhow!("Invalid OutBAction value: {}", val)), } } @@ -139,3 +136,14 @@ impl TryFrom for VmAction { } } } + +#[cfg(test)] +mod tests { + use super::OutBAction; + + #[test] + fn rejects_legacy_stack_actions() { + assert!(OutBAction::try_from(99).is_err()); + assert!(OutBAction::try_from(101).is_err()); + } +} diff --git a/src/hyperlight_common/src/transport.rs b/src/hyperlight_common/src/transport.rs new file mode 100644 index 0000000000..2f8eac43a3 --- /dev/null +++ b/src/hyperlight_common/src/transport.rs @@ -0,0 +1,450 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 The Hyperlight Authors. + +//! Shared guest and host transport protocol. +//! +//! Every logical message starts with this fixed header. It enables message type +//! discrimination, request/response correlation, and payload length validation. + +use alloc::vec::Vec; + +use anyhow::Result; +pub use bytes::Buf; +use bytes::Bytes; + +use crate::flatbuffer_wrappers::ExternalValueSink; + +/// Length of a FlatBuffer size prefix. +pub const SIZE_PREFIX_LEN: usize = core::mem::size_of::(); + +/// Message types for the virtqueue wire protocol. +#[repr(u8)] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum MsgKind { + /// A function call request (FunctionCall payload follows). + Request = 0x01, + /// A function call response (FunctionCallResult payload follows). + Response = 0x02, + /// A stream data chunk. + StreamChunk = 0x03, + /// End-of-stream marker. + StreamEnd = 0x04, + /// Cancel a pending request. + Cancel = 0x05, + /// A guest log message (GuestLogData payload follows). + Log = 0x06, + /// Internal request to prepare canonical transport state for snapshotting. + SnapshotCheckpoint = 0x07, +} + +impl TryFrom for MsgKind { + type Error = u8; + + fn try_from(value: u8) -> Result { + match value { + 0x01 => Ok(Self::Request), + 0x02 => Ok(Self::Response), + 0x03 => Ok(Self::StreamChunk), + 0x04 => Ok(Self::StreamEnd), + 0x05 => Ok(Self::Cancel), + 0x06 => Ok(Self::Log), + 0x07 => Ok(Self::SnapshotCheckpoint), + other => Err(other), + } + } +} + +/// Wire header for all virtqueue messages. +#[derive(Debug, Clone, Copy, PartialEq, Eq, bytemuck::Pod, bytemuck::Zeroable)] +#[repr(C)] +pub struct MsgHeader { + /// Discriminates the message type. + pub kind: u8, + /// Keep the header aligned to four bytes. + reserved: [u8; 3], + /// Caller-assigned correlation ID. Responses echo the request's ID. + pub cid: u32, + /// Total number of payload bytes in this logical message. + pub payload_len: u32, +} + +impl MsgHeader { + pub const SIZE: usize = core::mem::size_of::(); + + /// Create a message header. + pub const fn new(kind: MsgKind, cid: u32, payload_len: u32) -> Self { + Self { + kind: kind as u8, + reserved: [0; 3], + cid, + payload_len, + } + } + + /// Parse the kind field into a [`MsgKind`] enum. + pub fn msg_kind(&self) -> Result { + MsgKind::try_from(self.kind) + } + + /// Return the wire representation. + pub fn as_bytes(&self) -> &[u8] { + bytemuck::bytes_of(self) + } + + /// Parse and validate a wire header. + pub fn from_bytes(bytes: &[u8]) -> Option { + if bytes.len() != Self::SIZE { + return None; + } + + let header: Self = bytemuck::pod_read_unaligned(bytes); + (header.reserved == [0; 3] && header.msg_kind().is_ok()).then_some(header) + } +} + +/// Borrowed wire message split into transport-ready chunks. +#[derive(Debug)] +pub struct EncodedMessage<'a> { + header: MsgHeader, + control: &'a [u8], + externals: ExternalValues<'a>, + total_len: usize, +} + +impl<'a> EncodedMessage<'a> { + /// Build a message, returning `None` if its payload exceeds the wire field. + pub fn new( + kind: MsgKind, + cid: u32, + control: &'a [u8], + externals: ExternalValues<'a>, + ) -> Option { + let payload_len = control.len().checked_add(externals.total_len())?; + let payload_len = u32::try_from(payload_len).ok()?; + let total_len = MsgHeader::SIZE.checked_add(payload_len as usize)?; + + Some(Self { + header: MsgHeader::new(kind, cid, payload_len), + control, + externals, + total_len, + }) + } + + // Build a snapshot checkpoint message with no payload. + pub fn new_snapshot_cp() -> Self { + let total_len = MsgHeader::SIZE; + let externals = ExternalValues::new(); + + Self { + header: MsgHeader::new(MsgKind::SnapshotCheckpoint, 0, 0), + control: &[], + externals, + total_len, + } + } + + /// Borrow the complete wire message as a zero-copy byte cursor. + pub fn as_buf(&self) -> impl Buf + '_ { + EncodedMessageBuf::new( + self.header.as_bytes(), + self.control, + &self.externals.chunks, + self.total_len, + ) + } + + /// Iterate over the complete wire message in transmission order. + pub fn chunks(&self) -> impl Iterator + '_ { + core::iter::once(self.header.as_bytes()) + .chain(core::iter::once(self.control)) + .chain(self.externals.chunks()) + } + + /// Iterate over external transport chunks in wire order. + pub fn external_chunks(&self) -> impl Iterator + '_ { + self.externals.chunks() + } + + /// Message header. + pub const fn header(&self) -> &MsgHeader { + &self.header + } + + /// Size-prefixed FlatBuffer control data. + pub const fn control(&self) -> &[u8] { + self.control + } + + /// Total external byte-stream length. + pub const fn external_len(&self) -> usize { + self.payload_len() - self.control.len() + } + + /// Length of the header and control prefix before external bytes. + pub const fn prefix_len(&self) -> usize { + MsgHeader::SIZE + self.control.len() + } + + /// Logical payload length after the header. + pub const fn payload_len(&self) -> usize { + self.header.payload_len as usize + } + + /// Total wire length of all chunks. + pub const fn total_len(&self) -> usize { + self.total_len + } +} + +/// Borrowed [`Buf`] cursor over an [`EncodedMessage`]. +/// +/// Advancing the cursor does not mutate the message or copy its chunks. +struct EncodedMessageBuf<'a> { + header: &'a [u8], + control: &'a [u8], + externals: &'a [&'a [u8]], + index: usize, + offset: usize, + remaining: usize, +} + +impl<'a> EncodedMessageBuf<'a> { + fn new( + header: &'a [u8], + control: &'a [u8], + externals: &'a [&'a [u8]], + remaining: usize, + ) -> Self { + let mut this = Self { + header, + control, + externals, + index: 0, + offset: 0, + remaining, + }; + + this.skip_empty_chunks(); + this + } + + fn current(&self) -> Option<&[u8]> { + match self.index { + 0 => Some(self.header), + 1 => Some(self.control), + index => self.externals.get(index - 2).copied(), + } + } + + fn skip_empty_chunks(&mut self) { + while self + .current() + .is_some_and(|chunk| self.offset >= chunk.len()) + { + self.index += 1; + self.offset = 0; + } + } +} + +impl Buf for EncodedMessageBuf<'_> { + fn remaining(&self) -> usize { + self.remaining + } + + fn chunk(&self) -> &[u8] { + if self.remaining == 0 { + return &[]; + } + + #[allow(clippy::expect_used)] // `remaining` is derived from the chunks. + let chunk = self.current().expect("message length mismatch"); + &chunk[self.offset..] + } + + fn advance(&mut self, cnt: usize) { + assert!(cnt <= self.remaining, "cannot advance past remaining bytes"); + + self.remaining -= cnt; + let mut cnt = cnt; + + while cnt != 0 { + #[allow(clippy::expect_used)] // `remaining` advances with `index`. + let chunk = self.current().expect("message length mismatch"); + let advanced = cnt.min(chunk.len() - self.offset); + + self.offset += advanced; + cnt -= advanced; + self.skip_empty_chunks(); + } + } +} + +/// Borrowed external values collected while encoding a FlatBuffer. +#[derive(Debug, Default)] +pub struct ExternalValues<'a> { + chunks: Vec<&'a [u8]>, + total_len: usize, +} + +impl<'a> ExternalValues<'a> { + /// Create an empty collection. + pub fn new() -> Self { + Self::default() + } + + /// Iterate over transport chunks in wire order. + fn chunks(&self) -> impl Iterator + '_ { + self.chunks.iter().copied() + } + + /// Total byte length of all collected values. + pub const fn total_len(&self) -> usize { + self.total_len + } +} + +impl<'a> ExternalValueSink<'a> for ExternalValues<'a> { + fn push_bytes(&mut self, value: &'a [u8]) -> Result<()> { + if value.is_empty() { + return Ok(()); + } + + self.total_len = self + .total_len + .checked_add(value.len()) + .ok_or_else(|| anyhow::anyhow!("external value length overflow"))?; + + self.chunks.push(value); + Ok(()) + } + + fn push_chunks(&mut self, value: &'a [Bytes]) -> Result<()> { + let total_len = value + .iter() + .try_fold(self.total_len, |len, chunk| len.checked_add(chunk.len())) + .ok_or_else(|| anyhow::anyhow!("external value length overflow"))?; + + let chunks = value + .iter() + .map(Bytes::as_ref) + .filter(|chunk| !chunk.is_empty()); + + self.chunks.extend(chunks); + self.total_len = total_len; + Ok(()) + } +} + +/// Decode a FlatBuffer size prefix. +pub fn size_prefix_payload_len(prefix: &[u8]) -> Option { + // TODO: this is flatbuffer-specific and should be moved probably somewhere else. + let prefix = <[u8; SIZE_PREFIX_LEN]>::try_from(prefix).ok()?; + usize::try_from(u32::from_le_bytes(prefix)).ok() +} + +/// Add the FlatBuffer size prefix to a payload length. +pub const fn size_prefixed_len(payload_len: usize) -> Option { + // TODO: this is flatbuffer-specific and should be moved probably somewhere else. + SIZE_PREFIX_LEN.checked_add(payload_len) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::flatbuffer_wrappers::ExternalValueSink; + + #[test] + fn header_contains_framing_fields() { + let header = MsgHeader::new(MsgKind::Response, 0x1234_5678, 4096); + + assert_eq!(MsgHeader::SIZE, 12); + assert_eq!(header.msg_kind(), Ok(MsgKind::Response)); + assert_eq!(header.cid, 0x1234_5678); + assert_eq!(header.payload_len, 4096); + assert_eq!(header.reserved, [0; 3]); + } + + #[test] + fn rejects_invalid_wire_headers() { + let header = MsgHeader::new(MsgKind::Request, 1, 4); + let mut bytes = [0; MsgHeader::SIZE]; + bytes.copy_from_slice(header.as_bytes()); + + bytes[1] = 1; + assert_eq!(MsgHeader::from_bytes(&bytes), None); + + bytes[1] = 0; + bytes[0] = u8::MAX; + assert_eq!(MsgHeader::from_bytes(&bytes), None); + + bytes[0] = MsgKind::Request as u8; + assert_eq!(MsgHeader::from_bytes(&bytes[..MsgHeader::SIZE - 1]), None); + } + + #[test] + fn encoded_message_yields_wire_chunks_in_order() { + let chunks = [ + bytes::Bytes::from_static(b"ef"), + bytes::Bytes::from_static(b"gh"), + ]; + let mut external_values = ExternalValues::new(); + external_values.push_bytes(b"cd").unwrap(); + external_values.push_chunks(&chunks).unwrap(); + + let message = EncodedMessage::new(MsgKind::Request, 7, b"ab", external_values).unwrap(); + let visited: Vec<_> = message.chunks().map(<[u8]>::to_vec).collect(); + + assert_eq!(message.total_len(), MsgHeader::SIZE + 8); + assert_eq!(message.prefix_len(), MsgHeader::SIZE + 2); + assert_eq!(message.payload_len(), 8); + assert_eq!(visited[1..], [b"ab", b"cd", b"ef", b"gh"]); + } + + #[test] + fn encoded_message_buf_skips_empty_chunks() { + let mut external_values = ExternalValues::new(); + external_values.chunks.push(&[]); + external_values.push_bytes(b"ab").unwrap(); + + let message = EncodedMessage::new(MsgKind::Request, 7, &[], external_values).unwrap(); + let expected = message.chunks().flatten().copied().collect::>(); + let mut cursor = message.as_buf(); + let mut actual = vec![0; cursor.remaining()]; + + cursor.copy_to_slice(&mut actual); + + assert_eq!(actual, expected); + assert!(!cursor.has_remaining()); + } + + #[test] + fn encoded_message_rejects_length_overflow() { + let external_values = ExternalValues { + chunks: Vec::new(), + total_len: usize::MAX, + }; + + assert!(EncodedMessage::new(MsgKind::Request, 7, b"x", external_values).is_none()); + + let mut external_values = ExternalValues { + chunks: Vec::new(), + total_len: usize::MAX, + }; + assert!(external_values.push_bytes(b"x").is_err()); + assert!(external_values.chunks.is_empty()); + + let chunks = [Bytes::from_static(b"x")]; + assert!(external_values.push_chunks(&chunks).is_err()); + assert!(external_values.chunks.is_empty()); + } + + #[test] + fn size_prefix_helpers_validate_length() { + assert_eq!(size_prefix_payload_len(&4u32.to_le_bytes()), Some(4)); + assert_eq!(size_prefix_payload_len(&[0; 3]), None); + assert_eq!(size_prefixed_len(4), Some(SIZE_PREFIX_LEN + 4)); + assert_eq!(size_prefixed_len(usize::MAX), None); + } +} diff --git a/src/hyperlight_common/src/virtq/buffer.rs b/src/hyperlight_common/src/virtq/buffer.rs index 4ca31a3a03..4493f8099d 100644 --- a/src/hyperlight_common/src/virtq/buffer.rs +++ b/src/hyperlight_common/src/virtq/buffer.rs @@ -115,6 +115,11 @@ impl Segments { } } + /// Consume this payload without flattening its segments. + pub fn into_chunks(self) -> Vec { + self.0.into_vec() + } + fn collect(&self, sgs: &[Bytes], len: usize) -> Bytes { let mut out = Vec::with_capacity(len); out.extend(sgs.iter().flat_map(|seg| seg.iter().copied())); @@ -377,4 +382,18 @@ mod tests { assert_eq!(collected.as_ptr(), ptr); assert_eq!(collected.as_ref(), &[1, 2, 3, 4]); } + + #[test] + fn segments_into_chunks_preserves_segment_storage() { + let first = Bytes::from(vec![1, 2]); + let second = Bytes::from(vec![3, 4]); + let first_ptr = first.as_ptr(); + let second_ptr = second.as_ptr(); + + let chunks = Segments::new([first, second]).into_chunks(); + + assert_eq!(chunks.len(), 2); + assert_eq!(chunks[0].as_ptr(), first_ptr); + assert_eq!(chunks[1].as_ptr(), second_ptr); + } } diff --git a/src/hyperlight_common/src/virtq/consumer.rs b/src/hyperlight_common/src/virtq/consumer.rs index a063d8ae03..8eb265a760 100644 --- a/src/hyperlight_common/src/virtq/consumer.rs +++ b/src/hyperlight_common/src/virtq/consumer.rs @@ -2,6 +2,7 @@ // Copyright 2026 The Hyperlight Authors. use alloc::vec; +use alloc::vec::Vec; use core::fmt; use bytes::Bytes; @@ -185,6 +186,12 @@ pub enum ReplyChain { Ack(AckChain), } +/// One polled chain and its matching completion capability. +pub type PolledChain = (RecvChain, ReplyChain); + +/// An exact batch returned by [`VirtqConsumer::poll_exact`]. +pub type PolledChains = Vec>; + impl ReplyChain { /// The token identifying this reply. #[inline] @@ -251,6 +258,12 @@ impl WritableChain { self.state.total() } + /// Number of writable descriptors in this chain. + #[inline] + pub fn desc_count(&self) -> usize { + self.state.elems.len() + } + /// Number of bytes written so far. #[inline] pub fn written(&self) -> usize { @@ -444,11 +457,7 @@ impl VirtqConsumer { /// /// - [`VirtqError::BadChain`] - Descriptor chain format not recognized /// - [`VirtqError::InvalidState`] - Descriptor ID collision (driver bug) - #[allow(clippy::type_complexity)] - pub fn poll( - &mut self, - max_recv_len: usize, - ) -> Result, ReplyChain)>, VirtqError> { + pub fn poll(&mut self, max_recv_len: usize) -> Result>, VirtqError> { let (id, chain) = match self.inner.poll_available() { Ok(x) => x, Err(RingError::WouldBlock) => return Ok(None), @@ -512,6 +521,80 @@ impl VirtqConsumer { Ok(Some((chain, reply))) } + /// Poll exactly `count` chains without consuming a partial batch. + /// + /// Returns `None` and restores the consumer's local state when fewer than + /// `count` chains are available. Each returned chain must be completed + /// through [`complete`](Self::complete). + /// + /// # Arguments + /// + /// * `count` - Exact number of chains to poll. Zero returns an empty batch. + /// * `max_recv_len` - Maximum readable payload size accepted for each chain + /// independently. + pub fn poll_exact( + &mut self, + count: usize, + max_recv_len: usize, + ) -> Result>, VirtqError> { + self.poll_exact_with_spare(count, 0, max_recv_len) + } + + /// Poll exactly `count` chains while leaving `spare` chains available. + /// + /// Returns `None` and restores the consumer's local state when fewer than + /// `count + spare` chains are available. The spare chains are inspected + /// without completing them and remain available for a later poll. + /// + /// # Arguments + /// + /// * `count` - Exact number of chains to poll. Zero returns an empty batch. + /// * `spare` - Number of chains to leave available for later inspection. + /// * `max_recv_len` - Maximum readable payload size accepted for each chain + /// independently. + pub fn poll_exact_with_spare( + &mut self, + count: usize, + spare: usize, + max_recv_len: usize, + ) -> Result>, VirtqError> { + let Some(total) = count.checked_add(spare) else { + return Ok(None); + }; + + // Every chain consumes at least one descriptor. + if total > self.inner.num_free() { + return Ok(None); + } + + // Polling changes only local bookkeeping until a chain is completed. + let cp = self.inner.poll_checkpoint(); + let next_token = self.next_token; + + let mut spare_cp = None; + let mut polled = PolledChains::with_capacity(total); + + while polled.len() < total { + if spare != 0 && polled.len() == count { + spare_cp = Some((self.inner.poll_checkpoint(), self.next_token)); + } + + if let Some(chain) = self.poll(max_recv_len)? { + polled.push(chain); + continue; + } + + self.rollback_polled(cp, next_token, polled)?; + return Ok(None); + } + + if let Some((checkpoint, next_token)) = spare_cp { + self.rollback_polled(checkpoint, next_token, polled.drain(count..))?; + } + + Ok(Some(polled)) + } + /// Submit both halves of a received chain back to the ring. /// /// Consuming the [`RecvChain`] prevents further reads once its descriptors @@ -641,6 +724,29 @@ impl VirtqConsumer { self.inner.reset()?; self.inflight.clear(); + self.next_token = 0; + Ok(()) + } + + fn rollback_polled( + &mut self, + checkpoint: Checkpoint, + next_token: u32, + polled: impl IntoIterator>, + ) -> Result<(), VirtqError> { + // No chain handle may survive when its descriptor becomes pollable again. + let ids = polled + .into_iter() + .map(|(recv, _)| recv.token().id) + .collect::>(); + + self.inner.rollback_polls(checkpoint, &ids)?; + self.next_token = next_token; + + for id in ids { + self.inflight.set(id as usize, false); + } + Ok(()) } } @@ -970,6 +1076,155 @@ mod tests { } } + #[test] + fn test_poll_exact_rolls_back_partial_batch() { + let ring = make_ring(16); + let (mut producer, mut consumer, _notifier) = make_test_producer(&ring); + + for _ in 0..2 { + let chain = producer.chain().writable(16).build().unwrap(); + producer.submit(chain).unwrap(); + } + + let cursor = consumer.avail_cursor(); + assert!(consumer.poll_exact(3, 0).unwrap().is_none()); + assert_eq!(consumer.avail_cursor(), cursor); + assert_eq!(consumer.inflight.count_ones(..), 0); + assert_eq!(consumer.inner.num_inflight(), 0); + assert_eq!(consumer.next_token, 0); + assert!(producer.poll().unwrap().is_none()); + + let reserved = consumer.poll_exact(2, 0).unwrap().unwrap(); + for (recv, reply) in reserved { + consumer.complete(recv, reply).unwrap(); + } + assert!(producer.poll().unwrap().is_some()); + assert!(producer.poll().unwrap().is_some()); + } + + #[test] + fn test_poll_exact_with_spare_leaves_spare_available() { + let ring = make_ring(16); + let (mut producer, mut consumer, _notifier) = make_test_producer(&ring); + + for _ in 0..3 { + let chain = producer.chain().writable(16).build().unwrap(); + producer.submit(chain).unwrap(); + } + + let polled = consumer.poll_exact_with_spare(2, 1, 0).unwrap().unwrap(); + assert_eq!(consumer.avail_cursor().head(), 2); + assert_eq!(consumer.inflight.count_ones(..), 2); + assert_eq!(consumer.next_token, 2); + + for (recv, reply) in polled { + consumer.complete(recv, reply).unwrap(); + } + + let (recv, reply) = consumer.poll(0).unwrap().unwrap(); + assert_eq!(recv.token().seq, 2); + consumer.complete(recv, reply).unwrap(); + } + + #[test] + fn test_poll_exact_with_spare_rolls_back_requested_chains() { + let ring = make_ring(16); + let (mut producer, mut consumer, _notifier) = make_test_producer(&ring); + + for _ in 0..2 { + let chain = producer.chain().writable(16).build().unwrap(); + producer.submit(chain).unwrap(); + } + + let cursor = consumer.avail_cursor(); + assert!(consumer.poll_exact_with_spare(2, 1, 0).unwrap().is_none()); + assert_eq!(consumer.avail_cursor(), cursor); + assert_eq!(consumer.inflight.count_ones(..), 0); + assert_eq!(consumer.inner.num_inflight(), 0); + assert_eq!(consumer.next_token, 0); + + let polled = consumer.poll_exact(2, 0).unwrap().unwrap(); + for (recv, reply) in polled { + consumer.complete(recv, reply).unwrap(); + } + } + + #[test] + fn test_poll_exact_preserves_existing_inflight_chain() { + let ring = make_ring(16); + let (mut producer, mut consumer, _notifier) = make_test_producer(&ring); + + for _ in 0..3 { + let chain = producer.chain().writable(16).build().unwrap(); + producer.submit(chain).unwrap(); + } + + let existing = consumer.poll(0).unwrap().unwrap(); + let cursor = consumer.avail_cursor(); + assert!(consumer.poll_exact(3, 0).unwrap().is_none()); + assert_eq!(consumer.avail_cursor(), cursor); + assert_eq!(consumer.inflight.count_ones(..), 1); + assert_eq!(consumer.inner.num_inflight(), 1); + + let reserved = consumer.poll_exact(2, 0).unwrap().unwrap(); + consumer.complete(existing.0, existing.1).unwrap(); + for (recv, reply) in reserved { + consumer.complete(recv, reply).unwrap(); + } + } + + #[test] + fn test_poll_exact_rolls_back_multi_descriptor_chain() { + let ring = make_ring(16); + let (mut producer, mut consumer, _notifier) = make_test_producer(&ring); + + let chain = producer.chain().writable(8).writable(8).build().unwrap(); + producer.submit(chain).unwrap(); + + let cursor = consumer.avail_cursor(); + assert!(consumer.poll_exact(2, 0).unwrap().is_none()); + assert_eq!(consumer.avail_cursor(), cursor); + assert_eq!(consumer.inner.num_inflight(), 0); + + let mut reserved = consumer.poll_exact(1, 0).unwrap().unwrap(); + let (recv, reply) = reserved.pop().unwrap(); + let ReplyChain::Writable(writable) = &reply else { + panic!("expected writable chain"); + }; + assert_eq!(writable.desc_count(), 2); + consumer.complete(recv, reply).unwrap(); + } + + #[test] + fn test_poll_exact_rolls_back_across_wrap() { + let ring = make_ring(4); + let (mut producer, mut consumer, _notifier) = make_test_producer(&ring); + + for _ in 0..3 { + let chain = producer.chain().writable(16).build().unwrap(); + producer.submit(chain).unwrap(); + } + let reserved = consumer.poll_exact(3, 0).unwrap().unwrap(); + for (recv, reply) in reserved { + consumer.complete(recv, reply).unwrap(); + } + for _ in 0..3 { + assert!(producer.poll().unwrap().is_some()); + } + + let chain = producer.chain().writable(16).build().unwrap(); + producer.submit(chain).unwrap(); + + let cursor = consumer.avail_cursor(); + assert_eq!(cursor.head(), 3); + assert!(consumer.poll_exact(2, 0).unwrap().is_none()); + assert_eq!(consumer.avail_cursor(), cursor); + + let mut reserved = consumer.poll_exact(1, 0).unwrap().unwrap(); + let (recv, reply) = reserved.pop().unwrap(); + consumer.complete(recv, reply).unwrap(); + } + #[test] fn test_poll_too_large_returns_payload_error() { let ring = make_ring(16); @@ -1278,5 +1533,6 @@ mod tests { assert_eq!(consumer.inflight.count_ones(..), 0); assert_eq!(consumer.inner.num_inflight(), 0); + assert_eq!(consumer.next_token, 0); } } diff --git a/src/hyperlight_common/src/virtq/mod.rs b/src/hyperlight_common/src/virtq/mod.rs index 1ee43d538b..db44d5986a 100644 --- a/src/hyperlight_common/src/virtq/mod.rs +++ b/src/hyperlight_common/src/virtq/mod.rs @@ -152,7 +152,6 @@ mod buffer; mod consumer; mod desc; mod event; -pub mod msg; mod pool; mod producer; mod ring; @@ -950,6 +949,7 @@ mod tests { send_readonly(&mut producer, b"b"); send_readonly(&mut producer, b"c"); send_readonly(&mut producer, b"d"); + assert_eq!(producer.num_inflight(), 4); // Ring is now full - next submit should fail with Backpressure let mut se = producer.chain().readable(1).build().unwrap(); @@ -969,6 +969,7 @@ mod tests { // Reclaim should free ring slots without losing data let count = producer.reclaim().unwrap(); assert_eq!(count, 4, "expected 4 reclaimed entries"); + assert_eq!(producer.num_inflight(), 0); // Ring should have space now send_readonly(&mut producer, b"e"); diff --git a/src/hyperlight_common/src/virtq/msg.rs b/src/hyperlight_common/src/virtq/msg.rs deleted file mode 100644 index bac9da90ca..0000000000 --- a/src/hyperlight_common/src/virtq/msg.rs +++ /dev/null @@ -1,107 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -// Copyright 2026 The Hyperlight Authors. - -//! Wire format header for all virtqueue messages. -//! -//! Every payload on both the G2H and H2G queues starts with this -//! fixed 8-byte header, enabling message type discrimination and -//! request/response correlation. - -use bitflags::bitflags; - -/// Message types for the virtqueue wire protocol. -#[repr(u8)] -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum MsgKind { - /// A function call request (FunctionCall payload follows). - Request = 0x01, - /// A function call response (FunctionCallResult payload follows). - Response = 0x02, - /// A stream data chunk. - StreamChunk = 0x03, - /// End-of-stream marker. - StreamEnd = 0x04, - /// Cancel a pending request. - Cancel = 0x05, - /// A guest log message (GuestLogData payload follows). - Log = 0x06, -} - -impl TryFrom for MsgKind { - type Error = u8; - - fn try_from(value: u8) -> Result { - match value { - 0x01 => Ok(Self::Request), - 0x02 => Ok(Self::Response), - 0x03 => Ok(Self::StreamChunk), - 0x04 => Ok(Self::StreamEnd), - 0x05 => Ok(Self::Cancel), - 0x06 => Ok(Self::Log), - other => Err(other), - } - } -} - -bitflags! { - #[repr(transparent)] - #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] - pub struct MsgFlags: u8 { - /// More descriptors follow for this message. - const MORE = 1 << 0; - } -} - -/// Wire header for all virtqueue messages -#[derive(Debug, Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)] -#[repr(C)] -pub struct VirtqMsgHeader { - /// Discriminates the message type. - pub kind: u8, - /// Per-message flags (see [`MsgFlags`]). - pub flags: u8, - /// Caller-assigned correlation ID. Responses echo the request's ID. - pub req_id: u16, - /// Byte length of the payload following this header in this descriptor. - pub payload_len: u32, -} - -impl VirtqMsgHeader { - pub const SIZE: usize = core::mem::size_of::(); - - /// Create a new message header with no flags set. - pub const fn new(kind: MsgKind, req_id: u16, payload_len: u32) -> Self { - Self { - kind: kind as u8, - flags: 0, - req_id, - payload_len, - } - } - - /// Create a new header with flags. - pub const fn with_flags(kind: MsgKind, flags: MsgFlags, req_id: u16, payload_len: u32) -> Self { - Self { - kind: kind as u8, - flags: flags.bits(), - req_id, - payload_len, - } - } - - /// Parse the kind field into a [`MsgKind`] enum. - pub fn msg_kind(&self) -> Result { - MsgKind::try_from(self.kind) - } - - /// Interpret the raw flags field as [`MsgFlags`]. - pub fn msg_flags(&self) -> MsgFlags { - MsgFlags::from_bits_truncate(self.flags) - } - - /// Returns true if [`MsgFlags::MORE`] is set, indicating more - /// descriptors follow for this message. - pub const fn has_more(&self) -> bool { - self.flags & MsgFlags::MORE.bits() != 0 - } -} diff --git a/src/hyperlight_common/src/virtq/producer.rs b/src/hyperlight_common/src/virtq/producer.rs index 2b78cdb102..2e52b244e3 100644 --- a/src/hyperlight_common/src/virtq/producer.rs +++ b/src/hyperlight_common/src/virtq/producer.rs @@ -589,9 +589,9 @@ where /// A scoped batch of producer submissions. /// /// Submissions are published immediately, while notification is delayed until -/// [`finish`](Self::finish). `finish` is explicit because the event-suppression -/// check can fail; dropping a batch does not notify. -#[must_use = "call finish to notify the consumer about batched submissions"] +/// [`finish`](Self::finish). [`finish_without_notify`](Self::finish_without_notify) +/// supports protocols whose peer is already scheduled to inspect the queue. +#[must_use = "finish the batch explicitly"] pub struct SubmitBatch<'a, M, N, P> { producer: &'a mut VirtqProducer, notify_from: Option, @@ -637,6 +637,12 @@ where self.producer.notify_since(notify_from) } + + /// Finish the batch without notifying the consumer. + /// + /// Use this only when another protocol event guarantees that the consumer + /// will inspect the published descriptors. + pub fn finish_without_notify(self) {} } /// Builder for configuring a descriptor chain's buffer layout. @@ -1890,6 +1896,23 @@ mod tests { assert_eq!(notifier.notification_count(), 0); } + #[test] + fn test_batch_can_finish_without_notification() { + let ring = make_ring(16); + let (mut producer, mut consumer, notifier) = make_test_producer(&ring); + + let mut batch = producer.batch(); + let mut chain = batch.chain().readable(4).build().unwrap(); + chain.write_all(b"data").unwrap(); + batch.submit(chain).unwrap(); + batch.finish_without_notify(); + + assert_eq!(notifier.notification_count(), 0); + let (recv, reply) = poll_received(&mut consumer); + assert_eq!(recv.to_bytes().unwrap().as_ref(), b"data"); + consumer.complete(recv, reply).unwrap(); + } + #[test] fn test_write_only_round_trip() { let ring = make_ring(16); diff --git a/src/hyperlight_common/src/virtq/ring.rs b/src/hyperlight_common/src/virtq/ring.rs index 65557812f5..ab11d6de12 100644 --- a/src/hyperlight_common/src/virtq/ring.rs +++ b/src/hyperlight_common/src/virtq/ring.rs @@ -454,6 +454,19 @@ impl RingCursor { } } +/// Local [`RingConsumer`] state needed to undo a sequence of polls. +/// +/// The checkpoint is valid until a polled chain is completed. +#[derive(Clone, Copy)] +pub struct Checkpoint { + /// Position of the next available chain. + avail_cursor: RingCursor, + /// Position of the next completion. + used_cursor: RingCursor, + /// Number of descriptors awaiting completion. + num_inflight: usize, +} + /// Producer (driver) side of a packed virtqueue. /// /// The producer submits buffer chains for the device to process and polls @@ -1196,6 +1209,62 @@ impl RingConsumer { Ok(flags.is_avail(self.avail_cursor.wrap())) } + /// Capture the local state needed to undo subsequent polls. + pub fn poll_checkpoint(&self) -> Checkpoint { + Checkpoint { + avail_cursor: self.avail_cursor, + used_cursor: self.used_cursor, + num_inflight: self.num_inflight, + } + } + + /// Undo every chain in `ids` polled after `cp`. + /// + /// None of the chains may have been completed. On success, the next poll + /// observes the first chain again. + /// + /// # Errors + /// + /// Returns [`RingError::InvalidState`] when the IDs, cursors, or inflight + /// descriptor count do not match the checkpoint. + pub fn rollback_polls(&mut self, cp: Checkpoint, ids: &[u16]) -> Result<(), RingError> { + let desc_count = ids.iter().try_fold(0usize, |count, id| { + let chain_len = self + .id_num + .get(*id as usize) + .copied() + .filter(|len| *len != 0) + .ok_or(RingError::InvalidState)?; + + count + .checked_add(chain_len as usize) + .ok_or(RingError::InvalidState) + })?; + + let expected_inflight = cp + .num_inflight + .checked_add(desc_count) + .ok_or(RingError::InvalidState)?; + + let mut expected_cursor = cp.avail_cursor; + expected_cursor.advance_by(u16::try_from(desc_count).map_err(|_| RingError::InvalidState)?); + + if self.avail_cursor != expected_cursor + || self.used_cursor != cp.used_cursor + || self.num_inflight != expected_inflight + { + return Err(RingError::InvalidState); + } + + for id in ids { + self.id_num[*id as usize] = 0; + } + + self.avail_cursor = cp.avail_cursor; + self.num_inflight = cp.num_inflight; + Ok(()) + } + /// Submit a used descriptor and return whether to notify the driver. pub fn submit_used_with_notify( &mut self, diff --git a/src/hyperlight_common/src/virtq/ring/canonical.rs b/src/hyperlight_common/src/virtq/ring/canonical.rs index 684ecf68b9..baf01b9b78 100644 --- a/src/hyperlight_common/src/virtq/ring/canonical.rs +++ b/src/hyperlight_common/src/virtq/ring/canonical.rs @@ -1,18 +1,5 @@ -/* -Copyright 2026 The Hyperlight Authors. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. - */ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 The Hyperlight Authors. //! Canonical packed virtqueue images. //! diff --git a/src/hyperlight_common/src/virtq/ring/fuzz.rs b/src/hyperlight_common/src/virtq/ring/fuzz.rs index cef6d12e02..878b84b804 100644 --- a/src/hyperlight_common/src/virtq/ring/fuzz.rs +++ b/src/hyperlight_common/src/virtq/ring/fuzz.rs @@ -1,18 +1,5 @@ -/* -Copyright 2026 The Hyperlight Authors. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 The Hyperlight Authors. use quickcheck::{Arbitrary, Gen, QuickCheck}; diff --git a/src/hyperlight_component_util/src/guest.rs b/src/hyperlight_component_util/src/guest.rs index be02bd0694..ffbcb596f9 100644 --- a/src/hyperlight_component_util/src/guest.rs +++ b/src/hyperlight_component_util/src/guest.rs @@ -194,12 +194,12 @@ fn emit_export_extern_decl<'a, 'b, 'c>( let marshal_result = emit_hl_marshal_result(s, ret.clone(), &ft.result); let trait_path = s.cur_trait_path(); quote! { - fn #n(fc: ::hyperlight_common::flatbuffer_wrappers::function_call::FunctionCall) -> ::hyperlight_guest::error::Result<::alloc::vec::Vec> { + fn #n(fc: ::hyperlight_common::flatbuffer_wrappers::function_call::FunctionCall) -> ::hyperlight_guest::error::Result<::hyperlight_common::flatbuffer_wrappers::function_types::ReturnValue> { ::with_guest_state(|state| { #(#pds)* #(#get_instance)* let #ret = #trait_path::#n(state, #(#pus,)*); - ::core::result::Result::Ok(::hyperlight_common::flatbuffer_wrappers::util::get_flatbuffer_result::<&[u8]>(&#marshal_result)) + ::core::result::Result::Ok(::hyperlight_common::flatbuffer_wrappers::function_types::ReturnValue::VecBytes(#marshal_result)) }) } ::hyperlight_guest_bin::guest_function::register::register_function( diff --git a/src/hyperlight_guest/src/error.rs b/src/hyperlight_guest/src/error.rs index a6014fde62..147a6921c4 100644 --- a/src/hyperlight_guest/src/error.rs +++ b/src/hyperlight_guest/src/error.rs @@ -5,6 +5,7 @@ use alloc::format; use alloc::string::{String, ToString as _}; pub use hyperlight_common::flatbuffer_wrappers::guest_error::ErrorCode; +use hyperlight_common::flatbuffer_wrappers::guest_error::GuestError; use hyperlight_common::func::Error as FuncError; use hyperlight_common::virtq::VirtqError; use {anyhow, serde_json}; @@ -77,6 +78,15 @@ impl From for HyperlightGuestError { } } +impl From for HyperlightGuestError { + fn from(error: GuestError) -> Self { + Self { + kind: error.code, + message: error.message, + } + } +} + /// Extension trait to add context to `Option` and `Result` types in guest code, /// converting them to `Result`. /// @@ -168,10 +178,10 @@ impl GuestErrorContext for core::result::Result { #[macro_export] macro_rules! bail { ($ec:expr => $($msg:tt)*) => { - return ::core::result::Result::Err($crate::error::HyperlightGuestError::new($ec, ::alloc::format!($($msg)*))); + return ::core::result::Result::Err($crate::error::HyperlightGuestError::new($ec, ::alloc::format!($($msg)*))) }; ($($msg:tt)*) => { - $crate::bail!($crate::error::ErrorCode::GuestError => $($msg)*); + $crate::bail!($crate::error::ErrorCode::GuestError => $($msg)*) }; } diff --git a/src/hyperlight_guest/src/guest_handle/handle.rs b/src/hyperlight_guest/src/guest_handle/handle.rs index e9967cdf2e..47b4aa845f 100644 --- a/src/hyperlight_guest/src/guest_handle/handle.rs +++ b/src/hyperlight_guest/src/guest_handle/handle.rs @@ -1,17 +1,16 @@ // SPDX-License-Identifier: Apache-2.0 // Copyright 2025 The Hyperlight Authors. +use alloc::format; +use alloc::vec::Vec; + +use hyperlight_common::flatbuffer_wrappers::guest_error::ErrorCode; use hyperlight_common::mem::HyperlightPEB; +use tracing::instrument; + +use crate::error::{HyperlightGuestError, Result}; -/// A guest handle holds the `HyperlightPEB` and enables the guest to perform -/// operations like: -/// - calling host functions, -/// - accessing shared input and output buffers, -/// - writing errors, -/// - etc. -/// -/// Guests are expected to initialize this and store it. For example, you -/// could store it in a global variable. +/// Access to memory regions described by the guest's `HyperlightPEB`. #[derive(Debug, Clone, Copy, Default)] pub struct GuestHandle { peb: Option<*mut HyperlightPEB>, @@ -32,4 +31,29 @@ impl GuestHandle { pub fn peb(&self) -> Option<*mut HyperlightPEB> { self.peb } + + /// Get user memory region as bytes. + #[instrument(skip_all, level = "Trace")] + pub fn read_n_bytes_from_user_memory(&self, num: u64) -> Result> { + let peb_ptr = self.peb().unwrap(); + // SAFETY: GuestHandle is initialized with the PEB provided by the host, + // which remains valid for the guest lifetime. + let init_data = unsafe { (*peb_ptr).init_data }; + + if num > init_data.size { + return Err(HyperlightGuestError::new( + ErrorCode::GuestError, + format!( + "Requested {} bytes from user memory, but only {} bytes are available", + num, init_data.size + ), + )); + } + + // SAFETY: The PEB describes a valid user memory region and num was + // checked against its size. + let bytes = + unsafe { core::slice::from_raw_parts(init_data.ptr as *const u8, num as usize) }; + Ok(bytes.to_vec()) + } } diff --git a/src/hyperlight_guest/src/guest_handle/host_comm.rs b/src/hyperlight_guest/src/guest_handle/host_comm.rs deleted file mode 100644 index 31e160dbad..0000000000 --- a/src/hyperlight_guest/src/guest_handle/host_comm.rs +++ /dev/null @@ -1,194 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -// Copyright 2025 The Hyperlight Authors. - -use alloc::format; -use alloc::string::ToString; -use alloc::vec::Vec; - -use flatbuffers::FlatBufferBuilder; -use hyperlight_common::flatbuffer_wrappers::function_call::{FunctionCall, FunctionCallType}; -use hyperlight_common::flatbuffer_wrappers::function_types::{ - FunctionCallResult, ParameterValue, ReturnType, ReturnValue, -}; -use hyperlight_common::flatbuffer_wrappers::guest_error::ErrorCode; -use hyperlight_common::flatbuffer_wrappers::guest_log_data::GuestLogData; -use hyperlight_common::flatbuffer_wrappers::guest_log_level::LogLevel; -use hyperlight_common::flatbuffer_wrappers::util::estimate_flatbuffer_capacity; -use hyperlight_common::outb::OutBAction; -use tracing::instrument; - -use super::handle::GuestHandle; -use crate::error::{HyperlightGuestError, Result}; -use crate::exit::out32; - -impl GuestHandle { - /// Get user memory region as bytes. - #[instrument(skip_all, level = "Trace")] - pub fn read_n_bytes_from_user_memory(&self, num: u64) -> Result> { - let peb_ptr = self.peb().unwrap(); - let user_memory_region_ptr = unsafe { (*peb_ptr).init_data.ptr as *mut u8 }; - let user_memory_region_size = unsafe { (*peb_ptr).init_data.size }; - - if num > user_memory_region_size { - Err(HyperlightGuestError::new( - ErrorCode::GuestError, - format!( - "Requested {} bytes from user memory, but only {} bytes are available", - num, user_memory_region_size - ), - )) - } else { - let user_memory_region_slice = - unsafe { core::slice::from_raw_parts(user_memory_region_ptr, num as usize) }; - let user_memory_region_bytes = user_memory_region_slice.to_vec(); - - Ok(user_memory_region_bytes) - } - } - - /// Get a return value from a host function call. - /// This usually requires a host function to be called first using - /// `call_host_function_internal`. - /// - /// When calling `call_host_function`, this function is called - /// internally to get the return value. - #[instrument(skip_all, level = "Trace")] - pub fn get_host_return_value>(&self) -> Result { - let inner = self - .try_pop_shared_input_data_into::() - .expect("Unable to deserialize a return value from host") - .into_inner(); - - match inner { - Ok(ret) => T::try_from(ret).map_err(|_| { - let expected = core::any::type_name::(); - HyperlightGuestError::new( - ErrorCode::UnsupportedParameterType, - format!("Host return value could not be converted to expected {expected}",), - ) - }), - Err(e) => Err(HyperlightGuestError { - kind: e.code, - message: e.message, - }), - } - } - - pub fn get_host_return_raw(&self) -> Result { - let inner = self - .try_pop_shared_input_data_into::() - .expect("Unable to deserialize a return value from host") - .into_inner(); - - match inner { - Ok(ret) => Ok(ret), - Err(e) => Err(HyperlightGuestError { - kind: e.code, - message: e.message, - }), - } - } - - /// Call a host function without reading its return value from shared mem. - /// This is used by both the Rust and C APIs to reduce code duplication. - /// - /// Note: The function return value must be obtained by calling - /// `get_host_return_value`. - #[instrument(skip_all, level = "Trace")] - pub fn call_host_function_without_returning_result( - &self, - function_name: &str, - parameters: Option>, - return_type: ReturnType, - ) -> Result<()> { - let estimated_capacity = - estimate_flatbuffer_capacity(function_name, parameters.as_deref().unwrap_or(&[])); - - let host_function_call = FunctionCall::new( - function_name.to_string(), - parameters, - FunctionCallType::Host, - return_type, - ); - - let mut builder = FlatBufferBuilder::with_capacity(estimated_capacity); - - let host_function_call_buffer = host_function_call.encode(&mut builder); - self.push_shared_output_data(host_function_call_buffer)?; - - unsafe { - out32(OutBAction::CallFunction as u16, 0); - } - - Ok(()) - } - - /// Call a host function with the given parameters and return type. - /// This function serializes the function call and its parameters, - /// sends it to the host, and then retrieves the return value. - /// - /// The return value is deserialized into the specified type `T`. - #[instrument(skip_all, level = "Info")] - pub fn call_host_function>( - &self, - function_name: &str, - parameters: Option>, - return_type: ReturnType, - ) -> Result { - self.call_host_function_without_returning_result(function_name, parameters, return_type)?; - self.get_host_return_value::() - } - - /// Log a message with the specified log level, source, caller, source file, and line number. - pub fn log_message( - &self, - log_level: LogLevel, - message: &str, - source: &str, - caller: &str, - source_file: &str, - line: u32, - ) { - // Closure to send log message to host - let _send_to_host = || { - let guest_log_data = GuestLogData::new( - message.to_string(), - source.to_string(), - log_level, - caller.to_string(), - source_file.to_string(), - line, - ); - - let bytes: Vec = guest_log_data - .try_into() - .expect("Failed to convert GuestLogData to bytes"); - - self.push_shared_output_data(&bytes) - .expect("Unable to push log data to shared output data"); - - unsafe { - out32(OutBAction::Log as u16, 0); - } - }; - - #[cfg(all(feature = "trace_guest", target_arch = "x86_64"))] - if hyperlight_guest_tracing::is_trace_enabled() { - // If the "trace_guest" feature is enabled and tracing is initialized, log using tracing - tracing::trace!( - event = message, - level = ?log_level, - code.filepath = source, - caller = caller, - source_file = source_file, - code.lineno = line, - ); - } else { - _send_to_host(); - } - #[cfg(not(all(feature = "trace_guest", target_arch = "x86_64")))] - { - _send_to_host(); - } - } -} diff --git a/src/hyperlight_guest/src/guest_handle/io.rs b/src/hyperlight_guest/src/guest_handle/io.rs deleted file mode 100644 index 77d87726fc..0000000000 --- a/src/hyperlight_guest/src/guest_handle/io.rs +++ /dev/null @@ -1,137 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -// Copyright 2025 The Hyperlight Authors. - -use alloc::format; -use alloc::string::ToString; -use core::any::type_name; -use core::slice::from_raw_parts_mut; - -use hyperlight_common::flatbuffer_wrappers::guest_error::ErrorCode; -use tracing::instrument; - -use super::handle::GuestHandle; -use crate::error::{HyperlightGuestError, Result}; - -impl GuestHandle { - /// Pops the top element from the shared input data buffer and returns it as a T - #[instrument(skip_all, level = "Trace")] - pub fn try_pop_shared_input_data_into(&self) -> Result - where - T: for<'a> TryFrom<&'a [u8]>, - { - let peb_ptr = self.peb().unwrap(); - let input_stack_size = unsafe { (*peb_ptr).input_stack.size as usize }; - let input_stack_ptr = unsafe { (*peb_ptr).input_stack.ptr as *mut u8 }; - - let idb = unsafe { from_raw_parts_mut(input_stack_ptr, input_stack_size) }; - - if idb.is_empty() { - return Err(HyperlightGuestError::new( - ErrorCode::GuestError, - "Got a 0-size buffer in pop_shared_input_data_into".to_string(), - )); - } - - // get relative offset to next free address - let stack_ptr_rel: u64 = - u64::from_le_bytes(idb[..8].try_into().expect("Shared input buffer too small")); - - if stack_ptr_rel as usize > input_stack_size || stack_ptr_rel < 16 { - return Err(HyperlightGuestError::new( - ErrorCode::GuestError, - format!( - "Invalid stack pointer: {} in pop_shared_input_data_into", - stack_ptr_rel - ), - )); - } - - // go back 8 bytes and read. This is the offset to the element on top of stack - let last_element_offset_rel = u64::from_le_bytes( - idb[stack_ptr_rel as usize - 8..stack_ptr_rel as usize] - .try_into() - .expect("Invalid stack pointer in pop_shared_input_data_into"), - ); - - let buffer = &idb[last_element_offset_rel as usize..]; - - // convert the buffer to T - let type_t = match T::try_from(buffer) { - Ok(t) => Ok(t), - Err(_e) => { - return Err(HyperlightGuestError::new( - ErrorCode::GuestError, - format!("Unable to convert buffer to {}", type_name::()), - )); - } - }; - - // update the stack pointer to point to the element we just popped of since that is now free - idb[..8].copy_from_slice(&last_element_offset_rel.to_le_bytes()); - - // zero out popped off buffer - idb[last_element_offset_rel as usize..stack_ptr_rel as usize].fill(0); - - type_t - } - - /// Pushes the given data onto the shared output data buffer. - pub fn push_shared_output_data(&self, data: &[u8]) -> Result<()> { - let peb_ptr = self.peb().unwrap(); - let output_stack_size = unsafe { (*peb_ptr).output_stack.size as usize }; - let output_stack_ptr = unsafe { (*peb_ptr).output_stack.ptr as *mut u8 }; - - let odb = unsafe { from_raw_parts_mut(output_stack_ptr, output_stack_size) }; - - if odb.is_empty() { - return Err(HyperlightGuestError::new( - ErrorCode::GuestError, - "Got a 0-size buffer in push_shared_output_data".to_string(), - )); - } - - // get offset to next free address on the stack - let stack_ptr_rel: u64 = - u64::from_le_bytes(odb[..8].try_into().expect("Shared output buffer too small")); - - // check if the stack pointer is within the bounds of the buffer. - // It can be equal to the size, but never greater - // It can never be less than 8. An empty buffer's stack pointer is 8 - if stack_ptr_rel as usize > output_stack_size || stack_ptr_rel < 8 { - return Err(HyperlightGuestError::new( - ErrorCode::GuestError, - format!( - "Invalid stack pointer: {} in push_shared_output_data", - stack_ptr_rel - ), - )); - } - - // check if there is enough space in the buffer - let size_required = data.len() + 8; // the data plus the pointer pointing to the data - let size_available = output_stack_size - stack_ptr_rel as usize; - if size_required > size_available { - return Err(HyperlightGuestError::new( - ErrorCode::GuestError, - format!( - "Not enough space in shared output buffer. Required: {}, Available: {}", - size_required, size_available - ), - )); - } - - // write the actual data - odb[stack_ptr_rel as usize..stack_ptr_rel as usize + data.len()].copy_from_slice(data); - - // write the offset to the newly written data, to the top of the stack - let bytes: [u8; 8] = stack_ptr_rel.to_le_bytes(); - odb[stack_ptr_rel as usize + data.len()..stack_ptr_rel as usize + data.len() + 8] - .copy_from_slice(&bytes); - - // update stack pointer to point to next free address - let new_stack_ptr_rel: u64 = (stack_ptr_rel as usize + data.len() + 8) as u64; - odb[0..8].copy_from_slice(&(new_stack_ptr_rel).to_le_bytes()); - - Ok(()) - } -} diff --git a/src/hyperlight_guest/src/lib.rs b/src/hyperlight_guest/src/lib.rs index aa723d7845..cdd7843703 100644 --- a/src/hyperlight_guest/src/lib.rs +++ b/src/hyperlight_guest/src/lib.rs @@ -17,6 +17,4 @@ pub mod types; pub mod guest_handle { pub mod handle; - pub mod host_comm; - pub mod io; } diff --git a/src/hyperlight_guest/src/transport/codec.rs b/src/hyperlight_guest/src/transport/codec.rs new file mode 100644 index 0000000000..3fcec728e9 --- /dev/null +++ b/src/hyperlight_guest/src/transport/codec.rs @@ -0,0 +1,226 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 The Hyperlight Authors. + +//! Guest-side virtqueue message decoding. + +use alloc::vec::Vec; + +use hyperlight_common::flatbuffer_wrappers::ExternalValueSource; +use hyperlight_common::flatbuffer_wrappers::function_call::FunctionCall; +use hyperlight_common::flatbuffer_wrappers::function_types::{Bytes, FunctionCallResult}; +use hyperlight_common::transport::{ + MsgHeader, MsgKind, SIZE_PREFIX_LEN, size_prefix_payload_len, size_prefixed_len, +}; +use hyperlight_common::virtq::Segments; + +use crate::bail; +use crate::error::{GuestErrorContext, Result}; + +/// Decode one H2G guest-function request payload. +/// +/// Chunked external values retain their H2G slot owners until their final +/// [`Bytes`] clone drops. +pub(super) fn decode_request(cid: u32, segments: Segments) -> Result<(u32, FunctionCall)> { + if cid == 0 { + bail!("Guest function request has correlation ID zero"); + } + + let (control, mut external_values) = decode_payload(segments)?; + let call = FunctionCall::decode(&control, &mut external_values) + .with_context(|| "failed to decode guest function request")?; + + Ok((cid, call)) +} + +/// Decode one G2H host-function response. +/// +/// Contiguous byte values are flattened into `Vec`. Chunked values retain +/// their transport-backed [`Bytes`] owners. +pub(super) fn decode_response(segments: Segments, cid: u32) -> Result { + let (header, payload) = split_header(segments)?; + if header.msg_kind() != Ok(MsgKind::Response) { + bail!("Host function response has an invalid message kind"); + } + + if header.cid != cid { + bail!("Host function response correlation ID mismatch"); + } + + let (control, mut external_values) = decode_payload(payload)?; + FunctionCallResult::decode(&control, &mut external_values) + .with_context(|| "failed to decode host function response") +} + +fn split_header(mut segments: Segments) -> Result<(MsgHeader, Segments)> { + let header = segments + .split_to(MsgHeader::SIZE) + .context("virtqueue message is missing its header")? + .into_bytes(); + + let Some(header) = MsgHeader::from_bytes(&header) else { + bail!("Virtqueue message has an invalid header"); + }; + + if usize::try_from(header.payload_len).ok() != Some(segments.len()) { + bail!("Virtqueue message payload length mismatch"); + } + + Ok((header, segments)) +} + +/// Copy FlatBuffer control data while retaining external payload owners. +fn decode_payload(mut segments: Segments) -> Result<(Vec, SegmentSource)> { + let prefix = segments + .split_to(SIZE_PREFIX_LEN) + .context("virtqueue message is missing its size prefix")? + .into_bytes(); + + let payload_len = + size_prefix_payload_len(&prefix).context("virtqueue message has an invalid prefix")?; + + let payload = segments + .split_to(payload_len) + .context("virtqueue message control data is truncated")?; + + let control_len = + size_prefixed_len(payload_len).context("virtqueue message control length overflow")?; + + let mut control = Vec::with_capacity(control_len); + control.extend_from_slice(&prefix); + + for segment in payload.iter() { + control.extend_from_slice(segment); + } + + Ok((control, SegmentSource::new(segments))) +} + +/// Supplies complete logical external values from transport segments. +struct SegmentSource { + segments: Segments, +} + +impl SegmentSource { + fn new(segments: Segments) -> Self { + Self { segments } + } + + fn take(&mut self, length: usize) -> anyhow::Result { + self.segments.split_to(length).ok_or_else(|| { + anyhow::anyhow!( + "External value requires {length} bytes, only {} remain", + self.segments.len() + ) + }) + } +} + +impl ExternalValueSource for SegmentSource { + fn take_bytes(&mut self, length: usize) -> anyhow::Result> { + let segments = self.take(length)?; + let mut value = Vec::with_capacity(length); + for segment in segments.iter() { + value.extend_from_slice(segment); + } + Ok(value) + } + + fn take_chunks(&mut self, length: usize) -> anyhow::Result> { + Ok(self.take(length)?.into_chunks()) + } + + fn finish(&mut self) -> anyhow::Result<()> { + if !self.segments.is_empty() { + anyhow::bail!( + "Virtqueue message has {} trailing external bytes", + self.segments.len() + ); + } + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use alloc::vec; + + use flatbuffers::FlatBufferBuilder; + use hyperlight_common::flatbuffer_wrappers::function_types::ReturnValue; + use hyperlight_common::transport::ExternalValues; + + use super::*; + + #[test] + fn response_byte_chunks_retain_transport_storage() { + let external = Bytes::from(vec![1, 2, 3, 4]); + let external_ptr = external.as_ptr(); + let result = FunctionCallResult::new(Ok(ReturnValue::ByteChunks(vec![external.clone()]))); + let mut builder = FlatBufferBuilder::new(); + let mut external_values = ExternalValues::new(); + let control = result.encode(&mut builder, &mut external_values).unwrap(); + let payload_len = control.len() + external.len(); + let header = MsgHeader::new(MsgKind::Response, 7, u32::try_from(payload_len).unwrap()); + let segments = Segments::new([ + Bytes::copy_from_slice(header.as_bytes()), + Bytes::copy_from_slice(control), + external, + ]); + + let decoded = decode_response(segments, 7).unwrap().into_inner().unwrap(); + let ReturnValue::ByteChunks(chunks) = decoded else { + panic!("expected ByteChunks response"); + }; + + assert_eq!(chunks.len(), 1); + assert_eq!(chunks[0].as_ptr(), external_ptr); + assert_eq!(chunks[0].as_ref(), &[1, 2, 3, 4]); + } + + #[test] + fn segment_source_flattens_only_contiguous_values() { + let first = Bytes::from_static(b"ab"); + let second = Bytes::from_static(b"cd"); + let second_ptr = second.as_ptr(); + let mut source = SegmentSource::new(Segments::new([first, second])); + + let contiguous = source.take_bytes(3).unwrap(); + let chunks = source.take_chunks(1).unwrap(); + source.finish().unwrap(); + + assert_eq!(contiguous, b"abc"); + assert_eq!(chunks.len(), 1); + assert_eq!(chunks[0].as_ref(), b"d"); + assert_eq!(chunks[0].as_ptr(), second_ptr.wrapping_add(1)); + } + + #[test] + fn request_byte_chunks_retain_transport_storage() { + use hyperlight_common::flatbuffer_wrappers::function_call::FunctionCallType; + use hyperlight_common::flatbuffer_wrappers::function_types::{ParameterValue, ReturnType}; + + let external = Bytes::from(vec![1, 2, 3, 4]); + let external_ptr = external.as_ptr(); + let call = FunctionCall::new( + "echo".into(), + Some(vec![ParameterValue::ByteChunks(vec![external.clone()])]), + FunctionCallType::Guest, + ReturnType::ByteChunks, + ); + let mut builder = FlatBufferBuilder::new(); + let mut external_values = ExternalValues::new(); + let control = call.encode(&mut builder, &mut external_values).unwrap(); + let segments = Segments::new([Bytes::copy_from_slice(control), external]); + + let (cid, decoded) = decode_request(9, segments).unwrap(); + let ParameterValue::ByteChunks(chunks) = + decoded.parameters.unwrap().into_iter().next().unwrap() + else { + panic!("expected ByteChunks parameter"); + }; + + assert_eq!(cid, 9); + assert_eq!(chunks.len(), 1); + assert_eq!(chunks[0].as_ptr(), external_ptr); + assert_eq!(chunks[0].as_ref(), &[1, 2, 3, 4]); + } +} diff --git a/src/hyperlight_guest/src/transport/context.rs b/src/hyperlight_guest/src/transport/context.rs index 5a9e11d110..44f50b93ab 100644 --- a/src/hyperlight_guest/src/transport/context.rs +++ b/src/hyperlight_guest/src/transport/context.rs @@ -3,31 +3,64 @@ //! Guest virtqueue context. +use alloc::vec::Vec; use core::result; +use flatbuffers::FlatBufferBuilder; +use hyperlight_common::flatbuffer_wrappers::function_call::{FunctionCall, FunctionCallType}; +use hyperlight_common::flatbuffer_wrappers::function_types::{ + FunctionCallResult, ParameterValue, ReturnType, ReturnValue, +}; +use hyperlight_common::flatbuffer_wrappers::guest_error::GuestError; +use hyperlight_common::flatbuffer_wrappers::util::estimate_flatbuffer_capacity; +use hyperlight_common::outb::OutBAction; +use hyperlight_common::transport::{EncodedMessage, ExternalValues, MsgHeader, MsgKind}; use hyperlight_common::virtq::{ - AllocError, G2H_LOWER_SLOT_COUNT, G2H_LOWER_SLOT_SIZE, Layout, Notifier, QueueStats, - SlotLayout, SlotPool, VirtqProducer, + AllocError, G2H_LOWER_SLOT_COUNT, G2H_LOWER_SLOT_SIZE, Layout, MemOps, Notifier, QueueStats, + Segments, SendChain, SlotLayout, SlotPool, Token, UsedChain, VirtqError, VirtqProducer, }; -use super::GuestMemOps; +use super::{GuestMemOps, codec}; +use crate::bail; use crate::error::{GuestErrorContext, Result}; +use crate::exit::out32; -/// Guest-side notifier for polled transport operation. +/// G2H notifier that exits to the host to process available work. #[derive(Clone, Copy)] -pub struct GuestNotifier; +pub struct G2hNotifier; + +impl Notifier for G2hNotifier { + fn notify(&self, _stats: QueueStats) { + unsafe { + out32(OutBAction::VirtqNotify as u16, 0); + } + } +} -impl Notifier for GuestNotifier { +/// H2G prefill does not notify before the host consumer is attached. +#[derive(Clone, Copy)] +pub struct H2gNotifier; + +impl Notifier for H2gNotifier { fn notify(&self, _stats: QueueStats) {} } /// Type alias for the guest-side G2H producer. -pub type G2hProducer = VirtqProducer; +pub type G2hProducer = VirtqProducer; /// Type alias for the guest-side H2G producer. -pub type H2gProducer = VirtqProducer; +pub type H2gProducer = VirtqProducer; + +/// Work selected by one H2G dispatch entry. +pub enum DispatchAction { + /// Invoke one guest function and return its correlation ID. + Call(u32, FunctionCall), + /// Prepare canonical transport state for snapshot capture. + SnapshotCheckpoint, +} /// Configuration for one queue passed to [`GuestContext::new`]. +#[derive(Debug)] pub struct QueueConfig { /// Ring descriptor layout in shared memory. pub layout: Layout, @@ -39,43 +72,364 @@ pub struct QueueConfig { pub buffer_size: usize, } +/// Writable capacity reserved on a G2H request chain. +#[derive(Clone, Copy)] +enum ReplyCapacity { + /// The chain carries no reply. + None, + /// Reserve at least this many reply bytes. + Bounded(usize), + /// Reserve every available preferred allocation. + Available, +} + +impl ReplyCapacity { + /// Select reply capacity for one host function return type. + fn for_return_type(return_type: ReturnType) -> Self { + match return_type { + ReturnType::String | ReturnType::VecBytes | ReturnType::ByteChunks => Self::Available, + _ => Self::Bounded(G2H_LOWER_SLOT_SIZE), + } + } +} + /// Virtqueue runtime state for guest-host communication. pub struct GuestContext { + /// Access to the shared transport arena. + mem: GuestMemOps, /// Guest-to-host driver. - _g2h_producer: G2hProducer, + g2h_producer: G2hProducer, + /// G2H pool state used to count retained buffers. + g2h_pool: SlotPool, /// Host-to-guest driver. h2g_producer: H2gProducer, + /// H2G pool state used to count retained buffers. + h2g_pool: SlotPool, /// Size of each prefilled H2G buffer. h2g_slot_size: usize, + /// Snapshot checkpoint mailbox GVA. + mbx_gva: u64, + /// Correlation ID assigned to the next host-function request. + next_cid: u32, + /// Used by the C API. + last_host_result: Option>, + /// Error set by a C guest function. + last_guest_error: Option, } impl GuestContext { /// Create a new context with G2H and H2G queues. - pub fn new(g2h: QueueConfig, h2g: QueueConfig) -> Result { - Self::with_mem(g2h, h2g, GuestMemOps::for_scratch()) - } - - /// Create a new context with memory access provided. - fn with_mem(g2h: QueueConfig, h2g: QueueConfig, mem: GuestMemOps) -> Result { + pub fn new(g2h: QueueConfig, h2g: QueueConfig, mbx_gva: u64) -> Result { let g2h_pool = g2h_pool(g2h.pool_gva, g2h.pool_pages, g2h.buffer_size) .with_context(|| "failed to create G2H pool")?; - let g2h_producer = VirtqProducer::new(g2h.layout, mem, GuestNotifier, g2h_pool); + let mem = GuestMemOps::for_scratch(); + let g2h_producer = VirtqProducer::new(g2h.layout, mem, G2hNotifier, g2h_pool.clone()); let h2g_pool = h2g_pool(h2g.pool_gva, h2g.pool_pages, h2g.buffer_size) .with_context(|| "failed to create H2G slot pool")?; - let h2g_producer = VirtqProducer::new(h2g.layout, mem, GuestNotifier, h2g_pool); + let h2g_producer = VirtqProducer::new(h2g.layout, mem, H2gNotifier, h2g_pool.clone()); let mut ctx = Self { - _g2h_producer: g2h_producer, + mem, + g2h_producer, + g2h_pool, h2g_producer, + h2g_pool, h2g_slot_size: h2g.buffer_size, + mbx_gva, + next_cid: 1, + last_host_result: None, + last_guest_error: None, }; - ctx.prefill_h2g().expect("H2G initial prefill failed"); + ctx.prefill_h2g()?; Ok(ctx) } - /// Pre-fill H2G with writable buffers until its ring or pool is full. + /// Record an error raised through the C guest API. + pub fn set_guest_error(&mut self, error: GuestError) { + self.last_guest_error = Some(error); + } + + /// Take an error raised through the C guest API. + pub fn take_guest_error(&mut self) -> Option { + self.last_guest_error.take() + } + + /// Call a host function via the G2H virtqueue. + /// + /// Slot-aligned external values use a separate readable region. The same + /// chain carries bounded writable buffers for the response. + /// + /// # Errors + /// + /// Returns an error when encoding, queue submission, host dispatch, + /// response validation, or return-value conversion fails. + pub fn call_host_function>( + &mut self, + function_name: &str, + parameters: Option>, + return_type: ReturnType, + ) -> Result { + // Encode control data separately from borrowed external byte values. + let params = parameters.as_deref().unwrap_or_default(); + let estimated_capacity = estimate_flatbuffer_capacity(function_name, params); + + let fc = FunctionCall::new( + function_name.into(), + parameters, + FunctionCallType::Host, + return_type, + ); + + let mut builder = FlatBufferBuilder::with_capacity(estimated_capacity); + let mut externals = ExternalValues::new(); + + let control = fc + .encode(&mut builder, &mut externals) + .with_context(|| "failed to encode host function call")?; + + // Frame the request and include external values in its total length. + let cid = self.allocate_cid(); + let msg = EncodedMessage::new(MsgKind::Request, cid, control, externals) + .context("G2H message length overflow")?; + + let reply_cap = ReplyCapacity::for_return_type(return_type); + + // Submit once more after forcing the host to drain on backpressure. + let token = match self.try_send(&msg, reply_cap) { + Ok(token) => token, + Err(error) if error.is_transient() => { + self.g2h_producer.notify_backpressure(); + + if let Err(error) = self.g2h_producer.reclaim() { + bail!("G2H reclaim: {error}"); + } + + match self.try_send(&msg, reply_cap) { + Ok(token) => token, + Err(error) => bail!("G2H call retry: {error}"), + } + } + Err(error) => { + bail!("G2H call: {error}"); + } + }; + + // Poll completions, skipping earlier one-way acknowledgements until + // the request reply is available. + let reply = loop { + let Some(reply) = self.g2h_producer.poll()? else { + bail!("G2H: no reply received"); + }; + if reply.token() == token { + break reply; + } + if matches!(&reply, UsedChain::Data(..)) { + bail!("G2H: unexpected reply token {:?}", reply.token()); + } + }; + + let segments = match reply { + UsedChain::Data(_, segments) => segments, + UsedChain::Ack(_) => bail!("G2H: response was ack-only"), + }; + + // Decode external ByteChunks without flattening their transport-backed + // segments. + let fcr = codec::decode_response(segments, cid)?; + let ret = fcr.into_inner()?; + + let Ok(ret) = T::try_from(ret) else { + bail!("G2H: host return value type mismatch"); + }; + + Ok(ret) + } + + /// Receive one host-to-guest dispatch action. + /// + /// External `ByteChunks` retain their owner-backed H2G slots. Contiguous + /// `VecBytes` values copy directly into their final `Vec`. + pub fn recv_h2g_dispatch(&mut self) -> Result { + self.g2h_producer + .reclaim() + .with_context(|| "G2H completion reclaim failed")?; + + let Some(used) = self.h2g_producer.poll()? else { + bail!("H2G: expected a guest function call buffer"); + }; + + let mut first = match used { + UsedChain::Data(_, segments) => segments, + UsedChain::Ack(_) => bail!("H2G: guest function call buffer was ack-only"), + }; + + let header = first + .split_to(MsgHeader::SIZE) + .context("H2G buffer is missing its message header")? + .into_bytes(); + + let Some(header) = MsgHeader::from_bytes(&header) else { + bail!("H2G buffer has an invalid message header"); + }; + + match header.msg_kind() { + Ok(MsgKind::SnapshotCheckpoint) => { + return Ok(DispatchAction::SnapshotCheckpoint); + } + Ok(MsgKind::Request) if header.cid != 0 => {} + _ => bail!("H2G buffer has invalid request framing"), + } + + let payload_len = + usize::try_from(header.payload_len).context("H2G payload length overflow")?; + + if first.len() > payload_len { + bail!("H2G first buffer exceeds the declared payload length"); + } + + let mut received = first.len(); + let mut payload = first.into_chunks(); + + while received < payload_len { + let Some(used) = self.h2g_producer.poll()? else { + bail!("H2G: expected a continuation buffer"); + }; + + let segments = match used { + UsedChain::Data(_, segments) => segments, + UsedChain::Ack(_) => bail!("H2G continuation buffer was ack-only"), + }; + + if segments.is_empty() { + bail!("H2G continuation buffer is empty"); + } + + received = received + .checked_add(segments.len()) + .context("H2G payload length overflow")?; + + if received > payload_len { + bail!("H2G buffers exceed the declared payload length"); + } + payload.extend(segments.into_chunks()); + } + + let (cid, call) = codec::decode_request(header.cid, Segments::new(payload))?; + Ok(DispatchAction::Call(cid, call)) + } + + /// Return a guest-function result and replenish H2G receive buffers. + pub fn send_h2g_result(&mut self, cid: u32, result: FunctionCallResult) -> Result<()> { + self.g2h_producer + .reclaim() + .with_context(|| "G2H response reclaim failed")?; + + { + let mut builder = FlatBufferBuilder::new(); + let mut externals = ExternalValues::new(); + + let control = result + .encode(&mut builder, &mut externals) + .with_context(|| "failed to encode guest function result")?; + + let msg = EncodedMessage::new(MsgKind::Response, cid, control, externals) + .context("G2H response length overflow")?; + + self.try_send_deferred(&msg, ReplyCapacity::None) + .with_context(|| "G2H response submission failed")?; + } + + drop(result); + self.prefill_h2g() + } + + /// Canonicalize both queues while the host consumers are stopped. + pub fn prepare_snapshot(&mut self) -> Result<()> { + self.g2h_producer + .reclaim() + .with_context(|| "G2H snapshot reclaim failed")?; + self.g2h_producer + .reset() + .with_context(|| "G2H snapshot reset failed")?; + self.h2g_producer + .reset() + .with_context(|| "H2G snapshot reset failed")?; + + // [`SlotPool`] clones share one allocation bitmap with their producer. + // Producer reset releases every allocation still tracked by queue + // bookkeeping. At this checkpoint boundary, any allocation left in the + // bitmap is therefore held by an owner-backed `Bytes` returned to guest + // code. `num_live` gives the exact number of retained slots across both + // size tiers. Multiple `Bytes` clones or slices backed by one owner still + // count as one slot. + // + // This runs before H2G prefill because posted receive buffers are + // transport-owned allocations and must not be counted. The result only + // answers whether retained buffers exist. It does not identify their + // addresses, capacities, or initialized lengths. + let guest_owned = self + .g2h_pool + .num_live() + .checked_add(self.h2g_pool.num_live()) + .ok_or(VirtqError::InvalidState)?; + let guest_owned = u64::try_from(guest_owned).map_err(|_| VirtqError::InvalidState)?; + + // TODO: Publish a retained-buffer manifest with pool-relative offsets and + // initialized lengths so the host can snapshot sanitized payload ranges. + // The count-only mailbox currently rejects every retained-buffer snapshot. + self.mem + .write(self.mbx_gva, &guest_owned.to_le_bytes()) + .map_err(|_| VirtqError::MemoryWriteError)?; + + self.prefill_h2g()?; + Ok(()) + } + + /// Send a log message via the G2H queue. + /// + /// Current notification policy exits to the host for every log. + /// + /// # Errors + /// + /// Returns an error when the message cannot be framed or submitted. + pub fn emit_log(&mut self, log_data: &[u8]) -> Result<()> { + let message = EncodedMessage::new(MsgKind::Log, 0, log_data, ExternalValues::new()) + .context("G2H message length overflow")?; + self.send_g2h_oneshot(&message) + } + + /// Stash a host function result for later retrieval. + /// + /// Used by the C API's two-step calling convention where + /// `hl_call_host_function` and `hl_get_host_return_value_as_*` + /// are separate calls. + pub fn stash_host_result(&mut self, result: Result) { + self.last_host_result = Some(result); + } + + /// Take the stashed host return value. + /// + /// Panics if no value was stashed or if the type conversion fails. + /// If the stashed result was an error, panics with the error message. + pub fn take_host_return>(&mut self) -> T { + let value = self + .last_host_result + .take() + .expect("No host return value available") + .expect("Host function returned an error"); + + match T::try_from(value) { + Ok(value) => value, + Err(_) => panic!("Host return value type mismatch"), + } + } + + /// Publish one writable H2G chain for each currently free slot. + /// + /// Retained external values reduce the number of available receive buffers + /// until their final owner drops. fn prefill_h2g(&mut self) -> Result<()> { let mut batch = self.h2g_producer.batch(); @@ -83,22 +437,136 @@ impl GuestContext { let chain = match batch.chain().writable(self.h2g_slot_size).build() { Ok(chain) => chain, Err(error) if error.is_transient() => { - batch.finish()?; + batch.finish_without_notify(); return Ok(()); } - Err(error) => return Err(error.into()), + Err(error) => bail!("H2G prefill build: {error}"), }; match batch.submit(chain) { Ok(_) => {} Err(error) if error.is_transient() => { - batch.finish()?; + batch.finish_without_notify(); return Ok(()); } - Err(error) => return Err(error.into()), + Err(error) => bail!("H2G prefill submit: {error}"), } } } + + /// Submit a one-way G2H message without polling its acknowledgement. + /// + /// Completed acknowledgements remain available for normal polling or + /// reclamation when later submissions encounter backpressure. + fn send_g2h_oneshot(&mut self, message: &EncodedMessage<'_>) -> Result<()> { + match self.try_send(message, ReplyCapacity::None) { + Ok(_) => Ok(()), + Err(error) if error.is_transient() => { + // VM exit so host drains and completes G2H entries. + self.g2h_producer.notify_backpressure(); + + if let Err(error) = self.g2h_producer.reclaim() { + bail!("G2H one-way reclaim: {error}"); + } + + match self.try_send(message, ReplyCapacity::None) { + Ok(_) => Ok(()), + Err(error) => bail!("G2H one-way retry: {error}"), + } + } + Err(error) => bail!("G2H one-way message: {error}"), + } + } + + /// Build and submit one G2H descriptor chain. + /// + /// `reply_cap` defines optional host-function reply space. + fn try_send( + &mut self, + message: &EncodedMessage<'_>, + reply_cap: ReplyCapacity, + ) -> result::Result { + let chain = self.build_g2h_chain(message, reply_cap)?; + self.g2h_producer.submit(chain) + } + + /// Submit one G2H message for polling after the existing final halt. + fn try_send_deferred( + &mut self, + message: &EncodedMessage<'_>, + reply_capacity: ReplyCapacity, + ) -> result::Result { + let chain = self.build_g2h_chain(message, reply_capacity)?; + let mut batch = self.g2h_producer.batch(); + + let token = batch.submit(chain)?; + batch.finish_without_notify(); + + Ok(token) + } + + /// Build and initialize one G2H message chain. + fn build_g2h_chain( + &self, + message: &EncodedMessage<'_>, + reply_cap: ReplyCapacity, + ) -> result::Result, VirtqError> { + let segment_len = self.g2h_producer.preferred_segment_len(); + let num_free = self.g2h_pool.num_free(); + + let lengths = || message_region_lengths(message, segment_len); + + let reply_cap = match reply_cap { + ReplyCapacity::None => None, + ReplyCapacity::Bounded(cap) => Some(cap), + ReplyCapacity::Available => Some(self.g2h_pool.max_alloc(lengths(), num_free)?), + }; + + let mut builder = self.g2h_producer.chain(); + + for len in lengths() { + builder = builder.readable(len); + } + + if let Some(cap) = reply_cap { + builder = builder.writable(cap); + } + + let mut chain = builder.build()?; + for chunk in message.chunks() { + chain.write_all(chunk)?; + } + + Ok(chain) + } + + /// Allocate a new correlation ID for a host function request. + fn allocate_cid(&mut self) -> u32 { + let cid = self.next_cid; + self.next_cid = self.next_cid.wrapping_add(1); + + if self.next_cid == 0 { + self.next_cid = 1; + } + cid + } +} + +/// Group message bytes into logical readable region lengths. +fn message_region_lengths( + message: &EncodedMessage<'_>, + segment_len: usize, +) -> impl Iterator { + let external_len = message.external_len(); + let split_external = external_len != 0 && external_len.is_multiple_of(segment_len); + + let first_len = if split_external { + message.prefix_len() + } else { + message.total_len() + }; + + core::iter::once(first_len).chain(split_external.then_some(external_len)) } fn pool_len(pages: usize) -> result::Result { @@ -109,8 +577,7 @@ fn pool_len(pages: usize) -> result::Result { /// Build the uniform H2G pool. /// -/// Every preposted receive buffer has the configured size so the host sees one -/// predictable capacity for guest calls. +/// Each slot becomes one independent preposted receive buffer. fn h2g_pool(base: u64, pages: usize, buffer_size: usize) -> result::Result { let count = pool_len(pages)? / buffer_size; SlotPool::new(SlotLayout::new(base, buffer_size, count)) @@ -137,3 +604,29 @@ fn g2h_pool(base: u64, pages: usize, upper_size: usize) -> result::Result EncodedMessage<'_> { + let mut values = ExternalValues::new(); + values.push_bytes(external).unwrap(); + EncodedMessage::new(MsgKind::Request, 1, b"control", values).unwrap() + } + + #[test] + fn message_regions_split_only_aligned_external_values() { + let aligned = [0; 4096]; + let message = encoded_message(&aligned); + let regions = message_region_lengths(&message, 4096).collect::>(); + assert_eq!(regions, [message.prefix_len(), aligned.len()]); + + let unaligned = [0; 4095]; + let message = encoded_message(&unaligned); + let regions = message_region_lengths(&message, 4096).collect::>(); + assert_eq!(regions, [message.total_len()]); + } +} diff --git a/src/hyperlight_guest/src/transport/mod.rs b/src/hyperlight_guest/src/transport/mod.rs index 6a7101e7a6..9fec3a16fc 100644 --- a/src/hyperlight_guest/src/transport/mod.rs +++ b/src/hyperlight_guest/src/transport/mod.rs @@ -5,13 +5,14 @@ //! //! Global context is installed once via [`set_global_context`] and accessed via [`with_context`]. +mod codec; pub mod context; pub mod mem; use core::cell::RefCell; use core::sync::atomic::{AtomicU8, Ordering}; -pub use context::{GuestContext, QueueConfig}; +pub use context::{DispatchAction, GuestContext, QueueConfig}; pub use mem::GuestMemOps; const UNINITIALIZED: u8 = 0; @@ -35,10 +36,10 @@ pub fn is_initialized() -> bool { /// # Panics /// /// Panics if the context is uninitialized or already borrowed. -pub fn with_context(f: impl FnOnce(&mut GuestContext) -> R) -> R { +pub fn with_ctx(f: impl FnOnce(&mut GuestContext) -> R) -> R { assert!(is_initialized(), "transport context not initialized"); - let mut context = GLOBAL_CONTEXT.0.borrow_mut(); - f(context.as_mut().expect("transport context missing")) + let mut ctx = GLOBAL_CONTEXT.0.borrow_mut(); + f(ctx.as_mut().expect("transport context missing")) } /// Install the global transport context. diff --git a/src/hyperlight_guest_bin/src/guest_function/call.rs b/src/hyperlight_guest_bin/src/guest_function/call.rs index 0fdb268f17..367bb2cf4c 100644 --- a/src/hyperlight_guest_bin/src/guest_function/call.rs +++ b/src/hyperlight_guest_bin/src/guest_function/call.rs @@ -4,15 +4,17 @@ use alloc::format; use alloc::vec::Vec; -use flatbuffers::FlatBufferBuilder; use hyperlight_common::flatbuffer_wrappers::function_call::{FunctionCall, FunctionCallType}; -use hyperlight_common::flatbuffer_wrappers::function_types::{FunctionCallResult, ParameterType}; +use hyperlight_common::flatbuffer_wrappers::function_types::{ + FunctionCallResult, ParameterType, ReturnValue, +}; use hyperlight_common::flatbuffer_wrappers::guest_error::{ErrorCode, GuestError}; -use hyperlight_guest::bail; use hyperlight_guest::error::{HyperlightGuestError, Result}; +use hyperlight_guest::transport::DispatchAction; +use hyperlight_guest::{bail, transport}; use tracing::instrument; -use crate::{GUEST_HANDLE, REGISTERED_GUEST_FUNCTIONS}; +use crate::REGISTERED_GUEST_FUNCTIONS; core::arch::global_asm!( ".weak guest_dispatch_function", @@ -21,13 +23,13 @@ core::arch::global_asm!( ); #[tracing::instrument(skip_all, parent = tracing::Span::current(), level= "Trace")] -fn guest_dispatch_function_default(function_call: FunctionCall) -> Result> { +fn guest_dispatch_function_default(function_call: FunctionCall) -> Result { let name = &function_call.function_name; bail!(ErrorCode::GuestFunctionNotFound => "No handler found for function call: {name:#?}"); } #[instrument(skip_all, level = "Info")] -pub(crate) fn call_guest_function(function_call: FunctionCall) -> Result> { +pub(crate) fn call_guest_function(function_call: FunctionCall) -> Result { // Validate this is a Guest Function Call if function_call.function_call_type() != FunctionCallType::Guest { return Err(HyperlightGuestError::new( @@ -60,12 +62,8 @@ pub(crate) fn call_guest_function(function_call: FunctionCall) -> Result } else { // The given function is not registered. The guest should implement a function called // guest_dispatch_function to handle this. - - // TODO: ideally we would define a default implementation of this with weak linkage so the guest is not required - // to implement the function but its seems that weak linkage is an unstable feature so for now its probably better - // to not do that. unsafe extern "Rust" { - fn guest_dispatch_function(function_call: FunctionCall) -> Result>; + fn guest_dispatch_function(function_call: FunctionCall) -> Result; } unsafe { guest_dispatch_function(function_call) } @@ -85,34 +83,20 @@ pub(crate) fn internal_dispatch_function() { tracing::span!(tracing::Level::INFO, "internal_dispatch_function").entered() }; - let handle = unsafe { GUEST_HANDLE }; - - let function_call = handle - .try_pop_shared_input_data_into::() - .expect("Function call deserialization failed"); + let dispatch = transport::with_ctx(|ctx| ctx.recv_h2g_dispatch()) + .expect("H2G dispatch deserialization failed"); - // Reseed the libc PRNG if requested by the host. - #[cfg(feature = "libc")] - crate::refresh_libc_rng(); + let result = match dispatch { + DispatchAction::Call(cid, fc) => { + // Reseed the libc PRNG if requested by the host. + #[cfg(feature = "libc")] + crate::refresh_libc_rng(); - let res = call_guest_function(function_call); - - match res { - Ok(bytes) => { - handle - .push_shared_output_data(bytes.as_slice()) - .expect("Failed to serialize function call result"); - } - Err(err) => { - let guest_error = Err(GuestError::new(err.kind, err.message)); - let fcr = FunctionCallResult::new(guest_error); - let mut builder = FlatBufferBuilder::new(); - let data = fcr.encode(&mut builder); - handle - .push_shared_output_data(data) - .expect("Failed to serialize function call result"); + let res = call_guest_function(fc).map_err(|err| GuestError::new(err.kind, err.message)); + Some((cid, FunctionCallResult::new(res))) } - } + DispatchAction::SnapshotCheckpoint => None, + }; // All this tracing logic shall be done right before the call to `hlt` which is done after this // function returns @@ -130,4 +114,11 @@ pub(crate) fn internal_dispatch_function() { // the host, if necessary. hyperlight_guest_tracing::flush(); } + + match result { + Some((cid, result)) => transport::with_ctx(|ctx| ctx.send_h2g_result(cid, result)) + .expect("Failed to send function call result"), + None => transport::with_ctx(|ctx| ctx.prepare_snapshot()) + .expect("Failed to prepare snapshot transport"), + } } diff --git a/src/hyperlight_guest_bin/src/guest_function/definition.rs b/src/hyperlight_guest_bin/src/guest_function/definition.rs index 82e523a593..e9763e8b14 100644 --- a/src/hyperlight_guest_bin/src/guest_function/definition.rs +++ b/src/hyperlight_guest_bin/src/guest_function/definition.rs @@ -8,7 +8,6 @@ use alloc::vec::Vec; use hyperlight_common::flatbuffer_wrappers::function_call::FunctionCall; use hyperlight_common::flatbuffer_wrappers::function_types::{ParameterType, ReturnType}; use hyperlight_common::flatbuffer_wrappers::guest_error::ErrorCode; -use hyperlight_common::flatbuffer_wrappers::util::get_flatbuffer_result; use hyperlight_common::for_each_tuple; use hyperlight_common::func::{ Function, ParameterTuple, ResultType, ReturnValue, SupportedReturnType, @@ -16,7 +15,7 @@ use hyperlight_common::func::{ use hyperlight_guest::error::{HyperlightGuestError, Result}; /// The function pointer type for Rust guest functions. -pub type GuestFunc = fn(FunctionCall) -> Result>; +pub type GuestFunc = fn(FunctionCall) -> Result; /// The definition of a function exposed from the guest to the host. /// @@ -34,7 +33,7 @@ pub struct GuestFunctionDefinition { pub function_pointer: F, } -/// Trait for functions that can be converted to a `fn(FunctionCall) -> Result>` +/// Trait for functions that can be converted to a [`GuestFunc`]. #[doc(hidden)] pub trait IntoGuestFunction where @@ -46,8 +45,8 @@ where #[doc(hidden)] const ASSERT_ZERO_SIZED: (); - /// Convert the function into a `fn(FunctionCall) -> Result>` - fn into_guest_function(self) -> fn(FunctionCall) -> Result>; + /// Convert the function into a [`GuestFunc`]. + fn into_guest_function(self) -> GuestFunc; } /// Trait for functions that can be converted to a `GuestFunctionDefinition` @@ -65,22 +64,6 @@ where ) -> GuestFunctionDefinition; } -fn into_flatbuffer_result(value: ReturnValue) -> Vec { - match value { - ReturnValue::Void(()) => get_flatbuffer_result(()), - ReturnValue::Int(i) => get_flatbuffer_result(i), - ReturnValue::UInt(u) => get_flatbuffer_result(u), - ReturnValue::Long(l) => get_flatbuffer_result(l), - ReturnValue::ULong(ul) => get_flatbuffer_result(ul), - ReturnValue::Float(f) => get_flatbuffer_result(f), - ReturnValue::Double(d) => get_flatbuffer_result(d), - ReturnValue::Bool(b) => get_flatbuffer_result(b), - ReturnValue::String(s) => get_flatbuffer_result(s.as_str()), - ReturnValue::VecBytes(v) => get_flatbuffer_result(v.as_slice()), - ReturnValue::ByteChunks(v) => get_flatbuffer_result(v), - } -} - macro_rules! impl_host_function { ([$N:expr] ($($p:ident: $P:ident),*)) => { impl IntoGuestFunction for F @@ -121,7 +104,7 @@ macro_rules! impl_host_function { assert!(core::mem::size_of::() == 0) }; - fn into_guest_function(self) -> fn(FunctionCall) -> Result> { + fn into_guest_function(self) -> GuestFunc { |fc: FunctionCall| { // SAFETY: This is safe because: // 1. F is zero-sized (enforced by the ASSERT_ZERO_SIZED const). @@ -131,7 +114,7 @@ macro_rules! impl_host_function { let params = fc.parameters.unwrap_or_default(); let params = <($($P,)*) as ParameterTuple>::from_value(params)?; let result = Function::::call(&this, params)?; - Ok(into_flatbuffer_result(result.into_value())) + Ok(result.into_value()) } } } diff --git a/src/hyperlight_guest_bin/src/guest_logger.rs b/src/hyperlight_guest_bin/src/guest_logger.rs index caa9df593c..769d38ffc2 100644 --- a/src/hyperlight_guest_bin/src/guest_logger.rs +++ b/src/hyperlight_guest_bin/src/guest_logger.rs @@ -2,12 +2,14 @@ // Copyright 2025 The Hyperlight Authors. use alloc::format; +use alloc::string::ToString; +use alloc::vec::Vec; +use hyperlight_common::flatbuffer_wrappers::guest_log_data::GuestLogData; use hyperlight_common::flatbuffer_wrappers::guest_log_level::LogLevel; +use hyperlight_guest::transport; use log::{LevelFilter, Metadata, Record}; -use crate::GUEST_HANDLE; - // this is private on purpose so that `log` can only be called though the `log!` macros. struct GuestLogger {} @@ -25,11 +27,9 @@ impl log::Log for GuestLogger { fn enabled(&self, _: &Metadata) -> bool { true } - fn log(&self, record: &Record) { - let handle = unsafe { GUEST_HANDLE }; if self.enabled(record.metadata()) { - handle.log_message( + log_message( record.level().into(), format!("{}", record.args()).as_str(), record.module_path().unwrap_or("Unknown"), @@ -51,6 +51,40 @@ pub fn log_message( file: &str, line: u32, ) { - let handle = unsafe { GUEST_HANDLE }; - handle.log_message(level, message, module_path, target, file, line); + let _send_to_host = || { + let log = GuestLogData::new( + message.to_string(), + module_path.to_string(), + level, + target.to_string(), + file.to_string(), + line, + ); + let bytes: Vec = log + .try_into() + .expect("Failed to convert GuestLogData to bytes"); + + transport::with_ctx(|ctx| { + ctx.emit_log(&bytes) + .expect("Unable to send log data via virtq"); + }); + }; + + #[cfg(all(feature = "trace_guest", target_arch = "x86_64"))] + if hyperlight_guest_tracing::is_trace_enabled() { + tracing::trace!( + event = message, + level = ?level, + code.filepath = module_path, + caller = target, + source_file = file, + code.lineno = line, + ); + } else { + _send_to_host(); + } + #[cfg(not(all(feature = "trace_guest", target_arch = "x86_64")))] + { + _send_to_host(); + } } diff --git a/src/hyperlight_guest_bin/src/host_comm.rs b/src/hyperlight_guest_bin/src/host_comm.rs index e24d2e403d..0d9ce04743 100644 --- a/src/hyperlight_guest_bin/src/host_comm.rs +++ b/src/hyperlight_guest_bin/src/host_comm.rs @@ -9,9 +9,9 @@ use hyperlight_common::flatbuffer_wrappers::function_types::{ ParameterValue, ReturnType, ReturnValue, }; use hyperlight_common::flatbuffer_wrappers::guest_error::ErrorCode; -use hyperlight_common::flatbuffer_wrappers::util::get_flatbuffer_result; use hyperlight_common::func::{ParameterTuple, SupportedReturnType}; use hyperlight_guest::error::{HyperlightGuestError, Result}; +use hyperlight_guest::transport; use crate::GUEST_HANDLE; @@ -23,8 +23,7 @@ pub fn call_host_function( where T: TryFrom, { - let handle = unsafe { GUEST_HANDLE }; - handle.call_host_function::(function_name, parameters, return_type) + transport::with_ctx(|ctx| ctx.call_host_function(function_name, parameters, return_type)) } pub fn call_host(function_name: impl AsRef, args: impl ParameterTuple) -> Result @@ -34,44 +33,21 @@ where call_host_function::(function_name.as_ref(), Some(args.into_value()), T::TYPE) } -pub fn call_host_function_without_returning_result( - function_name: &str, - parameters: Option>, - return_type: ReturnType, -) -> Result<()> { - let handle = unsafe { GUEST_HANDLE }; - handle.call_host_function_without_returning_result(function_name, parameters, return_type) -} - -pub fn get_host_return_value_raw() -> Result { - let handle = unsafe { GUEST_HANDLE }; - handle.get_host_return_raw() -} - -pub fn get_host_return_value>() -> Result { - let handle = unsafe { GUEST_HANDLE }; - handle.get_host_return_value::() -} - pub fn read_n_bytes_from_user_memory(num: u64) -> Result> { let handle = unsafe { GUEST_HANDLE }; handle.read_n_bytes_from_user_memory(num) } /// Print a message using the host's print function. -/// -/// This function requires memory to be setup to be used. In particular, the -/// existence of the input and output memory regions. -pub fn print_output_with_host_print(function_call: FunctionCall) -> Result> { - let handle = unsafe { GUEST_HANDLE }; +pub fn print_output_with_host_print(function_call: FunctionCall) -> Result { if let ParameterValue::String(message) = function_call.parameters.unwrap().remove(0) { - let res = handle.call_host_function::( + let res = call_host_function::( "HostPrint", Some(Vec::from(&[ParameterValue::String(message)])), ReturnType::Int, )?; - Ok(get_flatbuffer_result(res)) + Ok(ReturnValue::Int(res)) } else { Err(HyperlightGuestError::new( ErrorCode::GuestError, diff --git a/src/hyperlight_guest_bin/src/lib.rs b/src/hyperlight_guest_bin/src/lib.rs index 398dc77a67..08cf25355d 100644 --- a/src/hyperlight_guest_bin/src/lib.rs +++ b/src/hyperlight_guest_bin/src/lib.rs @@ -267,6 +267,9 @@ pub(crate) extern "C" fn generic_init( OS_PAGE_SIZE = ops as u32; } + // Prepare transport before logging or guest initialization code can use it. + transport::initialize(); + // set up the logger let guest_log_level_filter = GuestLogFilter::try_from(max_log_level).expect("Invalid log level"); @@ -292,9 +295,6 @@ pub(crate) extern "C" fn generic_init( registration(); } - // Prepare transport before guest code starts. - transport::initialize(); - unsafe { hyperlight_main(); } @@ -323,6 +323,7 @@ pub mod __private { pub use alloc::vec::Vec; pub use hyperlight_common::flatbuffer_wrappers::function_call::FunctionCall; + pub use hyperlight_common::flatbuffer_wrappers::function_types::ReturnValue; pub use hyperlight_common::func::ResultType; pub use hyperlight_guest::error::HyperlightGuestError; pub use linkme; diff --git a/src/hyperlight_guest_bin/src/transport.rs b/src/hyperlight_guest_bin/src/transport.rs index 0d66c01f98..edbe2ffec2 100644 --- a/src/hyperlight_guest_bin/src/transport.rs +++ b/src/hyperlight_guest_bin/src/transport.rs @@ -32,6 +32,7 @@ pub(crate) fn initialize() { let h2g_ring_gva = scratch_gva(arena.h2g_ring_addr()); let g2h_pool_gva = scratch_gva(arena.g2h_pool_addr()); let h2g_pool_gva = scratch_gva(arena.h2g_pool_addr()); + let mbx_gva = scratch_gva(arena.mbx_addr()); let g2h_layout = unsafe { Layout::from_base(g2h_ring_gva, g2h.size()) }.expect("G2H layout is invalid"); @@ -52,6 +53,7 @@ pub(crate) fn initialize() { pool_pages: h2g_pages, buffer_size: h2g_bufsz, }, + mbx_gva, ) .expect("failed to create guest context"); diff --git a/src/hyperlight_guest_capi/src/dispatch.rs b/src/hyperlight_guest_capi/src/dispatch.rs index 9bd3d1f013..208ad05e25 100644 --- a/src/hyperlight_guest_capi/src/dispatch.rs +++ b/src/hyperlight_guest_capi/src/dispatch.rs @@ -11,13 +11,11 @@ use hyperlight_common::flatbuffer_wrappers::function_types::{ ParameterType, ReturnType, ReturnValue, }; use hyperlight_common::flatbuffer_wrappers::guest_error::ErrorCode; -use hyperlight_common::flatbuffer_wrappers::util::get_flatbuffer_result; use hyperlight_guest::error::{HyperlightGuestError, Result}; +use hyperlight_guest::transport; use hyperlight_guest_bin::guest_function::definition::GuestFunctionDefinition; use hyperlight_guest_bin::guest_function::register::GuestFunctionRegister; -use hyperlight_guest_bin::host_comm::{ - call_host_function_without_returning_result, get_host_return_value, -}; +use hyperlight_guest_bin::host_comm::call_host_function; use crate::types::{FfiFunctionCall, FfiReturnValue, OwnedFfiFunctionCall}; static mut REGISTERED_C_GUEST_FUNCTIONS: GuestFunctionRegister = @@ -30,24 +28,11 @@ unsafe extern "C" { fn c_guest_dispatch_function(function_call: &FfiFunctionCall) -> *mut FfiReturnValue; } -fn encode_return_value(value: ReturnValue) -> Vec { - match value { - ReturnValue::Int(value) => get_flatbuffer_result(value), - ReturnValue::UInt(value) => get_flatbuffer_result(value), - ReturnValue::Long(value) => get_flatbuffer_result(value), - ReturnValue::ULong(value) => get_flatbuffer_result(value), - ReturnValue::Float(value) => get_flatbuffer_result(value), - ReturnValue::Double(value) => get_flatbuffer_result(value), - ReturnValue::Bool(value) => get_flatbuffer_result(value), - ReturnValue::String(value) => get_flatbuffer_result(value.as_str()), - ReturnValue::VecBytes(value) => get_flatbuffer_result(value.as_slice()), - ReturnValue::ByteChunks(value) => get_flatbuffer_result(value), - ReturnValue::Void(()) => get_flatbuffer_result(()), - } -} - #[unsafe(no_mangle)] -pub fn guest_dispatch_function(function_call: FunctionCall) -> Result> { +pub fn guest_dispatch_function(function_call: FunctionCall) -> Result { + // Discard an error left by guest code outside the current dispatch. + let _ = transport::with_ctx(|ctx| ctx.take_guest_error()); + // Use &raw const to get an immutable reference to the static HashMap // this is to avoid the clippy warning "shared reference to mutable static" if let Some(registered_func) = @@ -65,6 +50,9 @@ pub fn guest_dispatch_function(function_call: FunctionCall) -> Result> { let ffi_func_call = OwnedFfiFunctionCall::from_function_call(function_call)?; let function_result = (registered_func.function_pointer)(ffi_func_call.as_ffi()); if function_result.is_null() { + if let Some(error) = transport::with_ctx(|ctx| ctx.take_guest_error()) { + return Err(HyperlightGuestError::new(error.code, error.message)); + } return Err(HyperlightGuestError::new( ErrorCode::GuestError, alloc::format!("C guest function {function_name:?} returned null"), @@ -76,7 +64,11 @@ pub fn guest_dispatch_function(function_call: FunctionCall) -> Result> { // SAFETY: registered C functions return values created by hl_result_from_*. let function_result = unsafe { (*function_result).into_return_value() }; - Ok(encode_return_value(function_result)) + if let Some(error) = transport::with_ctx(|ctx| ctx.take_guest_error()) { + return Err(HyperlightGuestError::new(error.code, error.message)); + } + + Ok(function_result) } else { // The given function is not registered. The guest should implement a function called c_guest_dispatch_function to handle this. @@ -87,6 +79,9 @@ pub fn guest_dispatch_function(function_call: FunctionCall) -> Result> { let ffi_func_call = OwnedFfiFunctionCall::from_function_call(function_call)?; let function_result = unsafe { c_guest_dispatch_function(ffi_func_call.as_ffi()) }; if function_result.is_null() { + if let Some(error) = transport::with_ctx(|ctx| ctx.take_guest_error()) { + return Err(HyperlightGuestError::new(error.code, error.message)); + } Err(HyperlightGuestError::new( ErrorCode::GuestFunctionNotFound, function_name, @@ -96,7 +91,11 @@ pub fn guest_dispatch_function(function_call: FunctionCall) -> Result> { // SAFETY: non-null fallback results are created by hl_result_from_*. let result = unsafe { (*result).into_return_value() }; - Ok(encode_return_value(result)) + if let Some(error) = transport::with_ctx(|ctx| ctx.take_guest_error()) { + return Err(HyperlightGuestError::new(error.code, error.message)); + } + + Ok(result) } } } @@ -128,11 +127,11 @@ pub extern "C" fn hl_call_host_function(function_call: &FfiFunctionCall) { let func_name = unsafe { function_call.copy_function_name() }; let return_type = unsafe { function_call.copy_return_type() }; - call_host_function_without_returning_result(&func_name, Some(parameters), return_type) - .expect("Failed to call host function"); + let result = call_host_function::(&func_name, Some(parameters), return_type); + transport::with_ctx(|ctx| ctx.stash_host_result(result)); } -/// Retrieve the return value from the last `hl_call_host_function`. +/// Retrieve the return value stashed by the last `hl_call_host_function`. pub(crate) fn take_last_host_return>() -> T { - get_host_return_value().expect("Unable to get host return value") + transport::with_ctx(|ctx| ctx.take_host_return::()) } diff --git a/src/hyperlight_guest_capi/src/error.rs b/src/hyperlight_guest_capi/src/error.rs index 3d8028104d..31a4f7ab66 100644 --- a/src/hyperlight_guest_capi/src/error.rs +++ b/src/hyperlight_guest_capi/src/error.rs @@ -3,31 +3,21 @@ use core::ffi::{CStr, c_char}; -use flatbuffers::FlatBufferBuilder; -use hyperlight_common::flatbuffer_wrappers::function_types::FunctionCallResult; use hyperlight_common::flatbuffer_wrappers::guest_error::{ErrorCode, GuestError}; -use hyperlight_guest_bin::GUEST_HANDLE; +use hyperlight_guest::transport; use crate::alloc::borrow::ToOwned; #[unsafe(no_mangle)] pub extern "C" fn hl_set_error(err: ErrorCode, message: *const c_char) { let cstr = unsafe { CStr::from_ptr(message) }; - let guest_error = Err(GuestError::new( + let guest_error = GuestError::new( err.into(), cstr.to_str() .expect("Failed to convert CStr to &str") .to_owned(), - )); - let fcr = FunctionCallResult::new(guest_error); - let mut builder = FlatBufferBuilder::new(); - let data = fcr.encode(&mut builder); - unsafe { - #[allow(static_mut_refs)] // we are single threaded - GUEST_HANDLE - .push_shared_output_data(data) - .expect("Failed to set error") - } + ); + transport::with_ctx(|ctx| ctx.set_guest_error(guest_error)); } #[unsafe(no_mangle)] diff --git a/src/hyperlight_guest_macro/src/lib.rs b/src/hyperlight_guest_macro/src/lib.rs index 44951672cd..bbee870ad7 100644 --- a/src/hyperlight_guest_macro/src/lib.rs +++ b/src/hyperlight_guest_macro/src/lib.rs @@ -191,12 +191,12 @@ pub fn main(_attr: TokenStream, item: TokenStream) -> TokenStream { /// use hyperlight_guest::error::Result; /// use hyperlight_guest::bail; /// use hyperlight_common::flatbuffer_wrappers::function_call::FunctionCall; -/// use hyperlight_common::flatbuffer_wrappers::util::get_flatbuffer_result; +/// use hyperlight_common::flatbuffer_wrappers::function_types::ReturnValue; /// #[dispatch] -/// fn dispatch(fc: FunctionCall) -> Result> { +/// fn dispatch(fc: FunctionCall) -> Result { /// let name = &fc.function_name; /// if name == "greet" { -/// return Ok(get_flatbuffer_result("Hello, world!")); +/// return Ok(ReturnValue::String("Hello, world!".into())); /// } /// bail!("Unknown function: {name}"); /// } @@ -228,9 +228,9 @@ pub fn dispatch(_attr: TokenStream, item: TokenStream) -> TokenStream { const _: () = { mod wrapper { - use #crate_name::__private::{FunctionCall, HyperlightGuestError, Vec}; + use #crate_name::__private::{FunctionCall, HyperlightGuestError, ReturnValue}; #[unsafe(no_mangle)] - pub fn guest_dispatch_function(function_call: FunctionCall) -> ::core::result::Result, HyperlightGuestError> { + pub fn guest_dispatch_function(function_call: FunctionCall) -> ::core::result::Result { super::#ident(function_call) } } diff --git a/src/hyperlight_guest_tracing/src/lib.rs b/src/hyperlight_guest_tracing/src/lib.rs index 2371b3e264..3329029c64 100644 --- a/src/hyperlight_guest_tracing/src/lib.rs +++ b/src/hyperlight_guest_tracing/src/lib.rs @@ -172,26 +172,15 @@ mod trace { } } - /// Returns information about the current trace state needed by the host to read the spans. + /// Returns information about the current trace state needed by the host. + /// + /// Returns `None` if tracing code already holds the state lock. Exception and + /// abort paths can then proceed without the pending trace data. pub fn serialized_data() -> Option<(u64, u64)> { if let Some(w) = GUEST_STATE.get() && let Some(state_mutex) = w.upgrade() { - // We want to protect against re-entrancy issues produced by tracing code that locks - // the state and then causes an exception that tries to lock the state again. - // - // For example: - // - 1. A span is created, locking the state - // - 2. An exception occurs while the span is being created (e.g. not enough memory, etc.) - // - 3. The exception handler uses the tracing API to send the trace data to the host - // or just create spans/events for logging purposes. - // - 4. The tracing API tries to lock the state again, causing a deadlock. - // To avoid this, we use try_lock and if we cannot acquire the lock, we panic to signal - // the issue. - let state = state_mutex - .try_lock() - .expect("Unable to lock GuestState in `serialized_data`"); - + let state = state_mutex.try_lock()?; state.serialized_data() } else { None diff --git a/src/hyperlight_host/benches/benchmarks.rs b/src/hyperlight_host/benches/benchmarks.rs index 70df4384ab..a7bc8393ef 100644 --- a/src/hyperlight_host/benches/benchmarks.rs +++ b/src/hyperlight_host/benches/benchmarks.rs @@ -5,14 +5,18 @@ use std::sync::{Arc, Barrier, Mutex}; use std::thread; use std::time::{Duration, Instant}; +use anyhow::{Result, bail}; use criterion::{BenchmarkId, Criterion, Throughput, criterion_group, criterion_main}; use flatbuffers::FlatBufferBuilder; +use hyperlight_common::flatbuffer_wrappers::ExternalValueSource; use hyperlight_common::flatbuffer_wrappers::function_call::{FunctionCall, FunctionCallType}; -use hyperlight_common::flatbuffer_wrappers::function_types::{ParameterValue, ReturnType}; +use hyperlight_common::flatbuffer_wrappers::function_types::{Bytes, ParameterValue, ReturnType}; use hyperlight_common::flatbuffer_wrappers::util::estimate_flatbuffer_capacity; -use hyperlight_host::SandboxBuilder; +use hyperlight_common::transport::ExternalValues; +use hyperlight_common::vmem::PAGE_SIZE; use hyperlight_host::mem::shared_mem::ExclusiveSharedMemory; -use hyperlight_host::sandbox::MultiUseSandbox; +use hyperlight_host::sandbox::{MultiUseSandbox, SandboxConfiguration, UninitializedSandbox}; +use hyperlight_host::{GuestBinary, SandboxBuilder}; use hyperlight_testing::sandbox_sizes::{LARGE_HEAP_SIZE, MEDIUM_HEAP_SIZE, SMALL_HEAP_SIZE}; use hyperlight_testing::{c_simple_guest_as_pathbuf, simple_guest_as_pathbuf}; @@ -347,17 +351,22 @@ fn guest_call_benchmark_large_param(c: &mut Criterion) { group.bench_function("guest_call_with_large_parameters", |b| { const SIZE: usize = 50 * 1024 * 1024; // 50 MB + const MIB: usize = 1024 * 1024; let large_vec = vec![0u8; SIZE]; let large_string = String::from_utf8(large_vec.clone()).unwrap(); - let mut sandbox = SandboxBuilder::from_file(simple_guest_as_pathbuf()) - // 2 * SIZE + 1 MB, to allow 1MB for the rest of the serialized function call - .input_data_size(2 * SIZE + (1024 * 1024)) - .heap_size(SIZE as u64 * 15) - // Big enough for the IO data regions and enough of the heap to be used - .scratch_size(6 * SIZE + 4 * (1024 * 1024)) - .build() - .unwrap(); + let mut config = SandboxConfiguration::default(); + config.set_h2g_buffer_size(4 * MIB); + config.set_h2g_pool_pages((2 * SIZE + 8 * MIB).div_ceil(PAGE_SIZE)); + config.set_heap_size(SIZE as u64 * 15); + config.set_scratch_size(9 * SIZE); + + let sandbox = UninitializedSandbox::new( + GuestBinary::FilePath(simple_guest_as_pathbuf()), + Some(config), + ) + .unwrap(); + let mut sandbox = sandbox.evolve().unwrap(); b.iter_with_setup( || (large_vec.clone(), large_string.clone()), @@ -373,51 +382,129 @@ fn guest_call_benchmark_large_param(c: &mut Criterion) { } // ============================================================================ -// Benchmark Category: Serialization +// Benchmark Category: Function Call Codec // ============================================================================ -fn function_call_serialization_benchmark(c: &mut Criterion) { - let mut group = c.benchmark_group("function_call_serialization"); +enum BenchExternalValue<'a> { + Bytes(&'a [u8]), + Chunks(&'a [Bytes]), +} + +struct BenchExternalSource<'a> { + value: Option>, +} + +impl<'a> BenchExternalSource<'a> { + fn new(value: BenchExternalValue<'a>) -> Self { + Self { value: Some(value) } + } +} + +impl ExternalValueSource for BenchExternalSource<'_> { + fn take_bytes(&mut self, length: usize) -> Result> { + let Some(BenchExternalValue::Bytes(value)) = self.value.take() else { + bail!("expected external bytes"); + }; + if value.len() != length { + bail!( + "external byte length mismatch: expected {length}, got {}", + value.len() + ); + } + Ok(value.to_vec()) + } + + fn take_chunks(&mut self, length: usize) -> Result> { + let Some(BenchExternalValue::Chunks(value)) = self.value.take() else { + bail!("expected external byte chunks"); + }; + let actual = value.iter().map(Bytes::len).sum::(); + if actual != length { + bail!("external chunk length mismatch: expected {length}, got {actual}"); + } + Ok(value.to_vec()) + } + + fn finish(&mut self) -> Result<()> { + if self.value.is_some() { + bail!("external value was not consumed"); + } + Ok(()) + } +} - let function_call = FunctionCall::new( +fn codec_benchmark_call(parameter: ParameterValue) -> FunctionCall { + FunctionCall::new( "TestFunction".to_string(), Some(vec![ - ParameterValue::VecBytes(vec![1; 10 * 1024 * 1024]), - ParameterValue::String(String::from_utf8(vec![2; 10 * 1024 * 1024]).unwrap()), + parameter, + ParameterValue::String("argument".to_string()), ParameterValue::Int(42), - ParameterValue::UInt(100), - ParameterValue::Long(1000), - ParameterValue::ULong(2000), - ParameterValue::Float(521521.53), - ParameterValue::Double(432.53), ParameterValue::Bool(true), - ParameterValue::VecBytes(vec![1; 10 * 1024 * 1024]), - ParameterValue::String(String::from_utf8(vec![2; 10 * 1024 * 1024]).unwrap()), ]), FunctionCallType::Guest, ReturnType::Int, - ); + ) +} + +fn function_call_codec_benchmark(c: &mut Criterion) { + const PAYLOAD_SIZE: usize = 10 * 1024 * 1024; + const CHUNK_SIZE: usize = 256 * 1024; + + let vec_bytes = vec![1; PAYLOAD_SIZE]; + let byte_chunks = (0..PAYLOAD_SIZE / CHUNK_SIZE) + .map(|_| Bytes::from(vec![1; CHUNK_SIZE])) + .collect::>(); - group.bench_function("serialize_function_call", |b| { + let vec_call = codec_benchmark_call(ParameterValue::VecBytes(vec_bytes.clone())); + let chunk_call = codec_benchmark_call(ParameterValue::ByteChunks(byte_chunks.clone())); + let mut group = c.benchmark_group("function_call_codec"); + + for (name, function_call) in [("vec_bytes", &vec_call), ("byte_chunks", &chunk_call)] { + group.bench_function(BenchmarkId::new("encode_control", name), |b| { + b.iter(|| { + let estimated_capacity = estimate_flatbuffer_capacity( + &function_call.function_name, + function_call.parameters.as_deref().unwrap_or_default(), + ); + let mut builder = FlatBufferBuilder::with_capacity(estimated_capacity); + let mut exts = ExternalValues::new(); + + let control = function_call.encode(&mut builder, &mut exts).unwrap(); + std::hint::black_box((control, exts.total_len())); + }); + }); + } + + let mut builder = FlatBufferBuilder::new(); + let mut external_values = ExternalValues::new(); + + let vec_control = vec_call + .encode(&mut builder, &mut external_values) + .unwrap() + .to_vec(); + + group.bench_function("decode_vec_bytes_copy", |b| { b.iter(|| { - // We specifically want to include the time to estimate the capacity in this benchmark - let estimated_capacity = estimate_flatbuffer_capacity( - function_call.function_name.as_str(), - function_call.parameters.as_deref().unwrap_or(&[]), - ); - let mut builder = FlatBufferBuilder::with_capacity(estimated_capacity); - let serialized: &[u8] = function_call.encode(&mut builder); - std::hint::black_box(serialized); + let mut src = BenchExternalSource::new(BenchExternalValue::Bytes(&vec_bytes)); + let function_call = FunctionCall::decode(&vec_control, &mut src).unwrap(); + std::hint::black_box(function_call); }); }); - group.bench_function("deserialize_function_call", |b| { - let mut builder = FlatBufferBuilder::new(); - let bytes = function_call.clone().encode(&mut builder); + let mut builder = FlatBufferBuilder::new(); + let mut external_values = ExternalValues::new(); + + let chunk_control = chunk_call + .encode(&mut builder, &mut external_values) + .unwrap() + .to_vec(); + group.bench_function("decode_byte_chunks_owner_backed", |b| { b.iter(|| { - let deserialized: FunctionCall = bytes.try_into().unwrap(); - std::hint::black_box(deserialized); + let mut src = BenchExternalSource::new(BenchExternalValue::Chunks(&byte_chunks)); + let function_call = FunctionCall::decode(&chunk_control, &mut src).unwrap(); + std::hint::black_box(function_call); }); }); @@ -432,9 +519,12 @@ fn sample_workloads_benchmark(c: &mut Criterion) { let mut group = c.benchmark_group("sample_workloads"); fn bench_24k_in_8k_out(b: &mut criterion::Bencher, guest_path: std::path::PathBuf) { - let mut sandbox = SandboxBuilder::from_file(guest_path) - .input_data_size(25 * 1024) - .build() + let mut cfg = SandboxConfiguration::default(); + cfg.set_h2g_pool_pages(8); + + let mut sandbox = UninitializedSandbox::new(GuestBinary::FilePath(guest_path), Some(cfg)) + .unwrap() + .evolve() .unwrap(); b.iter_with_setup( @@ -659,7 +749,7 @@ criterion_group! { guest_calls_benchmark, snapshots_benchmark, guest_call_benchmark_large_param, - function_call_serialization_benchmark, + function_call_codec_benchmark, sample_workloads_benchmark, shared_memory_benchmark, snapshot_file_benchmark diff --git a/src/hyperlight_host/src/error.rs b/src/hyperlight_host/src/error.rs index fb6dcb395b..1070ea9f47 100644 --- a/src/hyperlight_host/src/error.rs +++ b/src/hyperlight_host/src/error.rs @@ -15,6 +15,7 @@ use crossbeam_channel::{RecvError, SendError}; use flatbuffers::InvalidFlatbuffer; use hyperlight_common::flatbuffer_wrappers::function_types::{ParameterValue, ReturnValue}; use hyperlight_common::flatbuffer_wrappers::guest_error::ErrorCode; +use hyperlight_common::virtq::VirtqError; use thiserror::Error; use crate::hypervisor::hyperlight_vm::HyperlightVmError; @@ -52,6 +53,10 @@ pub enum HyperlightError { #[error("{0}")] Error(String), + /// The virtqueue transport cannot be used safely. + #[error("Virtqueue transport error: {0}")] + TransportError(String), + /// Execution violation #[error("Non-executable address {0:#x} tried to be executed")] ExecutionAccessViolation(u64), @@ -273,6 +278,12 @@ impl From for HyperlightError { } } +impl From for HyperlightError { + fn from(error: VirtqError) -> Self { + Self::TransportError(error.to_string()) + } +} + impl From<&str> for HyperlightError { fn from(s: &str) -> Self { HyperlightError::Error(s.to_string()) @@ -313,6 +324,7 @@ impl HyperlightError { | HyperlightError::PoisonedSandbox | HyperlightError::ExecutionAccessViolation(_) | HyperlightError::MemoryAccessViolation(_, _, _) + | HyperlightError::TransportError(_) // HyperlightVmError::Restore is already handled manually in restore(), but we mark it // as poisoning here too for defense in depth. | HyperlightError::HyperlightVmError(HyperlightVmError::Restore(_)) => true, @@ -409,6 +421,14 @@ mod tests { }; use crate::sandbox::outb::HandleOutbError; + #[test] + fn virtq_error_converts_to_poisoning_transport_error() { + let error = HyperlightError::from(VirtqError::InvalidState); + + assert!(matches!(error, HyperlightError::TransportError(_))); + assert!(error.is_poison_error()); + } + /// Test that ExecutionCancelledByHost promotes to HyperlightError::ExecutionCanceledByHost #[test] fn test_promote_execution_cancelled_by_host() { diff --git a/src/hyperlight_host/src/hypervisor/hyperlight_vm/x86_64.rs b/src/hyperlight_host/src/hypervisor/hyperlight_vm/x86_64.rs index 6af8df7eec..17793f1918 100644 --- a/src/hyperlight_host/src/hypervisor/hyperlight_vm/x86_64.rs +++ b/src/hyperlight_host/src/hypervisor/hyperlight_vm/x86_64.rs @@ -1424,14 +1424,9 @@ mod tests { // Test VM Setup // ========================================================================== - /// Creates a test VM with the given code. This is the shared setup logic used by - /// both `hyperlight_vm()` and `create_test_vm_context()`. - fn create_test_vm_context(code: &[u8]) -> TestVmContext { - let config: SandboxConfiguration = Default::default(); - #[cfg(any(crashdump, gdb))] - let rt_cfg: SandboxRuntimeConfig = Default::default(); - - let mut layout = SandboxMemoryLayout::new(config, code.len(), 4096, None).unwrap(); + fn create_test_layout(code_size: usize) -> (SandboxMemoryLayout, Box<[u8]>) { + let config = SandboxConfiguration::default(); + let mut layout = SandboxMemoryLayout::new(config, code_size, 4096, None).unwrap(); let pt_base_gpa = layout.get_pt_base_gpa(); let pt_buf = GuestPageTableBuffer::new(pt_base_gpa as usize); @@ -1457,7 +1452,6 @@ mod tests { unsafe { vmem::map(&pt_buf, mapping) }; } - // Map the scratch region at the top of the address space let scratch_size = config.get_scratch_size(); let scratch_gpa = hyperlight_common::layout::scratch_base_gpa(scratch_size); let scratch_gva = hyperlight_common::layout::scratch_base_gva(scratch_size); @@ -1468,13 +1462,24 @@ mod tests { kind: MappingKind::Basic(BasicMapping { readable: true, writable: true, - executable: true, // Match regular codepath (map_specials) + executable: true, }), }; unsafe { vmem::map(&pt_buf, scratch_mapping) }; let pt_bytes = pt_buf.into_bytes(); layout.set_pt_size(pt_bytes.len()).unwrap(); + (layout, pt_bytes) + } + + /// Creates a test VM with the given code. This is the shared setup logic used by + /// both `hyperlight_vm()` and `create_test_vm_context()`. + fn create_test_vm_context(code: &[u8]) -> TestVmContext { + let config: SandboxConfiguration = Default::default(); + #[cfg(any(crashdump, gdb))] + let rt_cfg: SandboxRuntimeConfig = Default::default(); + + let (layout, pt_bytes) = create_test_layout(code.len()); let mem_size = layout.get_memory_size().unwrap(); let mut snapshot_contents = vec![0u8; mem_size]; @@ -2106,7 +2111,7 @@ mod tests { /// Extended test context for FXSAVE tests that need to read memory at a specific offset. struct FxsaveTestContext { ctx: TestVmContext, - /// Offset in shared memory where FXSAVE data is stored (output_data region) + /// Offset in scratch memory where FXSAVE data is stored. fxsave_offset: usize, } @@ -2136,18 +2141,19 @@ mod tests { } } - /// Creates VM with guest code that: dirtys FPU (if flag==0), does FXSAVE to buffer, sets flag=1. - /// Uses output_data region for FXSAVE buffer (like regular guest output), scratch for stack. + /// Creates VM with guest code that dirties FPU once and writes FXSAVE state. fn hyperlight_vm_with_mem_mgr_fxsave() -> FxsaveTestContext { use iced_x86::code_asm::*; - // Compute fixed addresses for FXSAVE buffer and flag. - // These are in the output_data region which starts at a known offset. - // We use a default SandboxConfiguration to get the same layout as create_test_vm_context. - let config: SandboxConfiguration = Default::default(); - let layout = SandboxMemoryLayout::new(config, 512, 4096, None).unwrap(); - let fxsave_offset = layout.get_output_data_buffer_scratch_host_offset(); - let fxsave_gva = layout.get_output_data_buffer_gva(); + const CODE_SIZE_BOUND: usize = 512; + + let (layout, _) = create_test_layout(CODE_SIZE_BOUND); + let scratch_size = layout.get_scratch_size(); + let scratch_base_gpa = hyperlight_common::layout::scratch_base_gpa(scratch_size); + let scratch_base_gva = hyperlight_common::layout::scratch_base_gva(scratch_size); + let fxsave_gpa = layout.get_first_free_scratch_gpa(); + let fxsave_offset = usize::try_from(fxsave_gpa - scratch_base_gpa).unwrap(); + let fxsave_gva = scratch_base_gva + fxsave_offset as u64; let flag_gva = fxsave_gva + 512; let mut a = CodeAssembler::new(64).unwrap(); @@ -2204,9 +2210,11 @@ mod tests { a.hlt().unwrap(); let code = a.assemble(0).unwrap(); + assert!(code.len() <= CODE_SIZE_BOUND); // Reuse common test setup - initialise() will run the code let ctx = create_test_vm_context(&code); + assert_eq!(ctx.hshm.layout.get_first_free_scratch_gpa(), fxsave_gpa); FxsaveTestContext { ctx, fxsave_offset } } diff --git a/src/hyperlight_host/src/mem/layout.rs b/src/hyperlight_host/src/mem/layout.rs index bcd144521d..39addfc7b4 100644 --- a/src/hyperlight_host/src/mem/layout.rs +++ b/src/hyperlight_host/src/mem/layout.rs @@ -34,8 +34,8 @@ //! //! There is also a scratch region at the top of physical memory, //! which is mostly laid out as a large undifferentiated blob of -//! memory, although at present the snapshot process specially -//! privileges fixed input, output, and transport regions: +//! memory, although the transport arena and copied page tables have +//! fixed positions: //! //! +-------------------------------------------+ (top of physical memory) //! | Exception Stack, Metadata | @@ -45,16 +45,12 @@ //! | Guest Page Tables | //! +-------------------------------------------+ //! | Transport Arena | -//! +-------------------------------------------+ -//! | Output Data | -//! +-------------------------------------------+ -//! | Input Data | //! +-------------------------------------------+ (scratch size) use std::fmt::Debug; use std::mem::size_of; -use hyperlight_common::layout::TransportArena; +use hyperlight_common::layout::{QueueDims, TransportArena}; use hyperlight_common::mem::HyperlightPEB; use hyperlight_common::vmem::PAGE_SIZE; use tracing::{Span, instrument}; @@ -241,10 +237,6 @@ impl ResolvedGpa { #[derive(Copy, Clone)] pub(crate) struct SandboxMemoryLayout { - /// Input data buffer size (from SandboxConfiguration). - input_data_size: usize, - /// Output data buffer size (from SandboxConfiguration). - output_data_size: usize, /// The heap size of this sandbox. heap_size: usize, /// The size of the guest code section. @@ -292,14 +284,6 @@ impl Debug for SandboxMemoryLayout { "Init Data Size", &format_args!("{:#x}", self.init_data_size), ) - .field( - "Input Data Size", - &format_args!("{:#x}", self.input_data_size), - ) - .field( - "Output Data Size", - &format_args!("{:#x}", self.output_data_size), - ) .field("Scratch Size", &format_args!("{:#x}", self.scratch_size)) .field("G2H Queue Size", &self.g2h_queue_size) .field("H2G Queue Size", &self.h2g_queue_size) @@ -338,9 +322,6 @@ impl SandboxMemoryLayout { /// The base address of the sandbox's memory. pub(crate) const BASE_ADDRESS: usize = 0x4000; - // the offset into a sandbox's input/output buffer where the stack starts - pub(crate) const STACK_POINTER_SIZE_BYTES: u64 = 8; - /// Create a new `SandboxMemoryLayout` with the given /// `SandboxConfiguration`, code size and stack/heap size. #[instrument(err(Debug), skip_all, parent = Span::current(), level= "Trace")] @@ -360,8 +341,6 @@ impl SandboxMemoryLayout { "scratch size {scratch_size} must be a multiple of {PAGE_SIZE}" )); } - let input_data_size = cfg.get_input_data_size(); - let output_data_size = cfg.get_output_data_size(); let g2h_queue_size = cfg.get_g2h_queue_size(); let h2g_queue_size = cfg.get_h2g_queue_size(); let g2h_buffer_size = cfg.get_g2h_buffer_size(); @@ -369,8 +348,6 @@ impl SandboxMemoryLayout { let g2h_pool_pages = cfg.get_g2h_pool_pages(); let h2g_pool_pages = cfg.get_h2g_pool_pages(); let min_scratch_size = hyperlight_common::layout::min_scratch_size( - input_data_size, - output_data_size, g2h_queue_size, h2g_queue_size, g2h_pool_pages, @@ -381,8 +358,6 @@ impl SandboxMemoryLayout { } let mut ret = Self { - input_data_size, - output_data_size, heap_size, code_size, init_data_size, @@ -401,14 +376,6 @@ impl SandboxMemoryLayout { Ok(ret) } - pub(crate) fn input_data_size(&self) -> usize { - self.input_data_size - } - - pub(crate) fn output_data_size(&self) -> usize { - self.output_data_size - } - pub(crate) fn heap_size(&self) -> usize { self.heap_size } @@ -459,13 +426,15 @@ impl SandboxMemoryLayout { self.h2g_pool_pages } - pub(crate) fn get_g2h_queue_dims(&self) -> hyperlight_common::layout::QueueDims { - hyperlight_common::layout::QueueDims::new(self.g2h_queue_size, self.g2h_pool_pages) + #[allow(clippy::expect_used)] // `new` validates these dimensions. + pub(crate) fn get_g2h_queue_dims(&self) -> QueueDims { + QueueDims::new(self.g2h_queue_size, self.g2h_pool_pages) .expect("validated G2H queue dimensions") } - pub(crate) fn get_h2g_queue_dims(&self) -> hyperlight_common::layout::QueueDims { - hyperlight_common::layout::QueueDims::new(self.h2g_queue_size, self.h2g_pool_pages) + #[allow(clippy::expect_used)] // `new` validates these dimensions. + pub(crate) fn get_h2g_queue_dims(&self) -> QueueDims { + QueueDims::new(self.h2g_queue_size, self.h2g_pool_pages) .expect("validated H2G queue dimensions") } @@ -492,8 +461,6 @@ impl SandboxMemoryLayout { /// independent field and must be set separately. pub(crate) fn set_pt_size(&mut self, size: usize) -> Result<()> { let min_fixed_scratch = hyperlight_common::layout::min_scratch_size( - self.input_data_size, - self.output_data_size, self.g2h_queue_size, self.h2g_queue_size, self.g2h_pool_pages, @@ -629,14 +596,6 @@ impl SandboxMemoryLayout { let guest_base = Self::BASE_ADDRESS as u64; let peb = HyperlightPEB { - input_stack: GuestMemoryRegion { - size: self.input_data_size as u64, - ptr: self.get_input_data_buffer_gva(), - }, - output_stack: GuestMemoryRegion { - size: self.output_data_size as u64, - ptr: self.get_output_data_buffer_gva(), - }, init_data: GuestMemoryRegion { size: (self.get_unaligned_memory_size() - self.init_data_offset()) as u64, ptr: guest_base + self.init_data_offset() as u64, @@ -661,11 +620,6 @@ impl SandboxMemoryLayout { })?; dst.copy_from_slice(bytes); - // The input and output data regions do not have their layout - // initialised here, because they are in the scratch - // region---they are instead set in - // [`SandboxMemoryManager::update_scratch_bookkeeping`]. - Ok(()) } @@ -737,30 +691,10 @@ impl SandboxMemoryLayout { Self::BASE_ADDRESS + self.guest_code_offset() } - /// Guest virtual address of the start of output data. - pub(crate) fn get_output_data_buffer_gva(&self) -> u64 { - hyperlight_common::layout::scratch_base_gva(self.scratch_size) + self.input_data_size as u64 - } - - /// Offset into the host scratch buffer of the start of the output data. - pub(crate) fn get_output_data_buffer_scratch_host_offset(&self) -> usize { - self.input_data_size - } - - /// Guest virtual address of the start of input data. - fn get_input_data_buffer_gva(&self) -> u64 { - hyperlight_common::layout::scratch_base_gva(self.scratch_size) - } - - /// Offset into the host scratch buffer of the start of the input data. - pub(crate) fn get_input_data_buffer_scratch_host_offset(&self) -> usize { - 0 - } - /// Offset from the beginning of the scratch region to the location /// where page tables are eagerly copied on restore. pub(crate) fn get_pt_base_scratch_offset(&self) -> usize { - self.get_virtq_base_scratch_offset() + self.get_transport_arena().size() + self.get_transport_arena().size() } /// Base GPA to which the page tables are eagerly copied on restore. @@ -774,15 +708,10 @@ impl SandboxMemoryLayout { self.get_pt_base_gpa() + self.pt_size.unwrap_or(0) as u64 } - fn get_virtq_base_scratch_offset(&self) -> usize { - (self.input_data_size + self.output_data_size) - .next_multiple_of(hyperlight_common::vmem::PAGE_SIZE) - } - /// Exact transport placement in the fixed scratch prefix. + #[allow(clippy::expect_used)] // The base and dimensions are validated by `new`. pub(crate) fn get_transport_arena(&self) -> TransportArena { - let base_gpa = hyperlight_common::layout::scratch_base_gpa(self.scratch_size) - + self.get_virtq_base_scratch_offset() as u64; + let base_gpa = hyperlight_common::layout::scratch_base_gpa(self.scratch_size); TransportArena::new( base_gpa, @@ -851,8 +780,6 @@ mod tests { fn transport_memory_is_part_of_minimum_scratch_size() { let mut cfg = SandboxConfiguration::default(); let minimum = hyperlight_common::layout::min_scratch_size( - cfg.get_input_data_size(), - cfg.get_output_data_size(), cfg.get_g2h_queue_size(), cfg.get_h2g_queue_size(), cfg.get_g2h_pool_pages(), @@ -895,7 +822,6 @@ mod tests { let mut cfg = SandboxConfiguration::default(); // scratch_size exceeds 16 GiB limit cfg.set_scratch_size(17 * 1024 * 1024 * 1024); - cfg.set_input_data_size(16 * 1024 * 1024 * 1024); let layout = SandboxMemoryLayout::new(cfg, 4096, 4096, None); assert!(matches!(layout.unwrap_err(), MemoryRequestTooBig(..))); } @@ -958,10 +884,8 @@ mod tests { ); let mut cfg = SandboxConfiguration::default(); - cfg.set_input_data_size(0x2000); - cfg.set_output_data_size(0x2000); cfg.set_heap_size(0x2000); - cfg.set_scratch_size(0x20000); + cfg.set_scratch_size(0x30000); let layout = SandboxMemoryLayout::new(cfg, 0x1000, 0, None).unwrap(); pin_eq!(layout.guest_code_offset(), 0); @@ -971,39 +895,26 @@ mod tests { pin_eq!(layout.init_data_offset(), 0x4000); pin_eq!(layout.get_memory_size().unwrap(), 0x4000); - pin_eq!(layout.get_scratch_size(), 0x20000); + pin_eq!(layout.get_scratch_size(), 0x30000); pin_eq!(layout.get_pt_size(), 0); - pin_eq!(layout.get_input_data_buffer_scratch_host_offset(), 0); - pin_eq!(layout.get_output_data_buffer_scratch_host_offset(), 0x2000); - pin_eq!(layout.get_pt_base_scratch_offset(), 0x11000); + pin_eq!(layout.get_pt_base_scratch_offset(), 0x15000); let arena = layout.get_transport_arena(); - let scratch_base_gpa = hyperlight_common::layout::scratch_base_gpa(0x20000); - pin_eq!(arena.g2h_ring_addr() - scratch_base_gpa, 0x4000); - pin_eq!(arena.h2g_ring_addr() - scratch_base_gpa, 0x4410); - pin_eq!(arena.g2h_pool_addr() - scratch_base_gpa, 0x5000); + let scratch_base_gpa = hyperlight_common::layout::scratch_base_gpa(0x30000); + pin_eq!(arena.g2h_ring_addr() - scratch_base_gpa, 0); + pin_eq!(arena.h2g_ring_addr() - scratch_base_gpa, 0x410); + pin_eq!(arena.mbx_addr() - scratch_base_gpa, 0x618); + pin_eq!(arena.g2h_pool_addr() - scratch_base_gpa, 0x1000); pin_eq!(arena.h2g_pool_addr() - scratch_base_gpa, 0xd000); - pin_eq!(arena.end_addr() - scratch_base_gpa, 0x11000); + pin_eq!(arena.end_addr() - scratch_base_gpa, 0x15000); - // The output buffer sits one input buffer past the input - // buffer in the guest's scratch view. - pin_eq!( - layout.get_output_data_buffer_gva() - layout.get_input_data_buffer_gva(), - 0x2000 - ); - - // The input buffer sits at the scratch base. The page tables - // sit `get_pt_base_scratch_offset` above it. With the - // `SCRATCH_TOP` pins above, these fix the absolute addresses. - pin_eq!( - layout.get_input_data_buffer_gva() - - hyperlight_common::layout::scratch_base_gva(0x20000), - 0 - ); + // The transport arena sits at the scratch base. The page tables + // follow it. With the `SCRATCH_TOP` pins above, these fix the + // absolute addresses. pin_eq!( - layout.get_pt_base_gpa() - hyperlight_common::layout::scratch_base_gpa(0x20000), - 0x11000 + layout.get_pt_base_gpa() - hyperlight_common::layout::scratch_base_gpa(0x30000), + 0x15000 ); // pt_size is zero here, so the first free scratch GPA equals // the page table base. @@ -1012,13 +923,11 @@ mod tests { layout.get_pt_base_gpa() ); - // A second config with different sizes shifts the offsets off - // the first config's page boundaries. + // A second snapshot layout keeps the transport prefix fixed + // relative to its scratch base. let mut cfg = SandboxConfiguration::default(); - cfg.set_input_data_size(0x4000); - cfg.set_output_data_size(0x2000); cfg.set_heap_size(0x5000); - cfg.set_scratch_size(0x30000); + cfg.set_scratch_size(0x40000); let layout = SandboxMemoryLayout::new(cfg, 0x3000, 0, None).unwrap(); pin_eq!(layout.guest_code_offset(), 0); @@ -1031,34 +940,23 @@ mod tests { 0x9000_usize.next_multiple_of(page_size::get()) ); - pin_eq!(layout.get_scratch_size(), 0x30000); + pin_eq!(layout.get_scratch_size(), 0x40000); pin_eq!(layout.get_pt_size(), 0); - pin_eq!(layout.get_input_data_buffer_scratch_host_offset(), 0); - pin_eq!(layout.get_output_data_buffer_scratch_host_offset(), 0x4000); - pin_eq!(layout.get_pt_base_scratch_offset(), 0x13000); + pin_eq!(layout.get_pt_base_scratch_offset(), 0x15000); let arena = layout.get_transport_arena(); - let scratch_base_gpa = hyperlight_common::layout::scratch_base_gpa(0x30000); - pin_eq!(arena.g2h_ring_addr() - scratch_base_gpa, 0x6000); - pin_eq!(arena.h2g_ring_addr() - scratch_base_gpa, 0x6410); - pin_eq!(arena.g2h_pool_addr() - scratch_base_gpa, 0x7000); - pin_eq!(arena.h2g_pool_addr() - scratch_base_gpa, 0xf000); - pin_eq!(arena.end_addr() - scratch_base_gpa, 0x13000); - - pin_eq!( - layout.get_output_data_buffer_gva() - layout.get_input_data_buffer_gva(), - 0x4000 - ); + let scratch_base_gpa = hyperlight_common::layout::scratch_base_gpa(0x40000); + pin_eq!(arena.g2h_ring_addr() - scratch_base_gpa, 0); + pin_eq!(arena.h2g_ring_addr() - scratch_base_gpa, 0x410); + pin_eq!(arena.mbx_addr() - scratch_base_gpa, 0x618); + pin_eq!(arena.g2h_pool_addr() - scratch_base_gpa, 0x1000); + pin_eq!(arena.h2g_pool_addr() - scratch_base_gpa, 0xd000); + pin_eq!(arena.end_addr() - scratch_base_gpa, 0x15000); pin_eq!( - layout.get_input_data_buffer_gva() - - hyperlight_common::layout::scratch_base_gva(0x30000), - 0 - ); - pin_eq!( - layout.get_pt_base_gpa() - hyperlight_common::layout::scratch_base_gpa(0x30000), - 0x13000 + layout.get_pt_base_gpa() - hyperlight_common::layout::scratch_base_gpa(0x40000), + 0x15000 ); pin_eq!( layout.get_first_free_scratch_gpa(), diff --git a/src/hyperlight_host/src/mem/mgr.rs b/src/hyperlight_host/src/mem/mgr.rs index 30ec8a4424..96b66b5359 100644 --- a/src/hyperlight_host/src/mem/mgr.rs +++ b/src/hyperlight_host/src/mem/mgr.rs @@ -2,12 +2,12 @@ // Copyright 2025 The Hyperlight Authors. use flatbuffers::FlatBufferBuilder; -use hyperlight_common::flatbuffer_wrappers::function_call::{ - FunctionCall, validate_guest_function_call_buffer, -}; +use hyperlight_common::flatbuffer_wrappers::function_call::FunctionCall; use hyperlight_common::flatbuffer_wrappers::function_types::FunctionCallResult; -use hyperlight_common::flatbuffer_wrappers::guest_log_data::GuestLogData; use hyperlight_common::flatbuffer_wrappers::host_function_details::HostFunctionDetails; +use hyperlight_common::flatbuffer_wrappers::util::estimate_flatbuffer_capacity; +use hyperlight_common::transport::{Buf, EncodedMessage, ExternalValues, MsgKind}; +use hyperlight_common::virtq::ReplyChain; use hyperlight_common::vmem::{self, PAGE_TABLE_SIZE}; #[cfg(crashdump)] use hyperlight_common::vmem::{BasicMapping, MappingKind}; @@ -23,7 +23,7 @@ use crate::mem::memory_region::MemoryRegion; #[cfg(crashdump)] use crate::mem::memory_region::{CrashDumpRegion, MemoryRegionFlags, MemoryRegionType}; use crate::sandbox::snapshot::{NextAction, Snapshot}; -use crate::{Result, new_error}; +use crate::{HyperlightError, Result, new_error}; #[cfg(crashdump)] fn mapping_kind_to_flags(kind: &MappingKind) -> (MemoryRegionFlags, MemoryRegionType) { @@ -147,6 +147,8 @@ pub(crate) struct SandboxMemoryManager { pub(crate) g2h_consumer: Option, /// H2G consumer bound to the current scratch mapping. pub(crate) h2g_consumer: Option, + /// Correlation ID assigned to the next guest-function call. + next_guest_cid: u32, } impl Clone for SandboxMemoryManager { @@ -161,6 +163,7 @@ impl Clone for SandboxMemoryManager { snapshot_count: self.snapshot_count, g2h_consumer: None, h2g_consumer: None, + next_guest_cid: self.next_guest_cid, } } } @@ -300,6 +303,7 @@ where snapshot_count: 0, g2h_consumer: None, h2g_consumer: None, + next_guest_cid: 1, } } @@ -353,6 +357,7 @@ impl SandboxMemoryManager { snapshot_count: self.snapshot_count, g2h_consumer: None, h2g_consumer: None, + next_guest_cid: self.next_guest_cid, }; let guest_mgr = SandboxMemoryManager { shared_mem: gshm, @@ -364,8 +369,14 @@ impl SandboxMemoryManager { snapshot_count: self.snapshot_count, g2h_consumer: None, h2g_consumer: None, + next_guest_cid: self.next_guest_cid, }; host_mgr.update_scratch_bookkeeping()?; + + if matches!(host_mgr.next_action, NextAction::Initialise(_)) { + host_mgr.create_virtq_consumers()?; + } + Ok((host_mgr, guest_mgr)) } } @@ -409,33 +420,23 @@ impl SandboxMemoryManager { ) } - /// Attach host consumers to a guest-produced initial transport image. - /// - /// Before guest initialization, the host publishes queue dimensions and the - /// transport arena GPA. The guest derives and initializes every fixed region - /// without consuming dynamic scratch. + /// Create host consumers before the guest initializes the transport. /// - /// This method runs after the initialization VM exit. It checks the - /// published arena against the host layout, derives bounded GVA views, - /// and validates each directional ring before exposing either consumer. - /// Fresh sandboxes and pre-initialization restores use this path. - pub(crate) fn attach_virtq(&mut self) -> Result<()> { + /// The consumers begin at cursor zero and observe descriptors published + /// during the first guest entry. + fn create_virtq_consumers(&mut self) -> Result<()> { if self.g2h_consumer.is_some() || self.h2g_consumer.is_some() { - return Err(new_error!("virtqueue consumers are already attached")); + return Err(new_error!("virtqueue consumers already exist")); } - let (g2h, h2g) = virtq::attach(&self.layout, &self.scratch_mem)?; + let (g2h, h2g) = virtq::create_consumers(&self.layout, &self.scratch_mem)?; self.g2h_consumer = Some(g2h); self.h2g_consumer = Some(h2g); Ok(()) } /// Restore a captured canonical transport image against this scratch mapping. - pub(crate) fn restore_virtq(&mut self, snapshot: Option<&virtq::VirtqSnapshot>) -> Result<()> { - let Some(snapshot) = snapshot else { - return Ok(()); - }; - + pub(crate) fn restore_virtq(&mut self, snapshot: &virtq::VirtqSnapshot) -> Result<()> { if self.g2h_consumer.is_some() || self.h2g_consumer.is_some() { return Err(new_error!("virtqueue consumers are already attached")); } @@ -446,95 +447,237 @@ impl SandboxMemoryManager { Ok(()) } - /// Reads a host function call from memory + /// Write a guest function call into the H2G virtqueue. #[instrument(err(Debug), skip_all, parent = Span::current(), level= "Trace")] - pub(crate) fn get_host_function_call(&mut self) -> Result { - self.scratch_mem - .try_pop_buffer_into::( - self.layout.get_output_data_buffer_scratch_host_offset(), - self.layout.output_data_size(), - ) - .map_err(From::from) - } + pub(crate) fn write_guest_function_call(&mut self, call: &FunctionCall) -> Result { + let cid = self.next_guest_cid; + let params = call.parameters.as_deref().unwrap_or_default(); + let cap = estimate_flatbuffer_capacity(&call.function_name, params); - /// Writes a host function call result to memory - #[instrument(err(Debug), skip_all, parent = Span::current(), level= "Trace")] - pub(crate) fn write_response_from_host_function_call( - &mut self, - res: &FunctionCallResult, - ) -> Result<()> { - let mut builder = FlatBufferBuilder::new(); - let data = res.encode(&mut builder); + let mut builder = FlatBufferBuilder::with_capacity(cap); + let mut externals = ExternalValues::new(); - self.scratch_mem - .push_buffer( - self.layout.get_input_data_buffer_scratch_host_offset(), - self.layout.input_data_size(), - data, - ) - .map_err(From::from) + let control = call.encode(&mut builder, &mut externals)?; + + let Some(msg) = EncodedMessage::new(MsgKind::Request, cid, control, externals) else { + return Err(new_error!("H2G request exceeds the wire payload limit")); + }; + + self.write_h2g_message(&msg)?; + + self.next_guest_cid = cid.wrapping_add(1); + if self.next_guest_cid == 0 { + self.next_guest_cid = 1; + } + + Ok(cid) } - /// Writes a guest function call to memory - #[instrument(err(Debug), skip_all, parent = Span::current(), level= "Trace")] - pub(crate) fn write_guest_function_call(&mut self, buffer: &[u8]) -> Result<()> { - validate_guest_function_call_buffer(buffer).map_err(|e| { - new_error!( - "Guest function call buffer validation failed: {}", - e.to_string() - ) - })?; - - self.scratch_mem.push_buffer( - self.layout.get_input_data_buffer_scratch_host_offset(), - self.layout.input_data_size(), - buffer, - )?; + fn write_h2g_message(&mut self, message: &EncodedMessage<'_>) -> Result<()> { + let Some(consumer) = self.h2g_consumer.as_mut() else { + return Err(new_error!("H2G consumer is not attached")); + }; + + let buffer_size = self.layout.get_h2g_buffer_size(); + let buffer_count = message.total_len().div_ceil(buffer_size); + + // External bytes may become owner-backed ByteChunks retained across + // calls. If they consume every posted H2G buffer, no buffer remains + // for a control call that releases them. External payloads therefore + // require one extra chain. poll_exact_with_spare checks the chain and + // leaves it available for the next call. + let spare_buffers = match message.header().msg_kind() { + Ok(MsgKind::SnapshotCheckpoint) => 0, + Ok(_) => usize::from(message.external_len() != 0), + Err(_) => unreachable!("validated upstream"), + }; + + // H2G receive buffers are writable-only, so any readable payload is malformed. + let maybe_buffers = consumer + .poll_exact_with_spare(buffer_count, spare_buffers, 0) + .map_err(|err| HyperlightError::TransportError(format!("H2G poll failed: {err}")))?; + + let Some(buffers) = maybe_buffers else { + return Err(new_error!( + "H2G capacity cannot provide {buffer_count} buffers with {spare_buffers} spare" + )); + }; + + // The message is a contiguous sequence of bytes, but the buffers are a chain of possibly + // non contiguous slices. Write the message into the buffers in order, advancing the message + // cursor as we go. + let mut message = message.as_buf(); + + for (recv, reply) in buffers { + let ReplyChain::Writable(mut buffer) = reply else { + return Err(HyperlightError::TransportError( + "H2G receive buffer is not writable".into(), + )); + }; + + if buffer.desc_count() != 1 || buffer.capacity() != buffer_size { + return Err(HyperlightError::TransportError( + "H2G receive buffer has an invalid shape".into(), + )); + } + + while message.has_remaining() && buffer.remaining() != 0 { + let written = buffer.write(message.chunk()).map_err(|err| { + HyperlightError::TransportError(format!("H2G write failed: {err}")) + })?; + + message.advance(written); + } + + consumer.complete(recv, buffer).map_err(|err| { + HyperlightError::TransportError(format!("H2G completion failed: {err}")) + })?; + } + + debug_assert!(!message.has_remaining()); Ok(()) } - /// Reads a function call result from memory. - /// A function call result can be either an error or a successful return value. + /// Read a guest function result from the G2H virtqueue. #[instrument(err(Debug), skip_all, parent = Span::current(), level= "Trace")] - pub(crate) fn get_guest_function_call_result(&mut self) -> Result { - self.scratch_mem - .try_pop_buffer_into::( - self.layout.get_output_data_buffer_scratch_host_offset(), - self.layout.output_data_size(), - ) - .map_err(From::from) - } + pub(crate) fn read_h2g_result_from_g2h(&mut self, cid: u32) -> Result { + let max_recv_len = self.layout.get_g2h_queue_dims().pool_len(); - /// Read guest log data from the `SharedMemory` contained within `self` - #[instrument(err(Debug), skip_all, parent = Span::current(), level= "Trace")] - pub(crate) fn read_guest_log_data(&mut self) -> Result { - self.scratch_mem - .try_pop_buffer_into::( - self.layout.get_output_data_buffer_scratch_host_offset(), - self.layout.output_data_size(), - ) - .map_err(From::from) - } + let Some(consumer) = self.g2h_consumer.as_mut() else { + return Err(HyperlightError::TransportError( + "G2H consumer is not attached".into(), + )); + }; - pub(crate) fn clear_io_buffers(&mut self) { - // Clear the output data buffer loop { - let Ok(_) = self.scratch_mem.try_pop_buffer_into::>( - self.layout.get_output_data_buffer_scratch_host_offset(), - self.layout.output_data_size(), - ) else { - break; + let maybe_next = consumer.poll(max_recv_len).map_err(|err| { + HyperlightError::TransportError(format!("G2H poll failed: {err}")) + })?; + + let Some((mut recv, reply)) = maybe_next else { + return Err(HyperlightError::TransportError( + "G2H has no guest function result after halt".into(), + )); }; + + let header = virtq::read_message_header(&mut recv).map_err(|err| { + HyperlightError::TransportError(format!("Failed to read G2H result header: {err}")) + })?; + + if !matches!(&reply, ReplyChain::Ack(_)) { + return Err(HyperlightError::TransportError( + "G2H result entry has writable buffers".into(), + )); + } + + match header.msg_kind() { + Ok(MsgKind::Log) => { + if header.cid != 0 { + return Err(HyperlightError::TransportError( + "G2H log has a correlation ID".into(), + )); + } + + let log = virtq::read_guest_log_data(&mut recv).map_err(|err| { + HyperlightError::TransportError(format!("Failed to read G2H log: {err}")) + })?; + + consumer.complete(recv, reply).map_err(|err| { + HyperlightError::TransportError(format!( + "Failed to complete G2H log: {err}" + )) + })?; + + crate::sandbox::outb::emit_guest_log(&log); + } + Ok(MsgKind::Response) => { + if header.cid != cid { + return Err(HyperlightError::TransportError( + "G2H guest function result correlation ID mismatch".into(), + )); + } + + let result = virtq::read_guest_function_call_result(&mut recv); + consumer.complete(recv, reply).map_err(|err| { + HyperlightError::TransportError(format!( + "Failed to complete G2H guest function result: {err}" + )) + })?; + + return result.map_err(|err| { + HyperlightError::TransportError(format!( + "Failed to decode G2H guest function result: {err}" + )) + }); + } + Ok(kind) => { + return Err(HyperlightError::TransportError(format!( + "Expected G2H guest function result, got {kind:?}" + ))); + } + Err(kind) => { + return Err(HyperlightError::TransportError(format!( + "Unknown G2H message kind {kind:#x}" + ))); + } + } } - // Clear the input data buffer - loop { - let Ok(_) = self.scratch_mem.try_pop_buffer_into::>( - self.layout.get_input_data_buffer_scratch_host_offset(), - self.layout.input_data_size(), - ) else { - break; - }; + } + + /// Publish an internal request for guest-side snapshot canonicalization. + /// + /// The pending marker distinguishes a completed checkpoint with no retained + /// buffers from a guest that halted without publishing mailbox status. + pub(crate) fn begin_snapshot_checkpoint(&mut self) -> Result<()> { + let offset = self.snapshot_mbx_offset()?; + self.scratch_mem.write(offset, u64::MAX.to_le_bytes())?; + + let message = EncodedMessage::new_snapshot_cp(); + self.write_h2g_message(&message) + } + + /// Reset host consumers and read the guest-side snapshot status. + /// + /// Consumer reset completes the canonical queue before the status is interpreted. + /// A retained-buffer rejection therefore leaves both queues usable. The current + /// status is only a retained slot count. + /// + /// TODO: This will change to allow the guest to publish a more detailed snapshot + /// status about what buffer ranges were retained so we can include them in the + /// snapshot. For now we simply error if the guest has retained any buffers. + pub(crate) fn finish_snapshot_checkpoint(&mut self) -> Result { + let Some(g2h) = self.g2h_consumer.as_mut() else { + return Err(new_error!("G2H consumer is not attached")); + }; + + let Some(h2g) = self.h2g_consumer.as_mut() else { + return Err(new_error!("H2G consumer is not attached")); + }; + + g2h.reset()?; + h2g.reset()?; + + let offset = self.snapshot_mbx_offset()?; + let guest_owned = u64::from_le_bytes(self.scratch_mem.read(offset)?); + + if guest_owned == u64::MAX { + return Err(HyperlightError::TransportError( + "Guest did not publish snapshot checkpoint status".to_string(), + )); } + + Ok(guest_owned) + } + + /// Get the offset of the snapshot mailbox in scratch memory. + fn snapshot_mbx_offset(&self) -> Result { + let arena = self.layout.get_transport_arena(); + Ok(usize::try_from( + arena + .mbx_addr() + .checked_sub(arena.base_addr()) + .ok_or_else(|| new_error!("Snapshot mailbox precedes transport arena"))?, + )?) } /// This function restores a memory snapshot from a given snapshot. @@ -545,8 +688,13 @@ impl SandboxMemoryManager { Option>, Option, )> { - if let Some(virtq) = snapshot.virtq() { + let virtq = snapshot.virtq(); + if let Some(virtq) = virtq { virtq.preflight(snapshot.layout())?; + } else if matches!(snapshot.next_action(), NextAction::Call(_)) { + return Err(new_error!( + "running snapshot has no canonical transport state" + )); } self.g2h_consumer = None; @@ -594,7 +742,11 @@ impl SandboxMemoryManager { self.original_entrypoint = snapshot.original_entrypoint(); self.update_scratch_bookkeeping()?; - self.restore_virtq(snapshot.virtq())?; + if let Some(virtq) = virtq { + self.restore_virtq(virtq)?; + } else if matches!(snapshot.next_action(), NextAction::Initialise(_)) { + self.create_virtq_consumers()?; + } Ok((gsnapshot, gscratch)) } @@ -640,7 +792,6 @@ impl SandboxMemoryManager { SCRATCH_TOP_SNAPSHOT_GENERATION_OFFSET, self.snapshot_count, )?; - // Record the G2H and H2G queue sizes, pool page counts, and buffer sizes. self.update_scratch_bookkeeping_item( SCRATCH_TOP_G2H_QUEUE_SIZE_OFFSET, @@ -673,17 +824,6 @@ impl SandboxMemoryManager { transport_arena.base_addr(), )?; - // Initialise the guest input and output data buffers in - // scratch memory. TODO: remove the need for this. - self.scratch_mem.write::( - self.layout.get_input_data_buffer_scratch_host_offset(), - SandboxMemoryLayout::STACK_POINTER_SIZE_BYTES, - )?; - self.scratch_mem.write::( - self.layout.get_output_data_buffer_scratch_host_offset(), - SandboxMemoryLayout::STACK_POINTER_SIZE_BYTES, - )?; - // Copy page tables from `shared_mem` into scratch. PT bytes // are appended to the snapshot blob at build time and live // just past the end of the guest-visible KVM slot (see @@ -882,17 +1022,211 @@ impl SandboxMemoryManager { } #[cfg(test)] -#[cfg(target_arch = "x86_64")] mod tests { + use hyperlight_common::flatbuffer_wrappers::function_call::FunctionCallType; + use hyperlight_common::flatbuffer_wrappers::function_types::{ParameterValue, ReturnType}; + use hyperlight_common::transport::{ + MsgHeader, SIZE_PREFIX_LEN, size_prefix_payload_len, size_prefixed_len, + }; + use hyperlight_common::virtq::DescFlags; + #[cfg(target_arch = "x86_64")] use hyperlight_testing::sandbox_sizes::{LARGE_HEAP_SIZE, MEDIUM_HEAP_SIZE, SMALL_HEAP_SIZE}; + #[cfg(target_arch = "x86_64")] use hyperlight_testing::simple_guest_as_pathbuf; + use super::*; + #[cfg(target_arch = "x86_64")] use crate::GuestBinary; + use crate::mem::virtq::tests::{H2G_BUFFER_SIZE, TestVirtq, memory_layout}; + #[cfg(target_arch = "x86_64")] use crate::sandbox::SandboxConfiguration; - use crate::sandbox::snapshot::Snapshot; - /// Build a Snapshot for the given configuration and verify the + fn manager(queue: &TestVirtq) -> SandboxMemoryManager { + #[cfg(not(unshared_snapshot_mem))] + let shared_mem = + ReadonlySharedMemory::from_bytes(&vec![0; vmem::PAGE_SIZE], vmem::PAGE_SIZE).unwrap(); + + #[cfg(unshared_snapshot_mem)] + let shared_mem = ExclusiveSharedMemory::new(vmem::PAGE_SIZE) + .unwrap() + .build() + .0; + + let mut mgr = SandboxMemoryManager::new( + memory_layout(), + shared_mem, + queue.scratch.clone(), + NextAction::None, + ); + + mgr.h2g_consumer = Some(queue.h2g_consumer()); + mgr + } + + fn h2g_call(bytes: usize) -> FunctionCall { + let params = (bytes != 0).then(|| vec![ParameterValue::VecBytes(vec![0xa5; bytes])]); + FunctionCall::new( + "call".to_string(), + params, + FunctionCallType::Guest, + ReturnType::Void, + ) + } + + #[test] + fn rejects_invalid_h2g_descriptors() { + for (len, expected) in [ + (H2G_BUFFER_SIZE as u32, "Payload data too large"), + (0, "not writable"), + ] { + let queue = TestVirtq::new(); + let mut mgr = manager(&queue); + let mut desc = queue.h2g_desc(0); + + desc.flags &= !DescFlags::WRITE.bits(); + desc.len = len; + queue.set_h2g_desc(0, desc); + + let error = mgr.write_guest_function_call(&h2g_call(0)).unwrap_err(); + + assert!(error.to_string().contains(expected), "{error:#}"); + assert!(error.is_poison_error()); + assert!(matches!(error, HyperlightError::TransportError(_))); + } + } + + #[test] + fn partial_h2g_write_is_fatal() { + let queue = TestVirtq::new(); + let mut mgr = manager(&queue); + let mut desc = queue.h2g_desc(1); + + desc.addr = queue.h2g_pool.end; + queue.set_h2g_desc(1, desc); + + let error = mgr + .write_guest_function_call(&h2g_call(H2G_BUFFER_SIZE + 1024)) + .unwrap_err(); + + assert!(error.to_string().contains("Memory write"), "{error:#}"); + assert!(error.is_poison_error()); + assert!(matches!(error, HyperlightError::TransportError(_))); + assert_eq!(mgr.h2g_consumer.as_ref().unwrap().used_cursor().head(), 1); + } + + #[test] + fn insufficient_h2g_capacity_rolls_back() { + let queue = TestVirtq::new(); + let mut mgr = manager(&queue); + let cursor = mgr.h2g_consumer.as_ref().unwrap().avail_cursor(); + + let error = mgr + .write_guest_function_call(&h2g_call(H2G_BUFFER_SIZE * 4)) + .unwrap_err(); + + assert!(error.to_string().contains("H2G capacity"), "{error:#}"); + assert!(!error.is_poison_error()); + assert_eq!(mgr.h2g_consumer.as_ref().unwrap().avail_cursor(), cursor); + assert_eq!(mgr.write_guest_function_call(&h2g_call(0)).unwrap(), 1); + } + + #[test] + fn missing_g2h_result_is_fatal() { + let queue = TestVirtq::new(); + let mut mgr = manager(&queue); + mgr.g2h_consumer = Some(queue.g2h_consumer()); + + let Err(error) = mgr.read_h2g_result_from_g2h(1) else { + panic!("expected missing G2H result"); + }; + + assert!( + error + .to_string() + .contains("G2H has no guest function result") + ); + assert!(error.is_poison_error()); + assert!(matches!(error, HyperlightError::TransportError(_))); + } + + #[test] + fn writes_dense_h2g_request_and_reserves_control_buffer() { + let queue = TestVirtq::new(); + let mut mgr = manager(&queue); + let external_len = H2G_BUFFER_SIZE * 2; + let buffers: Vec<_> = (0..4).map(|index| queue.h2g_desc(index).addr).collect(); + + let cid = mgr + .write_guest_function_call(&h2g_call(external_len)) + .unwrap(); + + let used = mgr.h2g_consumer.as_ref().unwrap().avail_cursor().head(); + let wire: Vec = (0..used) + .flat_map(|index| queue.h2g_buffer(index, buffers[index as usize])) + .collect(); + + let header = MsgHeader::from_bytes(&wire[..MsgHeader::SIZE]).unwrap(); + assert_eq!(header.msg_kind(), Ok(MsgKind::Request)); + assert_eq!(header.cid, cid); + assert_eq!(header.payload_len as usize, wire.len() - MsgHeader::SIZE); + + let control = + size_prefix_payload_len(&wire[MsgHeader::SIZE..MsgHeader::SIZE + SIZE_PREFIX_LEN]) + .unwrap(); + + let control_len = size_prefixed_len(control).unwrap(); + let external = &wire[MsgHeader::SIZE + control_len..]; + assert_eq!(external, vec![0xa5; external_len]); + + let cursor = mgr.h2g_consumer.as_ref().unwrap().avail_cursor(); + let error = mgr.write_guest_function_call(&h2g_call(1)).unwrap_err(); + + assert!(error.to_string().contains("H2G capacity"), "{error:#}"); + assert_eq!(mgr.h2g_consumer.as_ref().unwrap().avail_cursor(), cursor); + assert_eq!(mgr.write_guest_function_call(&h2g_call(0)).unwrap(), 2); + } + + #[test] + fn writes_header_only_snapshot_checkpoint() { + let queue = TestVirtq::new(); + let mut mgr = manager(&queue); + let buffer = queue.h2g_desc(0).addr; + + mgr.begin_snapshot_checkpoint().unwrap(); + + let wire = queue.h2g_buffer(0, buffer); + let header = MsgHeader::from_bytes(&wire).unwrap(); + + assert_eq!(wire.len(), MsgHeader::SIZE); + assert_eq!(header.msg_kind(), Ok(MsgKind::SnapshotCheckpoint)); + assert_eq!(header.cid, 0); + assert_eq!(header.payload_len, 0); + assert_eq!(mgr.next_guest_cid, 1); + + let mbx = mgr.snapshot_mbx_offset().unwrap(); + + assert_eq!( + mgr.scratch_mem.read::<[u8; 8]>(mbx).unwrap(), + u64::MAX.to_le_bytes() + ); + } + + #[test] + fn guest_cid_wraps_without_zero() { + let queue = TestVirtq::new(); + let mut mgr = manager(&queue); + mgr.next_guest_cid = u32::MAX; + + assert_eq!( + mgr.write_guest_function_call(&h2g_call(0)).unwrap(), + u32::MAX + ); + assert_eq!(mgr.write_guest_function_call(&h2g_call(0)).unwrap(), 1); + } + + /// Build a snapshot for the given configuration and verify the /// NULL page is not mapped in its page tables. + #[cfg(target_arch = "x86_64")] fn verify_page_tables(name: &str, config: SandboxConfiguration) { let path = simple_guest_as_pathbuf(); let snapshot = Snapshot::from_env(GuestBinary::FilePath(path), config) @@ -909,6 +1243,7 @@ mod tests { } #[test] + #[cfg(target_arch = "x86_64")] fn test_page_tables_for_various_configurations() { let test_cases: [(&str, SandboxConfiguration); 4] = [ ("default", { SandboxConfiguration::default() }), @@ -934,4 +1269,19 @@ mod tests { verify_page_tables(name, config); } } + + #[test] + #[cfg(target_arch = "x86_64")] + fn build_creates_virtq_consumers_before_initialization() { + let path = simple_guest_as_pathbuf(); + let snapshot = + Snapshot::from_env(GuestBinary::FilePath(path), SandboxConfiguration::default()) + .unwrap(); + + let mgr = SandboxMemoryManager::from_snapshot(&snapshot).unwrap(); + let (mgr, _) = mgr.build().unwrap(); + + assert!(mgr.g2h_consumer.is_some()); + assert!(mgr.h2g_consumer.is_some()); + } } diff --git a/src/hyperlight_host/src/mem/mod.rs b/src/hyperlight_host/src/mem/mod.rs index 693dc2af7a..1bea1781e8 100644 --- a/src/hyperlight_host/src/mem/mod.rs +++ b/src/hyperlight_host/src/mem/mod.rs @@ -27,5 +27,3 @@ pub mod shared_mem; pub(crate) mod shared_mem_tests; /// Host virtqueue attachment and validation. pub(crate) mod virtq; -#[allow(dead_code)] -pub(crate) mod virtq_mem; diff --git a/src/hyperlight_host/src/mem/shared_mem.rs b/src/hyperlight_host/src/mem/shared_mem.rs index 0d1eb01e54..b9e803ca8b 100644 --- a/src/hyperlight_host/src/mem/shared_mem.rs +++ b/src/hyperlight_host/src/mem/shared_mem.rs @@ -1,7 +1,6 @@ // SPDX-License-Identifier: Apache-2.0 // Copyright 2025 The Hyperlight Authors. -use std::any::type_name; use std::ffi::c_void; use std::io::Error; use std::mem::{align_of, size_of}; @@ -35,57 +34,6 @@ use crate::log_then_return; type Result = core::result::Result; -/// Whether a [`StackError`] was encountered whilst pushing or popping -/// from the guest stack -#[derive(Debug)] -pub enum StackOp { - /// The error was encountered while pushing to the guest stack - Push, - /// The error was encountered while popping from the guest stack - Pop, -} -/// An error related to the stack discipline of guest I/O -#[derive(Error, Debug)] -pub enum StackError { - /// The stack pointer for a stack entry was out-of-bounds for the - /// stack - #[error( - "Unable to {0:?} data from buffer: Stack pointer is out of bounds. Stack pointer: {1}, Buffer size: {2}" - )] - SpOob(StackOp, usize, usize), - - /// The back pointer for a stack entry was corrupt - #[error("Corrupt buffer back-pointer: element offset {0} is outside valid range [8, {1}].")] - CorruptBackPointer(usize, usize), - - /// A stack entry size prefix was too large for necessary - /// operations on it to remain in the range of a u32 - #[error("Corrupt buffer size prefix: value {0} overflows when adding 4-byte header.")] - OverflowingPrefix(u32), - - /// It was not possible to convert a stack entry size prefix into - /// a usize. This should be impossible on all currently supported - /// architectures, since usize is 64 bits on all of them. - #[error("Prefix too large: {0}")] - PrefixTooLarge(std::num::TryFromIntError), - - /// A stack entry size prefix is larger than its - /// logically-enclosing element - #[error( - "Corrupt buffer size prefix: flatbuffer claims {0} bytes but the element slot is only {1} bytes." - )] - CorruptPrefix(usize, usize), - - /// An error was encountered during a routine error conversion - /// that should have been infallible - #[error("pop_buffer_into: failed to convert buffer to {0}")] - ConvertError(String), - - /// There was not enough free space available on the stack for an - /// element to be pushed - #[error("Not enough space in buffer to push data. Required: {0}, Available: {1}")] - BufferFullError(usize, usize), -} /// This is just an alias for std::backtrace::Backtrace that we /// introduce to stop thiserror from using its backtrace /// functionality, which depends on nightly APIs. @@ -159,10 +107,6 @@ pub enum SharedMemoryError { #[error("Cannot take exclusive access to a ReadonlySharedMemory")] ReadonlySharedMemoryExclusiveRequest, - /// The stack discipline of guest I/O was violated in some way - #[error("{0}")] - Stack(#[from] StackError), - /// An error was encountered when trying to convert a slice of raw /// bytes into some logical data #[error("Error reading slice {0}")] @@ -1474,128 +1418,6 @@ impl HostSharedMemory { drop(guard); Ok(()) } - - /// Pushes the given data onto shared memory to the buffer at the given offset. - /// NOTE! buffer_start_offset must point to the beginning of the buffer - #[instrument(err(Debug), skip_all, parent = Span::current(), level= "Trace")] - pub fn push_buffer( - &mut self, - buffer_start_offset: usize, - buffer_size: usize, - data: &[u8], - ) -> Result<()> { - let stack_pointer_rel = self.read::(buffer_start_offset)? as usize; - - if stack_pointer_rel > buffer_size || stack_pointer_rel < 8 { - Err(StackError::SpOob( - StackOp::Push, - stack_pointer_rel, - buffer_size, - ))?; - } - - let size_required = data.len() + 8; - let size_available = buffer_size - stack_pointer_rel; - - if size_required > size_available { - Err(StackError::BufferFullError(size_required, size_available))?; - } - - // get absolute - let stack_pointer_abs = stack_pointer_rel + buffer_start_offset; - - // write the actual data to the top of stack - self.copy_from_slice(data, stack_pointer_abs)?; - - // write the offset to the newly written data, to the top of stack. - // this is used when popping the stack, to know how far back to jump - self.write::(stack_pointer_abs + data.len(), stack_pointer_rel as u64)?; - - // update stack pointer to point to the next free address - self.write::( - buffer_start_offset, - (stack_pointer_rel + data.len() + 8) as u64, - )?; - Ok(()) - } - - /// Pops the given given buffer into a `T` and returns it. - /// NOTE! the data must be a size-prefixed flatbuffer, and - /// buffer_start_offset must point to the beginning of the buffer - pub fn try_pop_buffer_into( - &mut self, - buffer_start_offset: usize, - buffer_size: usize, - ) -> Result - where - T: for<'b> TryFrom<&'b [u8]>, - { - // get the stackpointer - let stack_pointer_rel = self.read::(buffer_start_offset)? as usize; - - if stack_pointer_rel > buffer_size || stack_pointer_rel < 16 { - Err(StackError::SpOob( - StackOp::Pop, - stack_pointer_rel, - buffer_size, - ))?; - } - - // make it absolute - let last_element_offset_abs = stack_pointer_rel + buffer_start_offset; - - // go back 8 bytes to get offset to element on top of stack - let last_element_offset_rel: usize = - self.read::(last_element_offset_abs - 8)? as usize; - - // Validate element offset (guest-writable): must be in [8, stack_pointer_rel - 16] - // to leave room for the 8-byte back-pointer plus at least 8 bytes of element data - // (the minimum for a size-prefixed flatbuffer: 4-byte prefix + 4-byte root offset). - if last_element_offset_rel > stack_pointer_rel.saturating_sub(16) - || last_element_offset_rel < 8 - { - Err(StackError::CorruptBackPointer( - last_element_offset_rel, - stack_pointer_rel.saturating_sub(16), - ))?; - } - - // make it absolute - let last_element_offset_abs = last_element_offset_rel + buffer_start_offset; - - // Max bytes the element can span (excluding the 8-byte back-pointer). - let max_element_size = stack_pointer_rel - last_element_offset_rel - 8; - - // Get the size of the flatbuffer buffer from memory - let fb_buffer_size = { - let raw_prefix = self.read::(last_element_offset_abs)?; - // flatbuffer byte arrays are prefixed by 4 bytes indicating - // the remaining size; add 4 for the prefix itself. - let total = raw_prefix - .checked_add(4) - .ok_or(StackError::OverflowingPrefix(raw_prefix))?; - usize::try_from(total).map_err(StackError::PrefixTooLarge)? - }; - - if fb_buffer_size > max_element_size { - Err(StackError::CorruptPrefix(fb_buffer_size, max_element_size))?; - } - - let mut result_buffer = vec![0; fb_buffer_size]; - - self.copy_to_slice(&mut result_buffer, last_element_offset_abs)?; - let to_return = T::try_from(result_buffer.as_slice()) - .map_err(|_| StackError::ConvertError(type_name::().to_string()))?; - - // update the stack pointer to point to the element we just popped off since that is now free - self.write::(buffer_start_offset, last_element_offset_rel as u64)?; - - // zero out the memory we just popped off - let num_bytes_to_zero = stack_pointer_rel - last_element_offset_rel; - self.fill(0, last_element_offset_abs, num_bytes_to_zero)?; - - Ok(to_return) - } } impl HostSharedMemory { @@ -2533,192 +2355,6 @@ mod tests { } } - /// Bounds checking for `try_pop_buffer_into` against corrupt guest data. - mod try_pop_buffer_bounds { - use super::*; - - #[derive(Debug, PartialEq)] - struct RawBytes(Vec); - - impl TryFrom<&[u8]> for RawBytes { - type Error = String; - fn try_from(value: &[u8]) -> std::result::Result { - Ok(RawBytes(value.to_vec())) - } - } - - /// Create a buffer with stack pointer initialized to 8 (empty). - fn make_buffer(mem_size: usize) -> super::super::HostSharedMemory { - let eshm = ExclusiveSharedMemory::new(mem_size).unwrap(); - let (hshm, _) = eshm.build(); - hshm.write::(0, 8u64).unwrap(); - hshm - } - - #[test] - fn normal_push_pop_roundtrip() { - let mem_size = page_size::get(); - let mut hshm = make_buffer(mem_size); - - // Size-prefixed flatbuffer-like payload: [size: u32 LE][payload] - let payload = b"hello"; - let mut data = Vec::new(); - data.extend_from_slice(&(payload.len() as u32).to_le_bytes()); - data.extend_from_slice(payload); - - hshm.push_buffer(0, mem_size, &data).unwrap(); - let result: RawBytes = hshm.try_pop_buffer_into(0, mem_size).unwrap(); - assert_eq!(result.0, data); - } - - #[test] - fn malicious_flatbuffer_size_prefix() { - let mem_size = page_size::get(); - let mut hshm = make_buffer(mem_size); - - let payload = b"small"; - let mut data = Vec::new(); - data.extend_from_slice(&(payload.len() as u32).to_le_bytes()); - data.extend_from_slice(payload); - hshm.push_buffer(0, mem_size, &data).unwrap(); - - // Corrupt size prefix at element start (offset 8) to near u32::MAX. - hshm.write::(8, 0xFFFF_FFFBu32).unwrap(); // +4 = 0xFFFF_FFFF - - let result: Result = hshm.try_pop_buffer_into(0, mem_size); - let err_msg = format!("{}", result.unwrap_err()); - assert!( - err_msg.contains("Corrupt buffer size prefix: flatbuffer claims 4294967295 bytes but the element slot is only 9 bytes"), - "Unexpected error message: {}", - err_msg - ); - } - - #[test] - fn malicious_element_offset_too_small() { - let mem_size = page_size::get(); - let mut hshm = make_buffer(mem_size); - - let payload = b"test"; - let mut data = Vec::new(); - data.extend_from_slice(&(payload.len() as u32).to_le_bytes()); - data.extend_from_slice(payload); - hshm.push_buffer(0, mem_size, &data).unwrap(); - - // Corrupt back-pointer (offset 16) to 0 (before valid range). - hshm.write::(16, 0u64).unwrap(); - - let result: Result = hshm.try_pop_buffer_into(0, mem_size); - let err_msg = format!("{}", result.unwrap_err()); - assert!( - err_msg.contains( - "Corrupt buffer back-pointer: element offset 0 is outside valid range [8, 8]" - ), - "Unexpected error message: {}", - err_msg - ); - } - - #[test] - fn malicious_element_offset_past_stack_pointer() { - let mem_size = page_size::get(); - let mut hshm = make_buffer(mem_size); - - let payload = b"test"; - let mut data = Vec::new(); - data.extend_from_slice(&(payload.len() as u32).to_le_bytes()); - data.extend_from_slice(payload); - hshm.push_buffer(0, mem_size, &data).unwrap(); - - // Corrupt back-pointer (offset 16) to 9999 (past stack pointer 24). - hshm.write::(16, 9999u64).unwrap(); - - let result: Result = hshm.try_pop_buffer_into(0, mem_size); - let err_msg = format!("{}", result.unwrap_err()); - assert!( - err_msg.contains( - "Corrupt buffer back-pointer: element offset 9999 is outside valid range [8, 8]" - ), - "Unexpected error message: {}", - err_msg - ); - } - - #[test] - fn malicious_flatbuffer_size_off_by_one() { - let mem_size = page_size::get(); - let mut hshm = make_buffer(mem_size); - - let payload = b"abcd"; - let mut data = Vec::new(); - data.extend_from_slice(&(payload.len() as u32).to_le_bytes()); - data.extend_from_slice(payload); - hshm.push_buffer(0, mem_size, &data).unwrap(); - - // Corrupt size prefix: claim 5 bytes (total 9), exceeding the 8-byte slot. - hshm.write::(8, 5u32).unwrap(); // fb_buffer_size = 5 + 4 = 9 - - let result: Result = hshm.try_pop_buffer_into(0, mem_size); - let err_msg = format!("{}", result.unwrap_err()); - assert!( - err_msg.contains("Corrupt buffer size prefix: flatbuffer claims 9 bytes but the element slot is only 8 bytes"), - "Unexpected error message: {}", - err_msg - ); - } - - /// Back-pointer just below stack_pointer causes underflow in - /// `stack_pointer_rel - last_element_offset_rel - 8`. - #[test] - fn back_pointer_near_stack_pointer_underflow() { - let mem_size = page_size::get(); - let mut hshm = make_buffer(mem_size); - - let payload = b"test"; - let mut data = Vec::new(); - data.extend_from_slice(&(payload.len() as u32).to_le_bytes()); - data.extend_from_slice(payload); - hshm.push_buffer(0, mem_size, &data).unwrap(); - - // stack_pointer_rel = 24. Set back-pointer to 23 (> 24 - 16 = 8, so rejected). - hshm.write::(16, 23u64).unwrap(); - - let result: Result = hshm.try_pop_buffer_into(0, mem_size); - let err_msg = format!("{}", result.unwrap_err()); - assert!( - err_msg.contains( - "Corrupt buffer back-pointer: element offset 23 is outside valid range [8, 8]" - ), - "Unexpected error message: {}", - err_msg - ); - } - - /// Size prefix of 0xFFFF_FFFD causes u32 overflow: 0xFFFF_FFFD + 4 wraps. - #[test] - fn size_prefix_u32_overflow() { - let mem_size = page_size::get(); - let mut hshm = make_buffer(mem_size); - - let payload = b"test"; - let mut data = Vec::new(); - data.extend_from_slice(&(payload.len() as u32).to_le_bytes()); - data.extend_from_slice(payload); - hshm.push_buffer(0, mem_size, &data).unwrap(); - - // Write 0xFFFF_FFFD as size prefix: checked_add(4) returns None. - hshm.write::(8, 0xFFFF_FFFDu32).unwrap(); - - let result: Result = hshm.try_pop_buffer_into(0, mem_size); - let err_msg = format!("{}", result.unwrap_err()); - assert!( - err_msg.contains("Corrupt buffer size prefix: value 4294967293 overflows when adding 4-byte header"), - "Unexpected error message: {}", - err_msg - ); - } - } - #[cfg(target_os = "linux")] mod guard_page_crash_test { use crate::mem::shared_mem::{ExclusiveSharedMemory, SharedMemory}; diff --git a/src/hyperlight_host/src/mem/virtq.rs b/src/hyperlight_host/src/mem/virtq.rs deleted file mode 100644 index 2d46a41a0b..0000000000 --- a/src/hyperlight_host/src/mem/virtq.rs +++ /dev/null @@ -1,735 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -// Copyright 2026 The Hyperlight Authors. - -//! Host virtqueue attachment. -//! -//! The host publishes one transport arena address in scratch-top metadata. Guest -//! initialization builds both queues in those fixed regions. This module -//! validates the complete initial image before returning either consumer. - -use core::ops::Range; - -use hyperlight_common::layout::{QueueDims, TransportArena}; -use hyperlight_common::virtq::canonical::validate_canon_image; -use hyperlight_common::virtq::{ - Layout as VirtqLayout, MemOps, Notifier, QueueStats, VirtqConsumer, -}; - -use super::layout::{BaseGpaRegion, SandboxMemoryLayout}; -use super::shared_mem::{HostSharedMemory, SharedMemory}; -use super::virtq_mem::{HostMemOps, ImageMem}; -use crate::{Result, new_error}; - -/// Host-side G2H virtqueue consumer. -pub(crate) type G2hConsumer = VirtqConsumer; -/// Host-side H2G virtqueue consumer. -pub(crate) type H2gConsumer = VirtqConsumer; - -/// No-op notifier for polled host transport. -#[derive(Clone, Copy)] -pub(crate) struct HostNotifier; - -impl Notifier for HostNotifier { - fn notify(&self, _stats: QueueStats) {} -} - -/// Build both host consumers from a guest-produced initial transport image. -/// -/// The consumers are returned only after the host-assigned arena and both -/// directional ring images have passed validation. -pub(crate) fn attach( - layout: &SandboxMemoryLayout, - scratch_mem: &HostSharedMemory, -) -> Result<(G2hConsumer, H2gConsumer)> { - let validator = Validator::new(layout)?; - let arena_gpa = read_published_arena_gpa(scratch_mem)?; - let regions = validator.validate_published_arena(arena_gpa)?; - - let g2h_ring_mem = HostMemOps::new(scratch_mem, regions.g2h_ring.clone())?; - let g2h_pool_mem = HostMemOps::new(scratch_mem, regions.g2h_pool)?; - let g2h_layout = validator.validate_g2h(&g2h_ring_mem, regions.g2h_ring)?; - - let h2g_ring_mem = HostMemOps::new(scratch_mem, regions.h2g_ring.clone())?; - let h2g_pool_mem = HostMemOps::new(scratch_mem, regions.h2g_pool.clone())?; - let h2g_layout = validator.validate_h2g(&h2g_ring_mem, regions.h2g_ring, regions.h2g_pool)?; - - Ok(( - VirtqConsumer::new_split(g2h_layout, g2h_ring_mem, g2h_pool_mem, HostNotifier), - VirtqConsumer::new_split(h2g_layout, h2g_ring_mem, h2g_pool_mem, HostNotifier), - )) -} - -/// Capture the canonical transport state omitted from the main memory snapshot. -pub(crate) fn snapshot( - layout: &SandboxMemoryLayout, - scratch_mem: &HostSharedMemory, -) -> Result { - let validator = Validator::new(layout)?; - - let arena_gpa = read_published_arena_gpa(scratch_mem)?; - let regions = validator.validate_published_arena(arena_gpa)?; - - let g2h_mem = HostMemOps::new(scratch_mem, regions.g2h_ring.clone())?; - validator.validate_g2h(&g2h_mem, regions.g2h_ring.clone())?; - - let h2g_mem = HostMemOps::new(scratch_mem, regions.h2g_ring.clone())?; - validator.validate_h2g(&h2g_mem, regions.h2g_ring.clone(), regions.h2g_pool.clone())?; - - // The vCPU is stopped, so the ring images and snapshotted guest producer - // bookkeeping describe the same instant. - Ok(VirtqSnapshot { - scratch_size: layout.get_scratch_size(), - g2h_ring: read_ring(scratch_mem, regions.g2h_ring)?, - h2g_ring: read_ring(scratch_mem, regions.h2g_ring)?, - }) -} - -/// Restore one captured canonical transport image and return fresh consumers. -pub(crate) fn restore( - layout: &SandboxMemoryLayout, - scratch_mem: &HostSharedMemory, - snapshot: &VirtqSnapshot, -) -> Result<(G2hConsumer, H2gConsumer)> { - let regions = Validator::new(layout)?.validate_snapshot(snapshot)?; - - write_published_arena_gpa(scratch_mem, layout.get_transport_arena().base_addr())?; - write_ring(scratch_mem, regions.g2h_ring, &snapshot.g2h_ring)?; - write_ring(scratch_mem, regions.h2g_ring, &snapshot.h2g_ring)?; - attach(layout, scratch_mem) -} - -/// Bounded GVA regions derived from validated transport GPAs. -struct GvaRegions { - g2h_ring: Range, - h2g_ring: Range, - g2h_pool: Range, - h2g_pool: Range, -} - -#[derive(Clone, Copy)] -struct QueueConfig { - /// Address-independent queue dimensions. - dims: QueueDims, - /// Size of the ring image in bytes including event suppressions. - ring_len: usize, - /// Size of the buffer pool in bytes. - pool_len: usize, - /// Size of each buffer in the pool in bytes. - buffer_size: usize, -} - -impl QueueConfig { - fn new(dims: QueueDims, buffer_size: usize) -> Result { - let ring_len = dims - .checked_ring_len() - .ok_or_else(|| new_error!("ring size overflow"))?; - let pool_len = dims - .checked_pool_len() - .ok_or_else(|| new_error!("pool size overflow"))?; - - if buffer_size == 0 { - return Err(new_error!("buffer size is zero")); - } - - Ok(Self { - dims, - ring_len, - pool_len, - buffer_size, - }) - } -} - -/// Host-owned transport dimensions. -#[derive(Clone, Copy)] -struct Config { - /// Host-requested G2H configuration. - g2h: QueueConfig, - /// Host-requested H2G configuration. - h2g: QueueConfig, - /// Fixed host-assigned transport arena. - arena: TransportArena, - /// Number of one-descriptor chains posted before the H2G ring or pool fills. - h2g_prefill_chains: usize, -} - -impl Config { - /// Compute the host transport configuration from the memory layout. - fn from_layout(layout: &SandboxMemoryLayout) -> Result { - let g2h = QueueConfig::new(layout.get_g2h_queue_dims(), layout.get_g2h_buffer_size())?; - - let h2g = QueueConfig::new(layout.get_h2g_queue_dims(), layout.get_h2g_buffer_size())?; - - let h2g_prefill_chains = - usize::from(h2g.dims.size().get()).min(h2g.pool_len / h2g.buffer_size); - let arena = layout.get_transport_arena(); - - Ok(Self { - g2h, - h2g, - arena, - h2g_prefill_chains, - }) - } -} - -/// Canonical in-memory transport state excluded from ordinary snapshot pages. -#[derive(Debug, PartialEq, Eq)] -pub(crate) struct VirtqSnapshot { - scratch_size: usize, - g2h_ring: Vec, - h2g_ring: Vec, -} - -impl VirtqSnapshot { - /// Validate every captured field before mutating restored scratch. - pub(crate) fn preflight(&self, layout: &SandboxMemoryLayout) -> Result<()> { - Validator::new(layout)?.validate_snapshot(self).map(|_| ()) - } -} - -/// Validates live and captured transport images against one host layout. -struct Validator<'a> { - config: Config, - layout: &'a SandboxMemoryLayout, -} - -impl<'a> Validator<'a> { - fn new(layout: &'a SandboxMemoryLayout) -> Result { - Ok(Self { - config: Config::from_layout(layout)?, - layout, - }) - } - - /// Validate the initial G2H queue and return its layout. - fn validate_g2h(&self, mem: &M, ring: Range) -> Result { - // SAFETY: `ring` spans the configured image and `mem` keeps that image - // valid for the duration of validation. - let layout = unsafe { VirtqLayout::from_base(ring.start, self.config.g2h.dims.size()) } - .map_err(|error| new_error!("invalid G2H ring layout: {error}"))?; - - validate_canon_image(mem, layout, 0, |_, _| false) - .map_err(|error| new_error!("invalid canonical G2H image: {error}"))?; - - Ok(layout) - } - - /// Validate the initial H2G queue and return its layout. - /// - /// Every available chain contains one configured size writable descriptor. - /// Descriptors must name distinct, slot-aligned ranges inside the H2G pool. - fn validate_h2g( - &self, - mem: &M, - ring: Range, - pool: Range, - ) -> Result { - // SAFETY: `ring` spans the configured image and `mem` keeps that image - // valid for the duration of validation. - let layout = unsafe { VirtqLayout::from_base(ring.start, self.config.h2g.dims.size()) } - .map_err(|error| new_error!("invalid H2G ring layout: {error}"))?; - - let bufsz = self.config.h2g.buffer_size; - let prefill = self.config.h2g_prefill_chains; - - if prefill == 0 { - return Err(new_error!("H2G pool has no complete buffers")); - } - - // Record the accepted descriptor ranges to detect overlaps. - let mut accepted: Vec> = Vec::with_capacity(prefill); - - let image = validate_canon_image(mem, layout, prefill, |_, elem| { - let Ok(bufsz_u64) = u64::try_from(bufsz) else { - return false; - }; - - // all descriptors must be writable and match the configured buffer size - if !elem.writable || usize::try_from(elem.len).ok() != Some(bufsz) { - return false; - } - - let Some(offset) = elem.addr.checked_sub(pool.start) else { - return false; - }; - let Some(end) = elem.addr.checked_add(u64::from(elem.len)) else { - return false; - }; - - // all descriptors must be slot-aligned and remain inside the pool - if !offset.is_multiple_of(bufsz_u64) || end > pool.end { - return false; - } - - let buf = elem.addr..end; - - // all descriptors must name distinct ranges - if accepted - .iter() - .any(|other| buf.start < other.end && other.start < buf.end) - { - return false; - } - - accepted.push(buf); - true - }) - .map_err(|error| new_error!("invalid canonical H2G image: {error}"))?; - - // compare the number of accepted chains to the expected prefill count - if image.len() != prefill { - return Err(new_error!("invalid initial H2G chains")); - } - - Ok(layout) - } - - /// Validate the published arena and return its GVA regions. - fn validate_published_arena(&self, arena_gpa: u64) -> Result { - if arena_gpa != self.config.arena.base_addr() { - return Err(new_error!("published transport arena is invalid")); - } - - self.resolve_gva_regions() - } - - fn validate_snapshot(&self, snapshot: &VirtqSnapshot) -> Result { - if snapshot.scratch_size != self.layout.get_scratch_size() { - return Err(new_error!( - "virtqueue snapshot scratch size {} does not match layout size {}", - snapshot.scratch_size, - self.layout.get_scratch_size() - )); - } - - let regions = self.resolve_gva_regions()?; - validate_ring_len("G2H", &snapshot.g2h_ring, self.config.g2h.ring_len)?; - validate_ring_len("H2G", &snapshot.h2g_ring, self.config.h2g.ring_len)?; - - let g2h_mem = ImageMem::new(regions.g2h_ring.start, &snapshot.g2h_ring); - self.validate_g2h(&g2h_mem, regions.g2h_ring.clone())?; - - let h2g_mem = ImageMem::new(regions.h2g_ring.start, &snapshot.h2g_ring); - self.validate_h2g(&h2g_mem, regions.h2g_ring.clone(), regions.h2g_pool.clone())?; - - Ok(regions) - } - - /// Translate validated transport GPAs into the GVA ranges used by descriptors. - fn resolve_gva_regions(&self) -> Result { - let to_gva = |gpa| { - let resolved = self - .layout - .resolve_gpa(gpa, &[]) - .ok_or_else(|| new_error!("GPA {gpa:#x} is outside scratch"))?; - - if !matches!(resolved.base, BaseGpaRegion::Scratch(())) { - return Err(new_error!("GPA {gpa:#x} is outside scratch")); - } - - hyperlight_common::layout::scratch_base_gva(self.layout.get_scratch_size()) - .checked_add(u64::try_from(resolved.offset)?) - .ok_or_else(|| new_error!("GPA {gpa:#x} to GVA translation overflow")) - }; - - let ( - g2h_ring_addr, - h2g_ring_addr, - g2h_pool_addr, - h2g_pool_addr, - g2h_ring_len, - h2g_ring_len, - g2h_pool_len, - h2g_pool_len, - ) = ( - self.config.arena.g2h_ring_addr(), - self.config.arena.h2g_ring_addr(), - self.config.arena.g2h_pool_addr(), - self.config.arena.h2g_pool_addr(), - self.config.g2h.ring_len, - self.config.h2g.ring_len, - self.config.g2h.pool_len, - self.config.h2g.pool_len, - ); - - Ok(GvaRegions { - g2h_ring: checked_region(to_gva(g2h_ring_addr)?, g2h_ring_len, "G2H ring")?, - h2g_ring: checked_region(to_gva(h2g_ring_addr)?, h2g_ring_len, "H2G ring")?, - g2h_pool: checked_region(to_gva(g2h_pool_addr)?, g2h_pool_len, "G2H pool")?, - h2g_pool: checked_region(to_gva(h2g_pool_addr)?, h2g_pool_len, "H2G pool")?, - }) - } -} - -/// Read the transport arena GPA from scratch-top metadata. -fn read_published_arena_gpa(scratch_mem: &HostSharedMemory) -> Result { - let offset = hyperlight_common::layout::SCRATCH_TOP_TRANSPORT_ARENA_GPA_OFFSET as usize; - Ok(scratch_mem.read::(scratch_mem.mem_size() - offset)?) -} - -fn write_published_arena_gpa(scratch_mem: &HostSharedMemory, arena_gpa: u64) -> Result<()> { - let offset = hyperlight_common::layout::SCRATCH_TOP_TRANSPORT_ARENA_GPA_OFFSET as usize; - Ok(scratch_mem.write::(scratch_mem.mem_size() - offset, arena_gpa)?) -} - -fn read_ring(scratch_mem: &HostSharedMemory, ring: Range) -> Result> { - let len = usize::try_from( - ring.end - .checked_sub(ring.start) - .ok_or_else(|| new_error!("invalid ring range"))?, - )?; - - let mem = HostMemOps::new(scratch_mem, ring.clone())?; - let mut bytes = vec![0; len]; - mem.read(ring.start, &mut bytes)?; - - Ok(bytes) -} - -fn write_ring(scratch_mem: &HostSharedMemory, ring: Range, bytes: &[u8]) -> Result<()> { - validate_ring_len("restored", bytes, usize::try_from(ring.end - ring.start)?)?; - let mem = HostMemOps::new(scratch_mem, ring.clone())?; - mem.write(ring.start, bytes) -} - -fn validate_ring_len(direction: &str, bytes: &[u8], expected: usize) -> Result<()> { - if bytes.len() != expected { - return Err(new_error!( - "{direction} snapshot ring length {} and expected length {expected}", - bytes.len() - )); - } - Ok(()) -} - -fn checked_region(start: u64, len: usize, tag: &str) -> Result> { - let end = start - .checked_add(u64::try_from(len)?) - .ok_or_else(|| new_error!("{tag} GVA range overflow"))?; - - Ok(start..end) -} - -#[cfg(test)] -mod tests { - use core::num::NonZeroU16; - - use hyperlight_common::virtq::{ - DescFlags, Descriptor, MemOps, SlotLayout, SlotPool, VirtqProducer, - }; - use hyperlight_common::vmem; - - use super::*; - use crate::mem::shared_mem::ExclusiveSharedMemory; - use crate::sandbox::SandboxConfiguration; - - const SCRATCH_SIZE: usize = 0x20_000; - const G2H_DEPTH: u16 = 16; - const H2G_DEPTH: u16 = 8; - const G2H_POOL_PAGES: usize = 3; - const H2G_POOL_PAGES: usize = 2; - const H2G_BUFFER_SIZE: usize = 3000; - - fn memory_layout() -> SandboxMemoryLayout { - let mut config = SandboxConfiguration::default(); - config.set_scratch_size(SCRATCH_SIZE); - config.set_g2h_queue_size(G2H_DEPTH as usize); - config.set_h2g_queue_size(H2G_DEPTH as usize); - config.set_h2g_buffer_size(H2G_BUFFER_SIZE); - config.set_g2h_pool_pages(G2H_POOL_PAGES); - config.set_h2g_pool_pages(H2G_POOL_PAGES); - SandboxMemoryLayout::new(config, 4096, 0, None).unwrap() - } - - fn attach_config() -> Config { - Config::from_layout(&memory_layout()).unwrap() - } - - fn host_scratch() -> HostSharedMemory { - let scratch = ExclusiveSharedMemory::new(SCRATCH_SIZE).unwrap(); - scratch.build().0 - } - - fn validate_published(arena_gpa: u64, config: Config) -> Result { - let layout = memory_layout(); - Validator { - config, - layout: &layout, - } - .validate_published_arena(arena_gpa) - } - - struct PreparedVirtq { - scratch: HostSharedMemory, - g2h_mem: HostMemOps, - h2g_mem: HostMemOps, - g2h_ring: Range, - h2g_ring: Range, - g2h_pool: Range, - h2g_pool: Range, - g2h_layout: VirtqLayout, - h2g_layout: VirtqLayout, - } - - fn prepared_virtq() -> PreparedVirtq { - let scratch = ExclusiveSharedMemory::new(SCRATCH_SIZE).unwrap(); - let (scratch, _) = scratch.build(); - - let layout = memory_layout(); - let config = Config::from_layout(&layout).unwrap(); - let scratch_base_gpa = hyperlight_common::layout::scratch_base_gpa(SCRATCH_SIZE); - let scratch_base_gva = hyperlight_common::layout::scratch_base_gva(SCRATCH_SIZE); - let to_gva = |gpa| scratch_base_gva + (gpa - scratch_base_gpa); - - let ring_base = to_gva(config.arena.g2h_ring_addr()); - let h2g_base = to_gva(config.arena.h2g_ring_addr()); - let g2h_pool_base = to_gva(config.arena.g2h_pool_addr()); - let g2h_pool_end = g2h_pool_base + (G2H_POOL_PAGES * vmem::PAGE_SIZE) as u64; - let h2g_pool_base = to_gva(config.arena.h2g_pool_addr()); - let h2g_pool_end = h2g_pool_base + (H2G_POOL_PAGES * vmem::PAGE_SIZE) as u64; - - // SAFETY: The scratch mapping covers both ring layouts. - let g2h_layout = unsafe { - VirtqLayout::from_base(ring_base, NonZeroU16::new(G2H_DEPTH).unwrap()).unwrap() - }; - // SAFETY: The scratch mapping covers both ring layouts. - let h2g_layout = unsafe { - VirtqLayout::from_base(h2g_base, NonZeroU16::new(H2G_DEPTH).unwrap()).unwrap() - }; - - let mem = HostMemOps::new(&scratch, ring_base..h2g_pool_end).unwrap(); - let h2g_prefill_chains = (H2G_POOL_PAGES * vmem::PAGE_SIZE) / H2G_BUFFER_SIZE; - - let h2g_pool = SlotPool::new(SlotLayout::new( - h2g_pool_base, - H2G_BUFFER_SIZE, - h2g_prefill_chains, - )) - .unwrap(); - - let mut h2g = VirtqProducer::new(h2g_layout, mem, HostNotifier, h2g_pool.clone()); - let mut batch = h2g.batch(); - - for _ in 0..h2g_pool.num_free() { - let chain = batch.chain().writable(H2G_BUFFER_SIZE).build().unwrap(); - batch.submit(chain).unwrap(); - } - - batch.finish().unwrap(); - write_published_arena_gpa(&scratch, config.arena.base_addr()).unwrap(); - - let g2h_ring = ring_base..ring_base + VirtqLayout::query_size(G2H_DEPTH as usize) as u64; - let h2g_ring = h2g_base..h2g_base + VirtqLayout::query_size(H2G_DEPTH as usize) as u64; - let g2h_pool = g2h_pool_base..g2h_pool_end; - let h2g_pool = h2g_pool_base..h2g_pool_end; - let g2h_mem = HostMemOps::new(&scratch, g2h_ring.clone()).unwrap(); - let h2g_mem = HostMemOps::new(&scratch, h2g_ring.clone()).unwrap(); - - PreparedVirtq { - scratch, - g2h_mem, - h2g_mem, - g2h_ring, - h2g_ring, - g2h_pool, - h2g_pool, - g2h_layout, - h2g_layout, - } - } - - fn validate(prepared: &PreparedVirtq) -> Result<()> { - let layout = memory_layout(); - let validator = Validator::new(&layout)?; - - validator.validate_g2h(&prepared.g2h_mem, prepared.g2h_ring.clone())?; - validator.validate_h2g( - &prepared.h2g_mem, - prepared.h2g_ring.clone(), - prepared.h2g_pool.clone(), - )?; - Ok(()) - } - - fn read_desc(mem: &HostMemOps, layout: VirtqLayout, index: u16) -> Descriptor { - mem.read_val(layout.desc_table_addr() + u64::from(index) * Descriptor::SIZE as u64) - .unwrap() - } - - fn write_desc(mem: &HostMemOps, layout: VirtqLayout, index: u16, desc: Descriptor) { - mem.write_val( - layout.desc_table_addr() + u64::from(index) * Descriptor::SIZE as u64, - desc, - ) - .unwrap(); - } - - #[test] - fn validates_host_placed_regions() { - let config = attach_config(); - let regions = validate_published(config.arena.base_addr(), config).unwrap(); - - assert_eq!( - regions.g2h_ring.end - regions.g2h_ring.start, - config.g2h.ring_len as u64 - ); - assert_eq!( - regions.h2g_ring.end - regions.h2g_ring.start, - config.h2g.ring_len as u64 - ); - assert_eq!( - regions.g2h_pool.end - regions.g2h_pool.start, - config.g2h.pool_len as u64 - ); - assert_eq!( - regions.h2g_pool.end - regions.h2g_pool.start, - config.h2g.pool_len as u64 - ); - } - - #[test] - fn rejects_invalid_published_regions() { - let config = attach_config(); - let arena_gpa = config.arena.base_addr() + 1; - assert!(validate_published(arena_gpa, config).is_err()); - } - - #[test] - fn rejects_published_region_overflow() { - let config = attach_config(); - assert!(validate_published(u64::MAX, config).is_err()); - } - - #[test] - fn rejects_untranslatable_or_overflowing_gva_regions() { - let config = attach_config(); - let arena_gpa = config.arena.base_addr(); - let invalid = hyperlight_common::layout::scratch_base_gpa(SCRATCH_SIZE) - 1; - assert!(validate_published(invalid, config).is_err()); - - let mut config = config; - config.g2h.ring_len = usize::MAX; - assert!(validate_published(arena_gpa, config).is_err()); - } - - #[test] - fn validates_initial_virtq_images() { - validate(&prepared_virtq()).unwrap(); - } - - #[test] - fn snapshots_and_restores_canonical_image() { - let prepared = prepared_virtq(); - let layout = memory_layout(); - let stale_pool = [0xa5; 16]; - let pool_mem = HostMemOps::new(&prepared.scratch, prepared.h2g_pool.clone()).unwrap(); - pool_mem - .write(prepared.h2g_pool.start, &stale_pool) - .unwrap(); - - let captured = snapshot(&layout, &prepared.scratch).unwrap(); - let restored = host_scratch(); - let allocator = layout.get_first_free_scratch_gpa(); - let allocator_offset = - restored.mem_size() - hyperlight_common::layout::SCRATCH_TOP_ALLOCATOR_OFFSET as usize; - restored.write::(allocator_offset, allocator).unwrap(); - - restore(&layout, &restored, &captured).unwrap(); - let restored_snapshot = snapshot(&layout, &restored).unwrap(); - let restored_pool = HostMemOps::new(&restored, prepared.h2g_pool.clone()).unwrap(); - let mut pool_bytes = [0; 16]; - restored_pool - .read(prepared.h2g_pool.start, &mut pool_bytes) - .unwrap(); - - assert_eq!(restored_snapshot, captured); - assert_eq!(restored.read::(allocator_offset).unwrap(), allocator); - assert_eq!(pool_bytes, [0; 16]); - } - - #[test] - fn rejects_corrupt_snapshot_ring_before_restore() { - let prepared = prepared_virtq(); - let layout = memory_layout(); - let mut snapshot = snapshot(&layout, &prepared.scratch).unwrap(); - snapshot.h2g_ring.fill(0); - let restored = host_scratch(); - - assert!(restore(&layout, &restored, &snapshot).is_err()); - assert_eq!(read_published_arena_gpa(&restored).unwrap(), 0); - } - - #[test] - fn restores_with_grown_page_tables() { - let prepared = prepared_virtq(); - let layout = memory_layout(); - let snapshot = snapshot(&layout, &prepared.scratch).unwrap(); - let mut grown_layout = layout; - grown_layout - .set_pt_size(layout.get_pt_size() + vmem::PAGE_SIZE) - .unwrap(); - let restored = host_scratch(); - - restore(&grown_layout, &restored, &snapshot).unwrap(); - assert_eq!( - read_published_arena_gpa(&restored).unwrap(), - grown_layout.get_transport_arena().base_addr() - ); - } - - #[test] - fn rejects_h2g_descriptors_outside_pool() { - let prepared = prepared_virtq(); - let mut desc = read_desc(&prepared.h2g_mem, prepared.h2g_layout, 0); - desc.addr = prepared.g2h_pool.start; - write_desc(&prepared.h2g_mem, prepared.h2g_layout, 0, desc); - assert!(validate(&prepared).is_err()); - } - - #[test] - fn rejects_nonzero_g2h_descriptors() { - let prepared = prepared_virtq(); - let mut desc = read_desc(&prepared.g2h_mem, prepared.g2h_layout, 0); - desc.addr = prepared.g2h_pool.start; - write_desc(&prepared.g2h_mem, prepared.g2h_layout, 0, desc); - assert!(validate(&prepared).is_err()); - } - - #[test] - fn rejects_readable_h2g_descriptor() { - let prepared = prepared_virtq(); - let mut desc = read_desc(&prepared.h2g_mem, prepared.h2g_layout, 0); - desc.flags &= !DescFlags::WRITE.bits(); - write_desc(&prepared.h2g_mem, prepared.h2g_layout, 0, desc); - assert!(validate(&prepared).is_err()); - } - - #[test] - fn rejects_invalid_h2g_size() { - let prepared = prepared_virtq(); - let mut desc = read_desc(&prepared.h2g_mem, prepared.h2g_layout, 0); - desc.len -= 1; - write_desc(&prepared.h2g_mem, prepared.h2g_layout, 0, desc); - assert!(validate(&prepared).is_err()); - } - - #[test] - fn rejects_misaligned_h2g_descriptor() { - let prepared = prepared_virtq(); - let mut desc = read_desc(&prepared.h2g_mem, prepared.h2g_layout, 0); - desc.addr += 1; - write_desc(&prepared.h2g_mem, prepared.h2g_layout, 0, desc); - assert!(validate(&prepared).is_err()); - } - - #[test] - fn rejects_overlapping_h2g_descriptors() { - let prepared = prepared_virtq(); - let first = read_desc(&prepared.h2g_mem, prepared.h2g_layout, 0); - let mut second = read_desc(&prepared.h2g_mem, prepared.h2g_layout, 1); - second.addr = first.addr; - write_desc(&prepared.h2g_mem, prepared.h2g_layout, 1, second); - assert!(validate(&prepared).is_err()); - } -} diff --git a/src/hyperlight_host/src/mem/virtq/codec.rs b/src/hyperlight_host/src/mem/virtq/codec.rs new file mode 100644 index 0000000000..84300dc2f8 --- /dev/null +++ b/src/hyperlight_host/src/mem/virtq/codec.rs @@ -0,0 +1,184 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 The Hyperlight Authors. + +//! Host RPC encoding and decoding over virtqueue chains. + +use anyhow::{Context, bail}; +use flatbuffers::FlatBufferBuilder; +use hyperlight_common::flatbuffer_wrappers::ExternalValueSource; +use hyperlight_common::flatbuffer_wrappers::function_call::FunctionCall; +use hyperlight_common::flatbuffer_wrappers::function_types::{Bytes, FunctionCallResult}; +use hyperlight_common::flatbuffer_wrappers::guest_log_data::GuestLogData; +use hyperlight_common::transport::{ + EncodedMessage, ExternalValues, MsgHeader, MsgKind, SIZE_PREFIX_LEN, size_prefix_payload_len, + size_prefixed_len, +}; +use hyperlight_common::virtq::{RecvChain, WritableChain}; + +use super::mem::HostMemOps; + +/// Copies external values from guest-writable scratch into host-owned storage. +/// +/// Chunked values become one owned chunk because host calls cannot retain +/// references into untrusted guest memory. +struct ChainExternalValues<'a> { + request: &'a mut RecvChain, +} + +impl<'a> ChainExternalValues<'a> { + fn new(request: &'a mut RecvChain) -> Self { + Self { request } + } +} + +impl ExternalValueSource for ChainExternalValues<'_> { + fn take_bytes(&mut self, length: usize) -> anyhow::Result> { + let remain = self.request.remaining(); + if length > remain { + bail!("External VecBytes requires {length} bytes, only {remain} remain"); + } + + let mut value = zeroed_vec(length)?; + self.request.read_exact(&mut value)?; + + Ok(value) + } + + fn take_chunks(&mut self, length: usize) -> anyhow::Result> { + if length == 0 { + return Ok(Vec::new()); + } + + let rem = self.request.remaining(); + if length > rem { + bail!("External ByteChunks requires {length} bytes, only {rem} remain"); + } + + let mut value = zeroed_vec(length)?; + self.request.read_exact(&mut value)?; + + Ok(vec![Bytes::from(value)]) + } + + fn finish(&mut self) -> anyhow::Result<()> { + if self.request.remaining() != 0 { + bail!( + "G2H message has {} trailing external bytes", + self.request.remaining() + ); + } + Ok(()) + } +} + +/// Decode one complete host function call from a G2H request. +/// +/// Control data and external values are copied out of guest-writable scratch. +/// Unconsumed trailing bytes are rejected. +pub(crate) fn get_host_function_call( + chain: &mut RecvChain, +) -> anyhow::Result { + let control = read_control(chain)?; + let mut exts = ChainExternalValues::new(chain); + FunctionCall::decode(&control, &mut exts) +} + +/// Read and validate one complete G2H message header. +pub(crate) fn read_message_header( + request: &mut RecvChain, +) -> anyhow::Result { + let mut bytes = [0u8; MsgHeader::SIZE]; + request.read_exact(&mut bytes)?; + + let header = MsgHeader::from_bytes(&bytes).context("G2H message has an invalid header")?; + if header.payload_len as usize != request.remaining() { + bail!("G2H message payload length mismatch"); + } + + Ok(header) +} + +/// Decode a guest-function result body after its G2H header. +pub(crate) fn read_guest_function_call_result( + request: &mut RecvChain, +) -> anyhow::Result { + let control = read_control(request)?; + let mut exts = ChainExternalValues::new(request); + FunctionCallResult::decode(&control, &mut exts) +} + +/// Encode and write a response when its complete wire message fits. +/// +/// `false` leaves the writable chain unchanged. +pub(crate) fn try_write_response( + reply: &mut WritableChain, + cid: u32, + result: &FunctionCallResult, +) -> anyhow::Result { + let mut builder = FlatBufferBuilder::new(); + let mut externals = ExternalValues::new(); + + let control = result.encode(&mut builder, &mut externals)?; + + let Some(msg) = EncodedMessage::new(MsgKind::Response, cid, control, externals) else { + bail!("Host function response length overflow"); + }; + + if msg.total_len() > reply.capacity() { + return Ok(false); + } + + for chunk in msg.chunks() { + reply.write_all(chunk)?; + } + + Ok(true) +} + +/// Decode guest log data and reject trailing external bytes. +pub(crate) fn read_guest_log_data( + chain: &mut RecvChain, +) -> anyhow::Result { + let control = read_control(chain)?; + let remain = chain.remaining(); + + if remain != 0 { + bail!("G2H log has {remain} trailing external bytes"); + } + + GuestLogData::try_from(control.as_slice()) +} + +/// Copy size-prefixed control data and leave external values unread. +fn read_control(request: &mut RecvChain) -> anyhow::Result> { + let mut prefix = [0u8; SIZE_PREFIX_LEN]; + request.read_exact(&mut prefix)?; + + let payload_len = size_prefix_payload_len(&prefix).context("G2H size prefix is invalid")?; + if payload_len > request.remaining() { + bail!( + "G2H control data declares {payload_len} bytes, only {} remain", + request.remaining() + ); + } + + let control_len = size_prefixed_len(payload_len).context("G2H control length overflow")?; + // Do not trust control_len to be small enough to allocate. + let mut control = zeroed_vec(control_len)?; + + control[..SIZE_PREFIX_LEN].copy_from_slice(&prefix); + request.read_exact(&mut control[SIZE_PREFIX_LEN..])?; + + Ok(control) +} + +/// Allocate zeroed host-owned storage without panicking on reserve failure. +pub fn zeroed_vec(length: usize) -> anyhow::Result> { + let mut value = Vec::new(); + value + .try_reserve_exact(length) + .with_context(|| format!("Failed to allocate {length} bytes"))?; + + value.resize(length, 0); + Ok(value) +} diff --git a/src/hyperlight_host/src/mem/virtq_mem.rs b/src/hyperlight_host/src/mem/virtq/mem.rs similarity index 99% rename from src/hyperlight_host/src/mem/virtq_mem.rs rename to src/hyperlight_host/src/mem/virtq/mem.rs index 944eee1b71..96f362dbf0 100644 --- a/src/hyperlight_host/src/mem/virtq_mem.rs +++ b/src/hyperlight_host/src/mem/virtq/mem.rs @@ -14,7 +14,7 @@ use core::sync::atomic::{AtomicU16, Ordering}; use hyperlight_common::layout::scratch_base_gva; use hyperlight_common::virtq::MemOps; -use super::shared_mem::{HostSharedMemory, SharedMemory}; +use crate::mem::shared_mem::{HostSharedMemory, SharedMemory}; use crate::{HyperlightError, Result, new_error}; /// Host virtqueue memory access confined to one scratch GVA range. diff --git a/src/hyperlight_host/src/mem/virtq/mod.rs b/src/hyperlight_host/src/mem/virtq/mod.rs new file mode 100644 index 0000000000..3582c1aa9e --- /dev/null +++ b/src/hyperlight_host/src/mem/virtq/mod.rs @@ -0,0 +1,477 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 The Hyperlight Authors. + +//! Host virtqueue construction and canonical snapshot validation. +//! +//! Runtime consumers bind bounded ring and pool views to the host-owned fixed +//! transport arena. They start at cursor zero before the first guest entry and +//! observe descriptors published by the guest later. +//! +//! H2G requests are written into guest-prefilled chains. G2H codec helpers copy +//! untrusted guest requests and results into host-owned values before use. +//! Shared wire framing lives in `hyperlight_common::transport`. +//! +//! Snapshot capture and restore validate canonical ring images against the +//! configured arena before exposing consumers. + +mod codec; +mod mem; +#[cfg(test)] +pub(crate) mod tests; + +use core::ops::Range; + +pub(crate) use codec::{ + get_host_function_call, read_guest_function_call_result, read_guest_log_data, + read_message_header, try_write_response, +}; +use hyperlight_common::layout::{QueueDims, TransportArena}; +use hyperlight_common::virtq::canonical::validate_canon_image; +use hyperlight_common::virtq::{ + Layout as VirtqLayout, MemOps, Notifier, QueueStats, VirtqConsumer, +}; +use mem::{HostMemOps, ImageMem}; + +use super::layout::{BaseGpaRegion, SandboxMemoryLayout}; +use super::shared_mem::{HostSharedMemory, SharedMemory}; +use crate::{Result, new_error}; + +/// Host-side G2H virtqueue consumer. +pub(crate) type G2hConsumer = VirtqConsumer; + +/// Host-side H2G virtqueue consumer. +pub(crate) type H2gConsumer = VirtqConsumer; + +/// No-op notifier because the host completes work during the current VM exit. +#[derive(Clone, Copy)] +pub(crate) struct HostNotifier; + +impl Notifier for HostNotifier { + fn notify(&self, _stats: QueueStats) {} +} + +/// Create both host consumers before the first guest entry. +/// +/// Ring contents are not inspected because the guest has not initialized them +/// yet. Consumer cursors start at zero and observe descriptors published later. +pub(crate) fn create_consumers( + layout: &SandboxMemoryLayout, + scratch_mem: &HostSharedMemory, +) -> Result<(G2hConsumer, H2gConsumer)> { + let validator = Validator::new(layout)?; + let regions = validator.resolve_gva_regions()?; + let g2h_layout = validator.config.g2h.layout(®ions.g2h_ring)?; + let h2g_layout = validator.config.h2g.layout(®ions.h2g_ring)?; + + build_consumers(scratch_mem, regions, g2h_layout, h2g_layout) +} + +/// Validate a materialized canonical image before attaching consumers. +fn attach_canonical( + layout: &SandboxMemoryLayout, + scratch_mem: &HostSharedMemory, +) -> Result<(G2hConsumer, H2gConsumer)> { + let validator = Validator::new(layout)?; + let arena_gpa = read_published_arena_gpa(scratch_mem)?; + let regions = validator.validate_published_arena(arena_gpa)?; + + let g2h_ring_mem = HostMemOps::new(scratch_mem, regions.g2h_ring.clone())?; + let g2h_layout = validator.validate_g2h(&g2h_ring_mem, regions.g2h_ring.clone())?; + + let h2g_ring_mem = HostMemOps::new(scratch_mem, regions.h2g_ring.clone())?; + // Why Range is not Copy? + let h2g_ring = regions.h2g_ring.clone(); + let h2g_pool = regions.h2g_pool.clone(); + let h2g_layout = validator.validate_h2g(&h2g_ring_mem, h2g_ring, h2g_pool)?; + + build_consumers(scratch_mem, regions, g2h_layout, h2g_layout) +} + +/// Bind consumers to separately bounded ring and pool mappings. +fn build_consumers( + scratch_mem: &HostSharedMemory, + regions: GvaRegions, + g2h_layout: VirtqLayout, + h2g_layout: VirtqLayout, +) -> Result<(G2hConsumer, H2gConsumer)> { + let g2h_ring_mem = HostMemOps::new(scratch_mem, regions.g2h_ring)?; + let g2h_pool_mem = HostMemOps::new(scratch_mem, regions.g2h_pool)?; + let h2g_ring_mem = HostMemOps::new(scratch_mem, regions.h2g_ring)?; + let h2g_pool_mem = HostMemOps::new(scratch_mem, regions.h2g_pool)?; + + Ok(( + VirtqConsumer::new_split(g2h_layout, g2h_ring_mem, g2h_pool_mem, HostNotifier), + VirtqConsumer::new_split(h2g_layout, h2g_ring_mem, h2g_pool_mem, HostNotifier), + )) +} + +/// Capture the canonical ring state omitted from the main memory snapshot. +/// +/// Pool contents are transient and are not included. +pub(crate) fn snapshot( + layout: &SandboxMemoryLayout, + scratch_mem: &HostSharedMemory, +) -> Result { + let validator = Validator::new(layout)?; + + let arena_gpa = read_published_arena_gpa(scratch_mem)?; + let regions = validator.validate_published_arena(arena_gpa)?; + + let g2h_mem = HostMemOps::new(scratch_mem, regions.g2h_ring.clone())?; + validator.validate_g2h(&g2h_mem, regions.g2h_ring.clone())?; + + let h2g_mem = HostMemOps::new(scratch_mem, regions.h2g_ring.clone())?; + validator.validate_h2g(&h2g_mem, regions.h2g_ring.clone(), regions.h2g_pool.clone())?; + + // The vCPU is stopped, so the ring images and snapshotted guest producer + // bookkeeping describe the same instant. + Ok(VirtqSnapshot { + scratch_size: layout.get_scratch_size(), + g2h_ring: read_ring(scratch_mem, regions.g2h_ring)?, + h2g_ring: read_ring(scratch_mem, regions.h2g_ring)?, + }) +} + +/// Validate and restore one canonical transport image. +/// +/// Validation completes before restored scratch is mutated. Fresh consumers +/// start from the canonical cursor state encoded in the rings. +pub(crate) fn restore( + layout: &SandboxMemoryLayout, + scratch_mem: &HostSharedMemory, + snapshot: &VirtqSnapshot, +) -> Result<(G2hConsumer, H2gConsumer)> { + let regions = Validator::new(layout)?.validate_snapshot(snapshot)?; + + write_published_arena_gpa(scratch_mem, layout.get_transport_arena().base_addr())?; + write_ring(scratch_mem, regions.g2h_ring, &snapshot.g2h_ring)?; + write_ring(scratch_mem, regions.h2g_ring, &snapshot.h2g_ring)?; + attach_canonical(layout, scratch_mem) +} + +/// Bounded GVA regions derived from validated transport GPAs. +struct GvaRegions { + /// Guest-to-host packed ring image. + g2h_ring: Range, + /// Host-to-guest packed ring image. + h2g_ring: Range, + /// Guest-to-host descriptor buffer pool. + g2h_pool: Range, + /// Host-to-guest descriptor buffer pool. + h2g_pool: Range, +} + +#[derive(Clone, Copy)] +struct QueueConfig { + /// Address-independent queue dimensions. + dims: QueueDims, + /// Size of each buffer in the pool in bytes. + buffer_size: usize, +} + +impl QueueConfig { + fn new(dims: QueueDims, buffer_size: usize) -> Result { + if buffer_size == 0 { + return Err(new_error!("buffer size is zero")); + } + + Ok(Self { dims, buffer_size }) + } + + fn layout(&self, ring: &Range) -> Result { + // SAFETY: `ring` is derived from the validated fixed transport arena. + unsafe { VirtqLayout::from_base(ring.start, self.dims.size()) } + .map_err(|error| new_error!("invalid ring layout: {error}")) + } +} + +/// Host-owned transport dimensions. +#[derive(Clone, Copy)] +struct Config { + /// Host-requested G2H configuration. + g2h: QueueConfig, + /// Host-requested H2G configuration. + h2g: QueueConfig, + /// Fixed host-assigned transport arena. + arena: TransportArena, + /// Number of canonical single-buffer H2G receive chains. + h2g_prefill_descs: usize, +} + +impl Config { + /// Compute the host transport configuration from the memory layout. + fn from_layout(layout: &SandboxMemoryLayout) -> Result { + let g2h = QueueConfig::new(layout.get_g2h_queue_dims(), layout.get_g2h_buffer_size())?; + let h2g = QueueConfig::new(layout.get_h2g_queue_dims(), layout.get_h2g_buffer_size())?; + + let h2g_prefill_descs = + usize::from(h2g.dims.size().get()).min(h2g.dims.pool_len() / h2g.buffer_size); + + let arena = layout.get_transport_arena(); + + Ok(Self { + g2h, + h2g, + arena, + h2g_prefill_descs, + }) + } +} + +/// Canonical in-memory transport state excluded from ordinary snapshot pages. +#[derive(Debug, PartialEq, Eq)] +pub(crate) struct VirtqSnapshot { + /// Scratch size used to derive transport GVAs. + scratch_size: usize, + /// Canonical guest-to-host ring image. + g2h_ring: Vec, + /// Canonical host-to-guest ring image. + h2g_ring: Vec, +} + +impl VirtqSnapshot { + pub(crate) fn new(scratch_size: usize, g2h_ring: Vec, h2g_ring: Vec) -> Self { + Self { + scratch_size, + g2h_ring, + h2g_ring, + } + } + + pub(crate) fn scratch_size(&self) -> usize { + self.scratch_size + } + + pub(crate) fn g2h_ring(&self) -> &[u8] { + &self.g2h_ring + } + + pub(crate) fn h2g_ring(&self) -> &[u8] { + &self.h2g_ring + } + + /// Validate every captured field before mutating restored scratch. + pub(crate) fn preflight(&self, layout: &SandboxMemoryLayout) -> Result<()> { + Validator::new(layout)?.validate_snapshot(self).map(|_| ()) + } +} + +/// Validates live and captured transport images against one host layout. +struct Validator<'a> { + config: Config, + layout: &'a SandboxMemoryLayout, +} + +impl<'a> Validator<'a> { + fn new(layout: &'a SandboxMemoryLayout) -> Result { + Ok(Self { + config: Config::from_layout(layout)?, + layout, + }) + } + + /// Validate a canonical G2H image and return its layout. + fn validate_g2h(&self, mem: &M, ring: Range) -> Result { + let layout = self.config.g2h.layout(&ring)?; + + validate_canon_image(mem, layout, 0, |_, _| false) + .map_err(|error| new_error!("invalid canonical G2H image: {error}"))?; + + Ok(layout) + } + + /// Validate a canonical H2G image and return its layout. + /// + /// Each available chain contains one writable descriptor. Descriptors must + /// name distinct, slot-aligned ranges inside the H2G pool. + fn validate_h2g( + &self, + mem: &M, + ring: Range, + pool: Range, + ) -> Result { + let layout = self.config.h2g.layout(&ring)?; + let bufsz = self.config.h2g.buffer_size; + let prefill = self.config.h2g_prefill_descs; + + if prefill == 0 { + return Err(new_error!("H2G pool has no complete buffers")); + } + + // Record the accepted descriptor ranges to detect overlaps. + let mut accepted: Vec> = Vec::with_capacity(prefill); + + let image = validate_canon_image(mem, layout, prefill, |_, elem| { + let Ok(bufsz_u64) = u64::try_from(bufsz) else { + return false; + }; + + // all descriptors must be writable and match the configured buffer size + if !elem.writable || usize::try_from(elem.len).ok() != Some(bufsz) { + return false; + } + + let Some(offset) = elem.addr.checked_sub(pool.start) else { + return false; + }; + let Some(end) = elem.addr.checked_add(u64::from(elem.len)) else { + return false; + }; + + // all descriptors must be slot-aligned and remain inside the pool + if !offset.is_multiple_of(bufsz_u64) || end > pool.end { + return false; + } + + let buf = elem.addr..end; + + // all descriptors must name distinct ranges + if accepted + .iter() + .any(|other| buf.start < other.end && other.start < buf.end) + { + return false; + } + + accepted.push(buf); + true + }) + .map_err(|error| new_error!("invalid canonical H2G image: {error}"))?; + + if image.len() != prefill || image.iter().any(|chain| chain.buffers().len() != 1) { + return Err(new_error!("invalid initial H2G receive buffers")); + } + + Ok(layout) + } + + /// Validate the published arena and return its GVA regions. + fn validate_published_arena(&self, arena_gpa: u64) -> Result { + if arena_gpa != self.config.arena.base_addr() { + return Err(new_error!("published transport arena is invalid")); + } + + self.resolve_gva_regions() + } + + fn validate_snapshot(&self, snapshot: &VirtqSnapshot) -> Result { + if snapshot.scratch_size != self.layout.get_scratch_size() { + return Err(new_error!( + "virtqueue snapshot scratch size {} does not match layout size {}", + snapshot.scratch_size, + self.layout.get_scratch_size() + )); + } + + let regions = self.resolve_gva_regions()?; + validate_ring_len("G2H", &snapshot.g2h_ring, self.config.g2h.dims.ring_len())?; + validate_ring_len("H2G", &snapshot.h2g_ring, self.config.h2g.dims.ring_len())?; + + let g2h_mem = ImageMem::new(regions.g2h_ring.start, &snapshot.g2h_ring); + self.validate_g2h(&g2h_mem, regions.g2h_ring.clone())?; + + let h2g_mem = ImageMem::new(regions.h2g_ring.start, &snapshot.h2g_ring); + self.validate_h2g(&h2g_mem, regions.h2g_ring.clone(), regions.h2g_pool.clone())?; + + Ok(regions) + } + + /// Translate validated transport GPAs into the GVA ranges used by descriptors. + fn resolve_gva_regions(&self) -> Result { + let to_gva = |gpa| { + let resolved = self + .layout + .resolve_gpa(gpa, &[]) + .ok_or_else(|| new_error!("GPA {gpa:#x} is outside scratch"))?; + + if !matches!(resolved.base, BaseGpaRegion::Scratch(())) { + return Err(new_error!("GPA {gpa:#x} is outside scratch")); + } + + hyperlight_common::layout::scratch_base_gva(self.layout.get_scratch_size()) + .checked_add(u64::try_from(resolved.offset)?) + .ok_or_else(|| new_error!("GPA {gpa:#x} to GVA translation overflow")) + }; + + let ( + g2h_ring_addr, + h2g_ring_addr, + g2h_pool_addr, + h2g_pool_addr, + g2h_ring_len, + h2g_ring_len, + g2h_pool_len, + h2g_pool_len, + ) = ( + self.config.arena.g2h_ring_addr(), + self.config.arena.h2g_ring_addr(), + self.config.arena.g2h_pool_addr(), + self.config.arena.h2g_pool_addr(), + self.config.g2h.dims.ring_len(), + self.config.h2g.dims.ring_len(), + self.config.g2h.dims.pool_len(), + self.config.h2g.dims.pool_len(), + ); + + Ok(GvaRegions { + g2h_ring: checked_region(to_gva(g2h_ring_addr)?, g2h_ring_len, "G2H ring")?, + h2g_ring: checked_region(to_gva(h2g_ring_addr)?, h2g_ring_len, "H2G ring")?, + g2h_pool: checked_region(to_gva(g2h_pool_addr)?, g2h_pool_len, "G2H pool")?, + h2g_pool: checked_region(to_gva(h2g_pool_addr)?, h2g_pool_len, "H2G pool")?, + }) + } +} + +/// Read the transport arena GPA from scratch-top metadata. +fn read_published_arena_gpa(scratch_mem: &HostSharedMemory) -> Result { + let offset = hyperlight_common::layout::SCRATCH_TOP_TRANSPORT_ARENA_GPA_OFFSET as usize; + Ok(scratch_mem.read::(scratch_mem.mem_size() - offset)?) +} + +/// Publish the fixed transport arena GPA in scratch-top metadata. +fn write_published_arena_gpa(scratch_mem: &HostSharedMemory, arena_gpa: u64) -> Result<()> { + let offset = hyperlight_common::layout::SCRATCH_TOP_TRANSPORT_ARENA_GPA_OFFSET as usize; + Ok(scratch_mem.write::(scratch_mem.mem_size() - offset, arena_gpa)?) +} + +/// Copy one ring image from its bounded scratch mapping. +fn read_ring(scratch_mem: &HostSharedMemory, ring: Range) -> Result> { + let len = usize::try_from( + ring.end + .checked_sub(ring.start) + .ok_or_else(|| new_error!("invalid ring range"))?, + )?; + + let mem = HostMemOps::new(scratch_mem, ring.clone())?; + let mut bytes = vec![0; len]; + mem.read(ring.start, &mut bytes)?; + + Ok(bytes) +} + +/// Copy one validated ring image into its bounded scratch mapping. +fn write_ring(scratch_mem: &HostSharedMemory, ring: Range, bytes: &[u8]) -> Result<()> { + validate_ring_len("restored", bytes, usize::try_from(ring.end - ring.start)?)?; + let mem = HostMemOps::new(scratch_mem, ring.clone())?; + mem.write(ring.start, bytes) +} + +/// Require a captured ring image to match its configured region exactly. +fn validate_ring_len(direction: &str, bytes: &[u8], expected: usize) -> Result<()> { + if bytes.len() != expected { + return Err(new_error!( + "{direction} snapshot ring length {} and expected length {expected}", + bytes.len() + )); + } + Ok(()) +} + +/// Build a GVA range while checking address arithmetic. +fn checked_region(start: u64, len: usize, tag: &str) -> Result> { + let end = start + .checked_add(u64::try_from(len)?) + .ok_or_else(|| new_error!("{tag} GVA range overflow"))?; + + Ok(start..end) +} diff --git a/src/hyperlight_host/src/mem/virtq/tests.rs b/src/hyperlight_host/src/mem/virtq/tests.rs new file mode 100644 index 0000000000..4f17ec59a1 --- /dev/null +++ b/src/hyperlight_host/src/mem/virtq/tests.rs @@ -0,0 +1,313 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 The Hyperlight Authors. + +use core::ops::Range; + +use hyperlight_common::layout::SCRATCH_TOP_ALLOCATOR_OFFSET; +use hyperlight_common::virtq::{ + DescFlags, Descriptor, MemOps, SlotLayout, SlotPool, VirtqProducer, +}; +use hyperlight_common::vmem; + +use super::mem::HostMemOps; +use super::*; +use crate::mem::shared_mem::{ExclusiveSharedMemory, HostSharedMemory}; +use crate::sandbox::SandboxConfiguration; + +pub(crate) const SCRATCH_SIZE: usize = 0x20_000; +pub(crate) const H2G_BUFFER_SIZE: usize = 3000; + +pub(crate) struct TestVirtq { + pub(crate) scratch: HostSharedMemory, + pub(crate) g2h_mem: HostMemOps, + pub(crate) h2g_mem: HostMemOps, + pub(crate) g2h_ring: Range, + pub(crate) h2g_ring: Range, + pub(crate) g2h_pool: Range, + pub(crate) h2g_pool: Range, + pub(crate) g2h_layout: VirtqLayout, + pub(crate) h2g_layout: VirtqLayout, +} + +impl TestVirtq { + pub(crate) fn new() -> Self { + let scratch = host_scratch(); + let layout = memory_layout(); + + let validator = Validator::new(&layout).unwrap(); + let config = validator.config; + let regions = validator.resolve_gva_regions().unwrap(); + + let g2h_layout = config.g2h.layout(®ions.g2h_ring).unwrap(); + let h2g_layout = config.h2g.layout(®ions.h2g_ring).unwrap(); + let arena = regions.g2h_ring.start..regions.h2g_pool.end; + + let mem = HostMemOps::new(&scratch, arena).unwrap(); + + let h2g_pool = SlotPool::new(SlotLayout::new( + regions.h2g_pool.start, + config.h2g.buffer_size, + config.h2g_prefill_descs, + )) + .unwrap(); + + let mut producer = VirtqProducer::new(h2g_layout, mem, HostNotifier, h2g_pool.clone()); + let mut batch = producer.batch(); + + for _ in 0..config.h2g_prefill_descs { + let chain = batch + .chain() + .writable(config.h2g.buffer_size) + .build() + .unwrap(); + batch.submit(chain).unwrap(); + } + + batch.finish_without_notify(); + + write_published_arena_gpa(&scratch, config.arena.base_addr()).unwrap(); + + Self { + g2h_mem: HostMemOps::new(&scratch, regions.g2h_ring.clone()).unwrap(), + h2g_mem: HostMemOps::new(&scratch, regions.h2g_ring.clone()).unwrap(), + scratch, + g2h_ring: regions.g2h_ring, + h2g_ring: regions.h2g_ring, + g2h_pool: regions.g2h_pool, + h2g_pool: regions.h2g_pool, + g2h_layout, + h2g_layout, + } + } + + pub(crate) fn h2g_consumer(&self) -> H2gConsumer { + attach_canonical(&memory_layout(), &self.scratch).unwrap().1 + } + + pub(crate) fn g2h_consumer(&self) -> G2hConsumer { + attach_canonical(&memory_layout(), &self.scratch).unwrap().0 + } + + fn validate(&self) -> Result<()> { + let layout = memory_layout(); + let validator = Validator::new(&layout)?; + validator.validate_g2h(&self.g2h_mem, self.g2h_ring.clone())?; + validator.validate_h2g(&self.h2g_mem, self.h2g_ring.clone(), self.h2g_pool.clone())?; + Ok(()) + } + + fn g2h_desc(&self, index: u16) -> Descriptor { + read_desc(&self.g2h_mem, self.g2h_layout, index) + } + + pub(crate) fn h2g_desc(&self, index: u16) -> Descriptor { + read_desc(&self.h2g_mem, self.h2g_layout, index) + } + + fn set_g2h_desc(&self, index: u16, desc: Descriptor) { + write_desc(&self.g2h_mem, self.g2h_layout, index, desc); + } + + pub(crate) fn set_h2g_desc(&self, index: u16, desc: Descriptor) { + write_desc(&self.h2g_mem, self.h2g_layout, index, desc); + } + + pub(crate) fn h2g_buffer(&self, index: u16, addr: u64) -> Vec { + let desc = self.h2g_desc(index); + let mut bytes = vec![0; desc.len as usize]; + let pool = HostMemOps::new(&self.scratch, self.h2g_pool.clone()).unwrap(); + pool.read(addr, &mut bytes).unwrap(); + bytes + } +} + +pub(crate) fn memory_layout() -> SandboxMemoryLayout { + let mut config = SandboxConfiguration::default(); + config.set_scratch_size(SCRATCH_SIZE); + config.set_g2h_queue_size(16); + config.set_h2g_queue_size(8); + config.set_h2g_buffer_size(H2G_BUFFER_SIZE); + config.set_g2h_pool_pages(3); + config.set_h2g_pool_pages(3); + + SandboxMemoryLayout::new(config, 4096, 0, None).unwrap() +} + +fn host_scratch() -> HostSharedMemory { + ExclusiveSharedMemory::new(SCRATCH_SIZE).unwrap().build().0 +} + +fn read_desc(mem: &HostMemOps, layout: VirtqLayout, index: u16) -> Descriptor { + mem.read_val(layout.desc_table_addr() + u64::from(index) * Descriptor::SIZE as u64) + .unwrap() +} + +fn write_desc(mem: &HostMemOps, layout: VirtqLayout, index: u16, desc: Descriptor) { + mem.write_val( + layout.desc_table_addr() + u64::from(index) * Descriptor::SIZE as u64, + desc, + ) + .unwrap(); +} + +#[test] +fn validates_host_placed_regions() { + let layout = memory_layout(); + let validator = Validator::new(&layout).unwrap(); + let config = validator.config; + let regions = validator.resolve_gva_regions().unwrap(); + + assert_eq!( + regions.g2h_ring.end - regions.g2h_ring.start, + config.g2h.dims.ring_len() as u64 + ); + assert_eq!( + regions.h2g_ring.end - regions.h2g_ring.start, + config.h2g.dims.ring_len() as u64 + ); + assert_eq!( + regions.g2h_pool.end - regions.g2h_pool.start, + config.g2h.dims.pool_len() as u64 + ); + assert_eq!( + regions.h2g_pool.end - regions.h2g_pool.start, + config.h2g.dims.pool_len() as u64 + ); +} + +#[test] +fn rejects_incorrect_published_arena() { + let layout = memory_layout(); + let validator = Validator::new(&layout).unwrap(); + let arena_gpa = validator.config.arena.base_addr() + 1; + assert!(validator.validate_published_arena(arena_gpa).is_err()); +} + +#[test] +fn validates_initial_virtq_images() { + TestVirtq::new().validate().unwrap(); +} + +#[test] +fn snapshots_and_restores_canonical_image() { + let queue = TestVirtq::new(); + let layout = memory_layout(); + let stale_pool = [0xa5; 16]; + let pool_mem = HostMemOps::new(&queue.scratch, queue.h2g_pool.clone()).unwrap(); + + pool_mem.write(queue.h2g_pool.start, &stale_pool).unwrap(); + + let captured = snapshot(&layout, &queue.scratch).unwrap(); + let restored = host_scratch(); + let allocator = layout.get_first_free_scratch_gpa(); + let allocator_offset = restored.mem_size() - SCRATCH_TOP_ALLOCATOR_OFFSET as usize; + + restored.write::(allocator_offset, allocator).unwrap(); + + restore(&layout, &restored, &captured).unwrap(); + let restored_snapshot = snapshot(&layout, &restored).unwrap(); + let restored_pool = HostMemOps::new(&restored, queue.h2g_pool.clone()).unwrap(); + let mut pool_bytes = [0; 16]; + + restored_pool + .read(queue.h2g_pool.start, &mut pool_bytes) + .unwrap(); + + assert_eq!(restored_snapshot, captured); + assert_eq!(restored.read::(allocator_offset).unwrap(), allocator); + assert_eq!(pool_bytes, [0; 16]); +} + +#[test] +fn rejects_corrupt_snapshot_ring_before_restore() { + let queue = TestVirtq::new(); + let layout = memory_layout(); + let mut captured = snapshot(&layout, &queue.scratch).unwrap(); + + captured.h2g_ring.fill(0); + let restored = host_scratch(); + + assert!(restore(&layout, &restored, &captured).is_err()); + assert_eq!(read_published_arena_gpa(&restored).unwrap(), 0); +} + +#[test] +fn restores_with_grown_page_tables() { + let queue = TestVirtq::new(); + let layout = memory_layout(); + let captured = snapshot(&layout, &queue.scratch).unwrap(); + + let mut grown_layout = layout; + + grown_layout + .set_pt_size(layout.get_pt_size() + vmem::PAGE_SIZE) + .unwrap(); + + let restored = host_scratch(); + + restore(&grown_layout, &restored, &captured).unwrap(); + assert_eq!( + read_published_arena_gpa(&restored).unwrap(), + grown_layout.get_transport_arena().base_addr() + ); +} + +#[test] +fn rejects_nonzero_g2h_descriptors() { + let queue = TestVirtq::new(); + let mut desc = queue.g2h_desc(0); + desc.addr = queue.g2h_pool.start; + queue.set_g2h_desc(0, desc); + assert!(queue.validate().is_err()); +} + +#[test] +fn rejects_invalid_h2g_descriptors() { + #[derive(Debug)] + enum Corruption { + OutsidePool, + Readable, + WrongSize, + Misaligned, + Overlapping, + } + + for corruption in [ + Corruption::OutsidePool, + Corruption::Readable, + Corruption::WrongSize, + Corruption::Misaligned, + Corruption::Overlapping, + ] { + let queue = TestVirtq::new(); + let mut desc = queue.h2g_desc(0); + + let index = match corruption { + Corruption::OutsidePool => { + desc.addr = queue.g2h_pool.start; + 0 + } + Corruption::Readable => { + desc.flags &= !DescFlags::WRITE.bits(); + 0 + } + Corruption::WrongSize => { + desc.len -= 1; + 0 + } + Corruption::Misaligned => { + desc.addr += 1; + 0 + } + Corruption::Overlapping => { + let first_addr = desc.addr; + desc = queue.h2g_desc(1); + desc.addr = first_addr; + 1 + } + }; + + queue.set_h2g_desc(index, desc); + assert!(queue.validate().is_err(), "{corruption:?}"); + } +} diff --git a/src/hyperlight_host/src/sandbox/builder.rs b/src/hyperlight_host/src/sandbox/builder.rs index 7ac5a44126..ab3b85fd43 100644 --- a/src/hyperlight_host/src/sandbox/builder.rs +++ b/src/hyperlight_host/src/sandbox/builder.rs @@ -307,30 +307,6 @@ impl SandboxBuilder { } impl SandboxBuilder { - /// Set the size of the memory buffer made available for input to the guest. - /// Values below [`SandboxConfiguration::MIN_INPUT_SIZE`] are clamped up. - pub fn input_data_size(mut self, size: usize) -> Self { - self.cfg.set_input_data_size(size); - self - } - - /// The size of the memory buffer made available for input to the guest. - pub fn get_input_data_size(&self) -> usize { - self.cfg.get_input_data_size() - } - - /// Set the size of the memory buffer made available for output from the guest. - /// Values below [`SandboxConfiguration::MIN_OUTPUT_SIZE`] are clamped up. - pub fn output_data_size(mut self, size: usize) -> Self { - self.cfg.set_output_data_size(size); - self - } - - /// The size of the memory buffer made available for output from the guest. - pub fn get_output_data_size(&self) -> usize { - self.cfg.get_output_data_size() - } - /// Set the guest heap size. A size of 0 selects /// [`SandboxConfiguration::DEFAULT_HEAP_SIZE`]. pub fn heap_size(mut self, size: u64) -> Self { @@ -441,10 +417,7 @@ mod tests { #[test] fn build_from_file() { let path = simple_guest_as_string().unwrap(); - let mut sandbox = SandboxBuilder::from_file(path) - .input_data_size(0x8000) - .build() - .unwrap(); + let mut sandbox = SandboxBuilder::from_file(path).build().unwrap(); let result = sandbox.call::("Echo", "hello".to_string()).unwrap(); assert_eq!(result, "hello"); diff --git a/src/hyperlight_host/src/sandbox/config.rs b/src/hyperlight_host/src/sandbox/config.rs index d9ce766c30..0e24bbb165 100644 --- a/src/hyperlight_host/src/sandbox/config.rs +++ b/src/hyperlight_host/src/sandbox/config.rs @@ -45,12 +45,6 @@ pub struct SandboxConfiguration { /// Guest gdb debug port #[cfg(gdb)] guest_debug_info: Option, - /// The size of the memory buffer that is made available for input to the - /// Guest Binary - input_data_size: usize, - /// The size of the memory buffer that is made available for input to the - /// Guest Binary - output_data_size: usize, /// The heap size to use in the guest sandbox. If set to 0, the heap /// size will be determined from the PE file header /// @@ -96,22 +90,15 @@ pub struct SandboxConfiguration { } impl SandboxConfiguration { - /// The default size of input data - pub const DEFAULT_INPUT_SIZE: usize = 0x4000; - /// The minimum size of input data - pub const MIN_INPUT_SIZE: usize = 0x2000; - /// The default size of output data - pub const DEFAULT_OUTPUT_SIZE: usize = 0x4000; - /// The minimum size of output data - pub const MIN_OUTPUT_SIZE: usize = 0x2000; /// The default interrupt retry delay pub const DEFAULT_INTERRUPT_RETRY_DELAY: Duration = Duration::from_micros(500); /// The default signal offset from `SIGRTMIN` used to determine the signal number for interrupting pub const INTERRUPT_VCPU_SIGRTMIN_OFFSET: u8 = 0; /// The default heap size of a hyperlight sandbox pub const DEFAULT_HEAP_SIZE: u64 = 131072; - /// The default size of the scratch region - pub const DEFAULT_SCRATCH_SIZE: usize = 0x55000; + /// The default scratch size keeps enough dynamic space to back the default + /// heap after reserving the transport arena and page tables. + pub const DEFAULT_SCRATCH_SIZE: usize = 0x56000; /// The default G2H virtqueue descriptor count. pub const DEFAULT_G2H_QUEUE_SIZE: usize = 64; /// The default H2G virtqueue descriptor count. @@ -121,9 +108,9 @@ impl SandboxConfiguration { /// The default H2G buffer size. pub const DEFAULT_H2G_BUFFER_SIZE: usize = PAGE_SIZE; /// The default total number of G2H pool pages. - pub const DEFAULT_G2H_POOL_PAGES: usize = 8; + pub const DEFAULT_G2H_POOL_PAGES: usize = 12; /// The default total number of H2G pool pages. - pub const DEFAULT_H2G_POOL_PAGES: usize = 4; + pub const DEFAULT_H2G_POOL_PAGES: usize = 8; /// The minimum G2H virtqueue descriptor count. const MIN_QUEUE_SIZE: usize = 2; /// The maximum G2H virtqueue descriptor count. @@ -138,12 +125,9 @@ impl SandboxConfiguration { #[cfg(target_arch = "x86_64")] pub const MAX_GUEST_MSRS: usize = 16; - #[allow(clippy::too_many_arguments)] /// Create a new configuration for a sandbox with the given sizes. #[instrument(skip_all, parent = Span::current(), level= "Trace")] fn new( - input_data_size: usize, - output_data_size: usize, heap_size_override: Option, scratch_size: usize, interrupt_retry_delay: Duration, @@ -152,8 +136,6 @@ impl SandboxConfiguration { #[cfg(crashdump)] guest_core_dump: bool, ) -> Self { Self { - input_data_size: max(input_data_size, Self::MIN_INPUT_SIZE), - output_data_size: max(output_data_size, Self::MIN_OUTPUT_SIZE), heap_size_override: heap_size_override.unwrap_or(0), scratch_size, g2h_queue_size: Self::DEFAULT_G2H_QUEUE_SIZE, @@ -175,20 +157,6 @@ impl SandboxConfiguration { } } - /// Set the size of the memory buffer that is made available for input to the guest - /// the minimum value is MIN_INPUT_SIZE - #[instrument(skip_all, parent = Span::current(), level= "Trace")] - pub fn set_input_data_size(&mut self, input_data_size: usize) { - self.input_data_size = max(input_data_size, Self::MIN_INPUT_SIZE); - } - - /// Set the size of the memory buffer that is made available for output from the guest - /// the minimum value is MIN_OUTPUT_SIZE - #[instrument(skip_all, parent = Span::current(), level= "Trace")] - pub fn set_output_data_size(&mut self, output_data_size: usize) { - self.output_data_size = max(output_data_size, Self::MIN_OUTPUT_SIZE); - } - /// Set the heap size to use in the guest sandbox. If set to 0, the heap size will be determined from the PE file header #[instrument(skip_all, parent = Span::current(), level= "Trace")] pub fn set_heap_size(&mut self, heap_size: u64) { @@ -305,16 +273,6 @@ impl SandboxConfiguration { self.guest_debug_info = Some(debug_info); } - #[instrument(skip_all, parent = Span::current(), level= "Trace")] - pub(crate) fn get_input_data_size(&self) -> usize { - self.input_data_size - } - - #[instrument(skip_all, parent = Span::current(), level= "Trace")] - pub(crate) fn get_output_data_size(&self) -> usize { - self.output_data_size - } - #[instrument(skip_all, parent = Span::current(), level= "Trace")] pub(crate) fn get_scratch_size(&self) -> usize { self.scratch_size @@ -461,8 +419,6 @@ impl Default for SandboxConfiguration { #[instrument(skip_all, parent = Span::current(), level= "Trace")] fn default() -> Self { Self::new( - Self::DEFAULT_INPUT_SIZE, - Self::DEFAULT_OUTPUT_SIZE, None, Self::DEFAULT_SCRATCH_SIZE, Self::DEFAULT_INTERRUPT_RETRY_DELAY, @@ -542,12 +498,8 @@ mod tests { #[test] fn overrides() { const HEAP_SIZE_OVERRIDE: u64 = 0x50000; - const INPUT_DATA_SIZE_OVERRIDE: usize = 0x4000; - const OUTPUT_DATA_SIZE_OVERRIDE: usize = 0x4001; const SCRATCH_SIZE_OVERRIDE: usize = 0x60000; let mut cfg = SandboxConfiguration::new( - INPUT_DATA_SIZE_OVERRIDE, - OUTPUT_DATA_SIZE_OVERRIDE, Some(HEAP_SIZE_OVERRIDE), SCRATCH_SIZE_OVERRIDE, SandboxConfiguration::DEFAULT_INTERRUPT_RETRY_DELAY, @@ -567,8 +519,6 @@ mod tests { cfg.scratch_size = 0x40000; assert_eq!(2048, cfg.heap_size_override); assert_eq!(0x40000, cfg.scratch_size); - assert_eq!(INPUT_DATA_SIZE_OVERRIDE, cfg.input_data_size); - assert_eq!(OUTPUT_DATA_SIZE_OVERRIDE, cfg.output_data_size); assert_eq!( SandboxConfiguration::DEFAULT_G2H_QUEUE_SIZE, cfg.get_g2h_queue_size() @@ -595,31 +545,6 @@ mod tests { ); } - #[test] - fn min_sizes() { - let mut cfg = SandboxConfiguration::new( - SandboxConfiguration::MIN_INPUT_SIZE - 1, - SandboxConfiguration::MIN_OUTPUT_SIZE - 1, - None, - SandboxConfiguration::DEFAULT_SCRATCH_SIZE, - SandboxConfiguration::DEFAULT_INTERRUPT_RETRY_DELAY, - SandboxConfiguration::INTERRUPT_VCPU_SIGRTMIN_OFFSET, - #[cfg(gdb)] - None, - #[cfg(crashdump)] - true, - ); - assert_eq!(SandboxConfiguration::MIN_INPUT_SIZE, cfg.input_data_size); - assert_eq!(SandboxConfiguration::MIN_OUTPUT_SIZE, cfg.output_data_size); - assert_eq!(0, cfg.heap_size_override); - - cfg.set_input_data_size(SandboxConfiguration::MIN_INPUT_SIZE - 1); - cfg.set_output_data_size(SandboxConfiguration::MIN_OUTPUT_SIZE - 1); - - assert_eq!(SandboxConfiguration::MIN_INPUT_SIZE, cfg.input_data_size); - assert_eq!(SandboxConfiguration::MIN_OUTPUT_SIZE, cfg.output_data_size); - } - #[test] fn queue_sizes_are_normalized() { let mut cfg = SandboxConfiguration::default(); @@ -693,21 +618,6 @@ mod tests { use crate::sandbox::config::DebugInfo; proptest! { - #[test] - fn input_data_size(size in SandboxConfiguration::MIN_INPUT_SIZE..=SandboxConfiguration::MIN_INPUT_SIZE * 10) { - let mut cfg = SandboxConfiguration::default(); - cfg.set_input_data_size(size); - prop_assert_eq!(size, cfg.get_input_data_size()); - } - - #[test] - fn output_data_size(size in SandboxConfiguration::MIN_OUTPUT_SIZE..=SandboxConfiguration::MIN_OUTPUT_SIZE * 10) { - let mut cfg = SandboxConfiguration::default(); - cfg.set_output_data_size(size); - prop_assert_eq!(size, cfg.get_output_data_size()); - } - - #[test] fn heap_size_override(size in 0x1000..=0x10000u64) { let mut cfg = SandboxConfiguration::default(); diff --git a/src/hyperlight_host/src/sandbox/initialized_multi_use.rs b/src/hyperlight_host/src/sandbox/initialized_multi_use.rs index e825b8cfec..84ed51aecb 100644 --- a/src/hyperlight_host/src/sandbox/initialized_multi_use.rs +++ b/src/hyperlight_host/src/sandbox/initialized_multi_use.rs @@ -6,12 +6,10 @@ use std::path::Path; use std::path::PathBuf; use std::sync::{Arc, Mutex}; -use flatbuffers::FlatBufferBuilder; use hyperlight_common::flatbuffer_wrappers::function_call::{FunctionCall, FunctionCallType}; use hyperlight_common::flatbuffer_wrappers::function_types::{ ParameterValue, ReturnType, ReturnValue, }; -use hyperlight_common::flatbuffer_wrappers::util::estimate_flatbuffer_capacity; use tracing::{Span, instrument}; use super::Callable; @@ -87,6 +85,8 @@ pub struct MultiUseSandbox { /// If the current state of the sandbox has been captured in a snapshot, /// that snapshot is stored here. pub(crate) snapshot: Option>, + /// Whether queue traffic occurred since the last canonical boundary. + transport_dirty: bool, /// Optional callback to discover page table roots from guest memory. /// Given (snapshot_mem, scratch_mem, cr3), returns a list of root GPAs. /// If not set, only CR3 is used as the single root. @@ -137,6 +137,7 @@ impl MultiUseSandbox { mem_mgr: mgr, vm, snapshot: None, + transport_dirty: false, pt_root_finder: None, } } @@ -238,8 +239,6 @@ impl MultiUseSandbox { if caller_supplied_config { warn_on_layout_override(&config, snapshot.layout()); } - config.set_input_data_size(snapshot.layout().input_data_size()); - config.set_output_data_size(snapshot.layout().output_data_size()); config.set_heap_size(snapshot.layout().heap_size() as u64); config.set_scratch_size(snapshot.layout().get_scratch_size()); config.set_g2h_queue_size(snapshot.layout().get_g2h_queue_size()); @@ -252,10 +251,7 @@ impl MultiUseSandbox { let mgr = crate::mem::mgr::SandboxMemoryManager::from_snapshot(&snapshot)?; let (mut hshm, gshm) = mgr.build()?; - let attach_virtq = matches!( - snapshot.next_action(), - super::snapshot::NextAction::Initialise(_) - ); + let restore_virtq = matches!(snapshot.next_action(), super::snapshot::NextAction::Call(_)); let page_size = u32::try_from(page_size::get())? as usize; @@ -330,10 +326,11 @@ impl MultiUseSandbox { })?; } - if attach_virtq { - hshm.attach_virtq()?; - } else { - hshm.restore_virtq(snapshot.virtq())?; + if restore_virtq { + let virtq = snapshot.virtq().ok_or_else(|| { + crate::new_error!("running snapshot has no canonical transport state") + })?; + hshm.restore_virtq(virtq)?; } let sbox = MultiUseSandbox::from_uninit(host_funcs, hshm, vm); @@ -381,6 +378,11 @@ impl MultiUseSandbox { if let Some(snapshot) = &self.snapshot { return Ok(snapshot.clone()); } + + if self.transport_dirty { + self.checkpoint_transport_for_snapshot()?; + } + let mapped_regions_iter = self.vm.get_mapped_regions(); let mapped_regions_vec: Vec = mapped_regions_iter.cloned().collect(); // Get CR3 from the vCPU @@ -431,6 +433,48 @@ impl MultiUseSandbox { Ok(snapshot) } + fn checkpoint_transport_for_snapshot(&mut self) -> Result<()> { + if let Err(error) = self.mem_mgr.begin_snapshot_checkpoint() { + if error.is_poison_error() { + self.poison(); + } + return Err(error); + } + + if let Err(error) = self + .vm + .dispatch_call_from_host(&mut self.mem_mgr, &self.host_funcs) + { + let (error, should_poison) = error.promote(); + if should_poison { + self.poison(); + } + return Err(error); + } + + let guest_owned = match self.mem_mgr.finish_snapshot_checkpoint() { + Ok(guest_owned) => guest_owned, + Err(error) => { + if error.is_poison_error() { + self.poison(); + } + return Err(error); + } + }; + + if guest_owned != 0 { + // TODO: Parse retained pool-relative ranges and initialized lengths + // from the mailbox, sanitize them, and include them in the snapshot. + // The count-only protocol cannot preserve payloads safely. + return Err(HyperlightError::Error(format!( + "Cannot snapshot while {guest_owned} transport buffers are retained" + ))); + } + + self.transport_dirty = false; + Ok(()) + } + fn restore_memory_and_mappings(&mut self, snapshot: &Snapshot) -> Result<()> { let (snapshot_mem, scratch_mem) = self.mem_mgr.restore_snapshot(snapshot)?; if let Some(snapshot_mem) = snapshot_mem { @@ -621,6 +665,7 @@ impl MultiUseSandbox { // The restored snapshot is now our most current snapshot self.snapshot = Some(snapshot.clone()); + self.transport_dirty = false; // Clear poison state when successfully restoring from snapshot. // @@ -907,7 +952,7 @@ impl MultiUseSandbox { self.vm.clear_cancel(); let res = (|| { - let estimated_capacity = estimate_flatbuffer_capacity(function_name, &args); + self.transport_dirty = true; let fc = FunctionCall::new( function_name.to_string(), @@ -916,10 +961,7 @@ impl MultiUseSandbox { return_type, ); - let mut builder = FlatBufferBuilder::with_capacity(estimated_capacity); - let buffer = fc.encode(&mut builder); - - self.mem_mgr.write_guest_function_call(buffer)?; + let cid = self.mem_mgr.write_guest_function_call(&fc)?; let dispatch_res = self .vm @@ -935,7 +977,7 @@ impl MultiUseSandbox { return Err(error); } - let guest_result = self.mem_mgr.get_guest_function_call_result()?.into_inner(); + let guest_result = self.mem_mgr.read_h2g_result_from_g2h(cid)?.into_inner(); match guest_result { Ok(val) => Ok(val), @@ -957,14 +999,7 @@ impl MultiUseSandbox { // Clear partial abort bytes so they don't leak across calls. self.mem_mgr.abort_buffer.clear(); - // In the happy path we do not need to clear io-buffers from the host because: - // - the serialized guest function call is zeroed out by the guest during deserialization, see call to `try_pop_shared_input_data_into::()` - // - the serialized guest function result is zeroed out by us (the host) during deserialization, see `get_guest_function_call_result` - // - any serialized host function call are zeroed out by us (the host) during deserialization, see `get_host_function_call` - // - any serialized host function result is zeroed out by the guest during deserialization, see `get_host_return_value` if let Err(e) = &res { - self.mem_mgr.clear_io_buffers(); - // Determine if we should poison the sandbox. if e.is_poison_error() { self.poison(); @@ -1137,16 +1172,6 @@ fn warn_on_layout_override( snapshot: &crate::mem::layout::SandboxMemoryLayout, ) { let mismatches: &[(&str, u64, u64)] = &[ - ( - "input_data_size", - caller.get_input_data_size() as u64, - snapshot.input_data_size() as u64, - ), - ( - "output_data_size", - caller.get_output_data_size() as u64, - snapshot.output_data_size() as u64, - ), ( "heap_size", caller.get_heap_size(), @@ -1206,6 +1231,7 @@ mod tests { use std::thread; use hyperlight_common::flatbuffer_wrappers::guest_error::ErrorCode; + use hyperlight_common::func::Bytes; use hyperlight_testing::sandbox_sizes::{LARGE_HEAP_SIZE, MEDIUM_HEAP_SIZE, SMALL_HEAP_SIZE}; use hyperlight_testing::{c_simple_guest_as_pathbuf, simple_guest_as_pathbuf}; @@ -1326,7 +1352,7 @@ mod tests { let _ = sbox.snapshot().unwrap(); } - /// Make sure input/output buffers are properly reset after guest call (with host call) + /// Make sure transport buffers are reclaimed after host call failures. #[test] fn host_func_error() { let path = simple_guest_as_pathbuf(); @@ -1337,7 +1363,7 @@ mod tests { .build() .unwrap(); - // will exhaust io if leaky + // Repeated calls exhaust the transport if buffers leak. for _ in 0..1000 { let result = sandbox .call::( @@ -1361,13 +1387,11 @@ mod tests { .unwrap(); } - /// Make sure input/output buffers are properly reset after guest call (with host call) + /// Make sure transport buffers are reclaimed after guest calls. #[test] - fn io_buffer_reset() { + fn transport_buffers_are_reclaimed() { let path = simple_guest_as_pathbuf(); let mut sandbox = SandboxBuilder::from_file(path) - .input_data_size(4096) - .output_data_size(4096) .host_function("HostAdd", |a: i32, b: i32| a + b) .build() .unwrap(); @@ -1406,23 +1430,25 @@ mod tests { assert_eq!(res, 0); } - // Checks that 1,000 calls work with constrained guest memory. + // Checks that 1,000 calls work with a 24 KiB stack and 40 KiB heap. // This catches guest stack reset and heap leaks. #[test] fn test_with_small_stack_and_heap() { - const HEAP_SIZE: u64 = 128 * 1024; - // Leave headroom for legacy transport and eagerly copied page tables. + const HEAP_SIZE: u64 = 40 * 1024; + // min_scratch_size already includes 1 page (4k on most + // platforms) of guest stack, so add 20k more to get 24k + // total, and then add some more for the eagerly-copied page + // tables on amd64 let scratch_size = { let defaults = SandboxConfiguration::default(); hyperlight_common::layout::min_scratch_size( - defaults.get_input_data_size(), - defaults.get_output_data_size(), defaults.get_g2h_queue_size(), defaults.get_h2g_queue_size(), defaults.get_g2h_pool_pages(), defaults.get_h2g_pool_pages(), ) - } + 0x40000; + } + 0x10000 + + 0x10000; let mut sbox1 = SandboxBuilder::from_file(simple_guest_as_pathbuf()) .heap_size(HEAP_SIZE) @@ -1470,6 +1496,87 @@ mod tests { assert_eq!(res, 0); } + #[test] + fn snapshots_checkpoint_only_dirty_transport() { + let mut sandbox = SandboxBuilder::from_file(simple_guest_as_pathbuf()) + .build() + .unwrap(); + + assert!(!sandbox.transport_dirty); + sandbox.call::("AddToStatic", 5i32).unwrap(); + assert!(sandbox.transport_dirty); + + let first = sandbox.snapshot().unwrap(); + assert!(!sandbox.transport_dirty); + let cached = sandbox.snapshot().unwrap(); + assert!(Arc::ptr_eq(&first, &cached)); + assert!(!sandbox.transport_dirty); + + sandbox.call::("AddToStatic", 5i32).unwrap(); + assert!(sandbox.transport_dirty); + sandbox.snapshot().unwrap(); + assert!(!sandbox.transport_dirty); + } + + #[test] + fn snapshots_reject_retained_transport_buffers_without_poisoning() { + let path = simple_guest_as_pathbuf(); + let mut sandbox = UninitializedSandbox::new(GuestBinary::FilePath(path), None).unwrap(); + sandbox + .register("HostEchoByteChunks", |value: Vec| value) + .unwrap(); + + let mut sandbox = sandbox.evolve().unwrap(); + let retained = vec![Bytes::from(vec![0xa5; 6 * 1024])]; + + let retained_len: i32 = sandbox + .call("RetainGuestByteChunks", retained.clone()) + .unwrap(); + + assert_eq!(retained_len, 6 * 1024); + + let Err(error) = sandbox.snapshot() else { + panic!("snapshot with retained H2G buffers succeeded"); + }; + + match error { + HyperlightError::Error(message) => { + assert!(message.contains("transport buffers are retained")) + } + err => unreachable!("unexpected snapshot error: {err:#}"), + } + assert!(!sandbox.status().is_poisoned()); + assert!(sandbox.transport_dirty); + + let released_len: i32 = sandbox.call("ReleaseGuestByteChunks", ()).unwrap(); + assert_eq!(released_len, retained_len); + + sandbox.snapshot().unwrap(); + assert!(!sandbox.transport_dirty); + + let retained_len: i32 = sandbox.call("RetainHostByteChunks", retained).unwrap(); + assert_eq!(retained_len, 6 * 1024); + + let Err(error) = sandbox.snapshot() else { + panic!("snapshot with retained G2H buffers succeeded"); + }; + + match error { + HyperlightError::Error(message) => { + assert!(message.contains("transport buffers are retained")) + } + err => unreachable!("unexpected snapshot error: {err:#}"), + } + assert!(!sandbox.status().is_poisoned()); + assert!(sandbox.transport_dirty); + + let released_len: i32 = sandbox.call("ReleaseHostByteChunks", ()).unwrap(); + assert_eq!(released_len, retained_len); + + sandbox.snapshot().unwrap(); + assert!(!sandbox.transport_dirty); + } + #[test] fn test_trigger_exception_on_guest() { let mut multi_use_sandbox = SandboxBuilder::from_file(simple_guest_as_pathbuf()) @@ -2082,14 +2189,34 @@ mod tests { type LayoutValue = fn(&crate::mem::layout::SandboxMemoryLayout) -> usize; let cases: &[(&str, Configure, LayoutValue)] = &[ ( - "input", - |cfg| cfg.set_input_data_size(0x8000), - |layout| layout.input_data_size(), + "G2H queue size", + |cfg| cfg.set_g2h_queue_size(128), + |layout| layout.get_g2h_queue_size(), ), ( - "output", - |cfg| cfg.set_output_data_size(0x8000), - |layout| layout.output_data_size(), + "H2G queue size", + |cfg| cfg.set_h2g_queue_size(64), + |layout| layout.get_h2g_queue_size(), + ), + ( + "G2H buffer size", + |cfg| cfg.set_g2h_buffer_size(0x2000), + |layout| layout.get_g2h_buffer_size(), + ), + ( + "H2G buffer size", + |cfg| cfg.set_h2g_buffer_size(0x2000), + |layout| layout.get_h2g_buffer_size(), + ), + ( + "G2H pool pages", + |cfg| cfg.set_g2h_pool_pages(16), + |layout| layout.get_g2h_pool_pages(), + ), + ( + "H2G pool pages", + |cfg| cfg.set_h2g_pool_pages(16), + |layout| layout.get_h2g_pool_pages(), ), ( "heap", @@ -2143,7 +2270,7 @@ mod tests { #[test] fn snapshot_restore_recovers_oom_with_larger_heap() { let mut source_cfg = SandboxConfiguration::default(); - source_cfg.set_heap_size(0x40_000); + source_cfg.set_heap_size(0x20_000); let path = simple_guest_as_pathbuf(); let mut source = UninitializedSandbox::new(GuestBinary::FilePath(path), Some(source_cfg)) .unwrap() @@ -2152,7 +2279,7 @@ mod tests { let snapshot = source.snapshot().unwrap(); let mut target_cfg = SandboxConfiguration::default(); - target_cfg.set_heap_size(0x20_000); + target_cfg.set_heap_size(40 * 1024); let path = simple_guest_as_pathbuf(); let mut target = UninitializedSandbox::new(GuestBinary::FilePath(path), Some(target_cfg)) .unwrap() @@ -2173,7 +2300,7 @@ mod tests { #[test] fn snapshot_restore_applies_smaller_heap_limit() { let mut source_cfg = SandboxConfiguration::default(); - source_cfg.set_heap_size(0x20_000); + source_cfg.set_heap_size(40 * 1024); let path = simple_guest_as_pathbuf(); let mut source = UninitializedSandbox::new(GuestBinary::FilePath(path), Some(source_cfg)) .unwrap() @@ -2182,7 +2309,7 @@ mod tests { let snapshot = source.snapshot().unwrap(); let mut target_cfg = SandboxConfiguration::default(); - target_cfg.set_heap_size(0x80_000); + target_cfg.set_heap_size(0x20_000); let path = simple_guest_as_pathbuf(); let mut target = UninitializedSandbox::new(GuestBinary::FilePath(path), Some(target_cfg)) .unwrap() @@ -2190,46 +2317,39 @@ mod tests { .unwrap(); assert_eq!( - target.call::("CallMalloc", 0x30_000i32).unwrap(), - 0x30_000 + target.call::("CallMalloc", 0x10_000i32).unwrap(), + 0x10_000 ); target.restore(snapshot).unwrap(); - assert_eq!(target.mem_mgr.layout.heap_size(), 0x20_000); - assert!(target.call::("CallMalloc", 0x30_000i32).is_err()); + assert_eq!(target.mem_mgr.layout.heap_size(), 40 * 1024); + assert!(target.call::("CallMalloc", 0x10_000i32).is_err()); assert!(target.status().is_poisoned()); } #[test] - fn snapshot_restore_applies_smaller_io_limits() { + fn snapshot_restore_applies_smaller_h2g_capacity() { let mut source_cfg = SandboxConfiguration::default(); - source_cfg.set_heap_size(0x40_000); - source_cfg.set_scratch_size(SandboxConfiguration::DEFAULT_SCRATCH_SIZE + 256 * 1024); - source_cfg.set_input_data_size(0x2000); - source_cfg.set_output_data_size(0x2000); + source_cfg.set_h2g_pool_pages(4); let path = simple_guest_as_pathbuf(); let mut source = UninitializedSandbox::new(GuestBinary::FilePath(path), Some(source_cfg)) .unwrap() .evolve() .unwrap(); let snapshot = source.snapshot().unwrap(); - let mut target_cfg = SandboxConfiguration::default(); - target_cfg.set_heap_size(0x40_000); - target_cfg.set_scratch_size(SandboxConfiguration::DEFAULT_SCRATCH_SIZE + 256 * 1024); - target_cfg.set_input_data_size(0x8000); - target_cfg.set_output_data_size(0x8000); + target_cfg.set_h2g_pool_pages(8); let path = simple_guest_as_pathbuf(); let mut target = UninitializedSandbox::new(GuestBinary::FilePath(path), Some(target_cfg)) .unwrap() .evolve() .unwrap(); - let large = "x".repeat(0x3000); + let large = "x".repeat(4 * hyperlight_common::vmem::PAGE_SIZE); assert_eq!(target.call::("Echo", large.clone()).unwrap(), large); target.restore(snapshot).unwrap(); - assert_eq!(target.mem_mgr.layout.input_data_size(), 0x2000); - assert_eq!(target.mem_mgr.layout.output_data_size(), 0x2000); - assert!(target.call::("Echo", large).is_err()); + assert_eq!(target.mem_mgr.layout.get_h2g_pool_pages(), 4); + let error = target.call::("Echo", large).unwrap_err(); + assert!(error.to_string().contains("H2G capacity")); assert!(!target.status().is_poisoned()); assert_eq!( target.call::("Echo", "small".to_string()).unwrap(), @@ -2240,9 +2360,7 @@ mod tests { #[test] fn snapshot_restore_alternates_different_layouts() { let mut small_cfg = SandboxConfiguration::default(); - small_cfg.set_input_data_size(0x2000); - small_cfg.set_output_data_size(0x2000); - small_cfg.set_heap_size(0x20_000); + small_cfg.set_heap_size(40 * 1024); let path = simple_guest_as_pathbuf(); let mut small = UninitializedSandbox::new(GuestBinary::FilePath(path), Some(small_cfg)) .unwrap() @@ -2252,8 +2370,6 @@ mod tests { let small_snapshot = small.snapshot().unwrap(); let mut large_cfg = SandboxConfiguration::default(); - large_cfg.set_input_data_size(0x8000); - large_cfg.set_output_data_size(0x8000); large_cfg.set_heap_size(0x40_000); large_cfg.set_scratch_size(0x90_000); let path = simple_guest_as_pathbuf(); @@ -2272,7 +2388,7 @@ mod tests { target.restore(small_snapshot.clone()).unwrap(); assert_eq!(target.call::("GetStatic", ()).unwrap(), 11); - assert_eq!(target.mem_mgr.layout.heap_size(), 0x20_000); + assert_eq!(target.mem_mgr.layout.heap_size(), 40 * 1024); target.restore(large_snapshot).unwrap(); assert_eq!(target.call::("GetStatic", ()).unwrap(), 22); @@ -2280,7 +2396,7 @@ mod tests { target.restore(small_snapshot).unwrap(); assert_eq!(target.call::("GetStatic", ()).unwrap(), 11); - assert_eq!(target.mem_mgr.layout.heap_size(), 0x20_000); + assert_eq!(target.mem_mgr.layout.heap_size(), 40 * 1024); } #[test] diff --git a/src/hyperlight_host/src/sandbox/outb.rs b/src/hyperlight_host/src/sandbox/outb.rs index 0d7322f11b..5c3318f146 100644 --- a/src/hyperlight_host/src/sandbox/outb.rs +++ b/src/hyperlight_host/src/sandbox/outb.rs @@ -3,11 +3,14 @@ use std::sync::{Arc, Mutex}; -use hyperlight_common::flatbuffer_wrappers::function_types::{FunctionCallResult, ParameterValue}; +use hyperlight_common::flatbuffer_wrappers::function_call::FunctionCallType; +use hyperlight_common::flatbuffer_wrappers::function_types::FunctionCallResult; use hyperlight_common::flatbuffer_wrappers::guest_error::{ErrorCode, GuestError}; use hyperlight_common::flatbuffer_wrappers::guest_log_data::GuestLogData; use hyperlight_common::flatbuffer_wrappers::guest_log_level::LogLevel; use hyperlight_common::outb::{Exception, OutBAction}; +use hyperlight_common::transport::MsgKind; +use hyperlight_common::virtq::ReplyChain; use tracing::{Span, instrument}; use super::host_funcs::FunctionRegistry; @@ -15,6 +18,7 @@ use super::host_funcs::FunctionRegistry; use crate::hypervisor::regs::CommonRegisters; use crate::mem::mgr::SandboxMemoryManager; use crate::mem::shared_mem::HostSharedMemory; +use crate::mem::virtq; #[cfg(feature = "mem_profile")] use crate::sandbox::trace::MemTraceInfo; @@ -30,8 +34,6 @@ pub enum HandleOutbError { }, #[error("Invalid outb port: {0}")] InvalidPort(String), - #[error("Failed to read guest log data: {0}")] - ReadLogData(String), #[error("Failed to read host function call: {0}")] ReadHostFunctionCall(String), #[error("Failed to acquire lock at {0}:{1} - {2}")] @@ -45,14 +47,7 @@ pub enum HandleOutbError { MemProfile(String), } -#[instrument(err(Debug), skip_all, parent = Span::current(), level="Trace")] -pub(super) fn outb_log( - mgr: &mut SandboxMemoryManager, -) -> Result<(), HandleOutbError> { - let log_data: GuestLogData = mgr - .read_guest_log_data() - .map_err(|e| HandleOutbError::ReadLogData(e.to_string()))?; - +pub(crate) fn emit_guest_log(log_data: &GuestLogData) { // Emit guest log data as a tracing event with structured fields. // // We match on the level at runtime because tracing macros determine their @@ -121,8 +116,6 @@ pub(super) fn outb_log( ); } } - - Ok(()) } const ABORT_TERMINATOR: u8 = 0xFF; @@ -191,27 +184,7 @@ pub(crate) fn handle_outb( .try_into() .map_err(|e: anyhow::Error| HandleOutbError::InvalidPort(e.to_string()))? { - OutBAction::Log => outb_log(mem_mgr), - OutBAction::CallFunction => { - let call = mem_mgr - .get_host_function_call() - .map_err(|e| HandleOutbError::ReadHostFunctionCall(e.to_string()))?; - let name = call.function_name.clone(); - let args: Vec = call.parameters.unwrap_or(vec![]); - let res = host_funcs - .try_lock() - .map_err(|e| HandleOutbError::LockFailed(file!(), line!(), e.to_string()))? - .call_host_function(&name, args) - .map_err(|e| GuestError::new(ErrorCode::HostFunctionError, e.to_string())); - - let func_result = FunctionCallResult::new(res); - - mem_mgr - .write_response_from_host_function_call(&func_result) - .map_err(|e| HandleOutbError::WriteHostFunctionResponse(e.to_string()))?; - - Ok(()) - } + OutBAction::VirtqNotify => outb_virtq_call(mem_mgr, host_funcs), OutBAction::Abort => outb_abort(mem_mgr, data), OutBAction::DebugPrint => { let ch: char = match char::from_u32(data) { @@ -232,18 +205,141 @@ pub(crate) fn handle_outb( OutBAction::TraceMemoryFree => trace_info.handle_trace_mem_free(regs, mem_mgr), } } + +/// Drain G2H messages published before this notification. +fn outb_virtq_call( + mem_mgr: &mut SandboxMemoryManager, + host_funcs: &Arc>, +) -> Result<(), HandleOutbError> { + let max_recv_len = mem_mgr.layout.get_g2h_queue_dims().pool_len(); + + let Some(consumer) = mem_mgr.g2h_consumer.as_mut() else { + return Err(HandleOutbError::ReadHostFunctionCall( + "G2H consumer is not attached".into(), + )); + }; + + // Drain entries, processing logs, until we find one call. + let (mut request, reply, header) = loop { + let maybe_next = consumer.poll(max_recv_len).map_err(|error| { + HandleOutbError::ReadHostFunctionCall(format!("G2H poll failed: {error}")) + })?; + + let Some((mut request, reply)) = maybe_next else { + // No entry can be a backpressure or prefill notification. + return Ok(()); + }; + + let header = virtq::read_message_header(&mut request) + .map_err(|error| HandleOutbError::ReadHostFunctionCall(error.to_string()))?; + + match header.msg_kind() { + Ok(MsgKind::Request) => break (request, reply, header), + Ok(MsgKind::Log) => { + if header.cid != 0 { + return Err(HandleOutbError::ReadHostFunctionCall( + "G2H log has a nonzero correlation ID".into(), + )); + } + + if !matches!(reply, ReplyChain::Ack(_)) { + return Err(HandleOutbError::ReadHostFunctionCall( + "G2H log has writable response buffers".into(), + )); + } + + let log = virtq::read_guest_log_data(&mut request) + .map_err(|error| HandleOutbError::ReadHostFunctionCall(error.to_string()))?; + + emit_guest_log(&log); + + consumer.complete(request, reply).map_err(|error| { + HandleOutbError::ReadHostFunctionCall(format!( + "G2H log completion failed: {error}" + )) + })?; + } + Ok(kind) => { + return Err(HandleOutbError::ReadHostFunctionCall(format!( + "Expected G2H request, got {kind:?}" + ))); + } + Err(kind) => { + return Err(HandleOutbError::ReadHostFunctionCall(format!( + "Unknown G2H message kind {kind:#x}" + ))); + } + } + }; + + if header.cid == 0 { + return Err(HandleOutbError::ReadHostFunctionCall( + "G2H request has correlation ID zero".into(), + )); + } + + let mut resp = reply.into_writable().map_err(|_| { + HandleOutbError::WriteHostFunctionResponse( + "G2H request has no writable response buffers".into(), + ) + })?; + + let call = virtq::get_host_function_call(&mut request) + .map_err(|error| HandleOutbError::ReadHostFunctionCall(error.to_string()))?; + + if call.function_call_type() != FunctionCallType::Host { + return Err(HandleOutbError::ReadHostFunctionCall( + "G2H request does not target a host function".into(), + )); + } + + let name = call.function_name; + let args = call.parameters.unwrap_or_default(); + + let result = host_funcs + .try_lock() + .map_err(|err| HandleOutbError::LockFailed(file!(), line!(), err.to_string()))? + .call_host_function(&name, args) + .map_err(|err| GuestError::new(ErrorCode::HostFunctionError, err.to_string())); + + let result = FunctionCallResult::new(result); + let resp_capacity = resp.capacity(); + + // Capacity is checked before writing, so an oversized result leaves the + // chain untouched and can be replaced with a bounded transport error. + if !virtq::try_write_response(&mut resp, header.cid, &result) + .map_err(|err| HandleOutbError::WriteHostFunctionResponse(err.to_string()))? + { + let fallback = FunctionCallResult::new(Err(GuestError::new( + ErrorCode::HostFunctionError, + "Host response exceeds virtqueue capacity".into(), + ))); + + // The guest must receive a response for this correlation id. Failure + // to fit even this small error makes the transport unusable. + if !virtq::try_write_response(&mut resp, header.cid, &fallback) + .map_err(|err| HandleOutbError::WriteHostFunctionResponse(err.to_string()))? + { + return Err(HandleOutbError::WriteHostFunctionResponse(format!( + "Writable response capacity {resp_capacity} cannot hold a transport error" + ))); + } + } + + consumer + .complete(request, resp) + .map_err(|err| HandleOutbError::WriteHostFunctionResponse(err.to_string()))?; + + Ok(()) +} + #[cfg(test)] mod tests { use hyperlight_common::flatbuffer_wrappers::guest_log_level::LogLevel; use hyperlight_testing::logger::{LOGGER, Logger}; - use hyperlight_testing::simple_guest_as_pathbuf; use tracing_core::callsite::rebuild_interest_cache; - use super::outb_log; - use crate::GuestBinary; - use crate::mem::mgr::SandboxMemoryManager; - use crate::sandbox::SandboxConfiguration; - use crate::sandbox::outb::GuestLogData; + use super::{GuestLogData, emit_guest_log}; use crate::testing::log_values::test_value_as_str; fn new_guest_log_data(level: LogLevel) -> GuestLogData { @@ -258,140 +354,70 @@ mod tests { } // Verifies that guest log events are forwarded to a `log` logger when no - // tracing subscriber is set. This exercises the `tracing` crate's built-in - // `log` compatibility feature, proving that consumers who only set up a - // `log` logger (not a tracing subscriber) still receive guest output. + // tracing subscriber is set. #[test] #[ignore] - fn test_log_outb_log() { + fn test_log_emit_guest_log() { Logger::initialize_test_logger(); LOGGER.set_max_level(log::LevelFilter::Off); - let sandbox_cfg = SandboxConfiguration::default(); - - let new_mgr = || { - let bin = GuestBinary::FilePath(simple_guest_as_pathbuf()); - let snapshot = crate::sandbox::snapshot::Snapshot::from_env(bin, sandbox_cfg).unwrap(); - let mgr = SandboxMemoryManager::from_snapshot(&snapshot).unwrap(); - let (hmgr, _) = mgr.build().unwrap(); - hmgr - }; - { - // We set a logger but there is no guest log data - // in memory, so expect a log operation to fail - let mut mgr = new_mgr(); - assert!(outb_log(&mut mgr).is_err()); - } - { - // Write a log message so outb_log will succeed. - // Since the logger level is set off, expect logs to be no-ops - let mut mgr = new_mgr(); - let log_msg = new_guest_log_data(LogLevel::Information); - - let guest_log_data_buffer: Vec = log_msg.try_into().unwrap(); - let offset = mgr.layout.get_output_data_buffer_scratch_host_offset(); - mgr.scratch_mem - .push_buffer( - offset, - sandbox_cfg.get_output_data_size(), - &guest_log_data_buffer, - ) - .unwrap(); - - let res = outb_log(&mut mgr); - assert!(res.is_ok()); - assert_eq!(0, LOGGER.num_log_calls()); - LOGGER.clear_log_calls(); - } - { - // now, test logging - LOGGER.set_max_level(log::LevelFilter::Trace); - let mut mgr = new_mgr(); + emit_guest_log(&new_guest_log_data(LogLevel::Information)); + assert_eq!(0, LOGGER.num_log_calls()); + LOGGER.clear_log_calls(); + + LOGGER.set_max_level(log::LevelFilter::Trace); + let levels = vec![ + LogLevel::Trace, + LogLevel::Debug, + LogLevel::Information, + LogLevel::Warning, + LogLevel::Error, + LogLevel::Critical, + LogLevel::None, + ]; + + for level in levels { LOGGER.clear_log_calls(); + emit_guest_log(&new_guest_log_data(level)); + + LOGGER.test_log_records(|log_calls| { + let expected_level: tracing::Level = match level { + LogLevel::Trace => tracing::Level::TRACE, + LogLevel::Debug => tracing::Level::DEBUG, + LogLevel::Information => tracing::Level::INFO, + LogLevel::Warning => tracing::Level::WARN, + LogLevel::Error | LogLevel::Critical => tracing::Level::ERROR, + LogLevel::None => tracing::Level::TRACE, + }; - // set up the logger and set the log level to the maximum - // possible (Trace) to ensure we're able to test all - // the possible branches of the match in outb_log - - let levels = vec![ - LogLevel::Trace, - LogLevel::Debug, - LogLevel::Information, - LogLevel::Warning, - LogLevel::Error, - LogLevel::Critical, - LogLevel::None, - ]; - for level in levels { - let layout = mgr.layout; - let log_data = new_guest_log_data(level); - - let guest_log_data_buffer: Vec = log_data.clone().try_into().unwrap(); - mgr.scratch_mem - .push_buffer( - layout.get_output_data_buffer_scratch_host_offset(), - sandbox_cfg.get_output_data_size(), - guest_log_data_buffer.as_slice(), - ) - .unwrap(); - - outb_log(&mut mgr).unwrap(); - - LOGGER.test_log_records(|log_calls| { - let expected_level: tracing::Level = match level { - LogLevel::Trace => tracing::Level::TRACE, - LogLevel::Debug => tracing::Level::DEBUG, - LogLevel::Information => tracing::Level::INFO, - LogLevel::Warning => tracing::Level::WARN, - LogLevel::Error => tracing::Level::ERROR, - LogLevel::Critical => tracing::Level::ERROR, - LogLevel::None => tracing::Level::TRACE, - }; - - assert!( - log_calls - .iter() - .filter(|log_call| { - log_call.level.as_str() == expected_level.as_str() - && log_call.args.contains("test log") - }) - .count() - == 1, - "log call did not occur for level {:?}", - level.clone() - ); - }); - } + assert_eq!( + log_calls + .iter() + .filter(|log_call| { + log_call.level.as_str() == expected_level.as_str() + && log_call.args.contains("test log") + }) + .count(), + 1, + "log call did not occur for level {level:?}" + ); + }); } } - // Tests that outb_log emits traces when a trace subscriber is set + // Tests that guest logs emit traces when a trace subscriber is set // this test is ignored because it is incompatible with other tests , specifically those which require a logger for tracing // marking this test as ignored means that running `cargo test` will not run this test but will allow a developer who runs that command // from their workstation to be successful without needed to know about test interdependencies // this test will be run explicitly as a part of the CI pipeline #[ignore] #[test] - fn test_trace_outb_log() { + fn test_trace_emit_guest_log() { Logger::initialize_log_tracer(); rebuild_interest_cache(); let subscriber = hyperlight_testing::tracing_subscriber::TracingSubscriber::new(tracing::Level::TRACE); - let sandbox_cfg = SandboxConfiguration::default(); tracing::subscriber::with_default(subscriber.clone(), || { - let new_mgr = || { - let bin = GuestBinary::FilePath(simple_guest_as_pathbuf()); - let snapshot = - crate::sandbox::snapshot::Snapshot::from_env(bin, sandbox_cfg).unwrap(); - let mgr = SandboxMemoryManager::from_snapshot(&snapshot).unwrap(); - let (hmgr, _) = mgr.build().unwrap(); - hmgr - }; - - // as a span does not exist one will be automatically created - // after that there will be an event for each log message - // we are interested only in the events for the log messages that we created - let levels = vec![ LogLevel::Trace, LogLevel::Debug, @@ -402,23 +428,11 @@ mod tests { LogLevel::None, ]; for level in levels { - let mut mgr = new_mgr(); - let layout = mgr.layout; let log_data: GuestLogData = new_guest_log_data(level); subscriber.clear(); + emit_guest_log(&log_data); - let guest_log_data_buffer: Vec = log_data.try_into().unwrap(); - mgr.scratch_mem - .push_buffer( - layout.get_output_data_buffer_scratch_host_offset(), - sandbox_cfg.get_output_data_size(), - guest_log_data_buffer.as_slice(), - ) - .unwrap(); - subscriber.clear(); - outb_log(&mut mgr).unwrap(); - - subscriber.test_trace_records(|spans, events| { + subscriber.test_trace_records(|_, events| { let expected_level = match level { LogLevel::Trace => "TRACE", LogLevel::Debug => "DEBUG", @@ -429,38 +443,6 @@ mod tests { LogLevel::None => "TRACE", }; - // We cannot get the parent span using the `current_span()` method as by the time we get to this point that span has been exited so there is no current span - // We need to make sure that the span that we created is in the spans map instead - // We are only interested in the first one that was created when calling outb_log. - - assert!(!spans.is_empty(), "expected at least one span, found none"); - - let span_value = spans - .get(&1) - .unwrap() - .as_object() - .unwrap() - .get("span") - .unwrap() - .get("attributes") - .unwrap() - .as_object() - .unwrap() - .get("metadata") - .unwrap() - .as_object() - .unwrap(); - - //test_value_as_str(span_value, "level", "INFO"); - test_value_as_str(span_value, "module_path", "hyperlight_host::sandbox::outb"); - let expected_file = if cfg!(windows) { - "src\\hyperlight_host\\src\\sandbox\\outb.rs" - } else { - "src/hyperlight_host/src/sandbox/outb.rs" - }; - test_value_as_str(span_value, "file", expected_file); - test_value_as_str(span_value, "target", "hyperlight_host::sandbox::outb"); - let mut count_matching_events = 0; for json_value in events { diff --git a/src/hyperlight_host/src/sandbox/snapshot/file/config.rs b/src/hyperlight_host/src/sandbox/snapshot/file/config.rs index b50fca0357..c23796e059 100644 --- a/src/hyperlight_host/src/sandbox/snapshot/file/config.rs +++ b/src/hyperlight_host/src/sandbox/snapshot/file/config.rs @@ -206,8 +206,6 @@ pub(super) struct OciSnapshotConfig { #[derive(Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub(super) struct MemoryLayout { - pub(super) input_data_size: usize, - pub(super) output_data_size: usize, pub(super) heap_size: usize, pub(super) code_size: usize, pub(super) init_data_size: usize, @@ -471,8 +469,6 @@ impl OciSnapshotConfig { // checked against `snapshot_size` in `load_inner`. let max_region = SandboxMemoryLayout::MAX_MEMORY_SIZE; for (name, value) in [ - ("input_data_size", self.layout.input_data_size), - ("output_data_size", self.layout.output_data_size), ("heap_size", self.layout.heap_size), ("code_size", self.layout.code_size), ("init_data_size", self.layout.init_data_size), @@ -834,8 +830,6 @@ mod tests { #[cfg(target_arch = "x86_64")] msrs: Vec::new(), layout: MemoryLayout { - input_data_size: 0, - output_data_size: 0, heap_size: 0, code_size: 0, init_data_size: 0, @@ -1072,8 +1066,6 @@ mod schema_pin { } ], "layout": { - "input_data_size": 1, - "output_data_size": 2, "heap_size": 3, "code_size": 4, "init_data_size": 5, @@ -1120,8 +1112,6 @@ mod schema_pin { "sp_el1": 6 }, "layout": { - "input_data_size": 1, - "output_data_size": 2, "heap_size": 3, "code_size": 4, "init_data_size": 5, diff --git a/src/hyperlight_host/src/sandbox/snapshot/file/media_types.rs b/src/hyperlight_host/src/sandbox/snapshot/file/media_types.rs index 8ec23eed23..249ca4277c 100644 --- a/src/hyperlight_host/src/sandbox/snapshot/file/media_types.rs +++ b/src/hyperlight_host/src/sandbox/snapshot/file/media_types.rs @@ -12,6 +12,9 @@ pub(in crate::sandbox::snapshot) const MT_CONFIG_CURRENT: &str = MT_CONFIG_V2; pub(in crate::sandbox::snapshot) const MT_SNAPSHOT_V1: &str = "application/vnd.hyperlight.snapshot.memory.v1"; pub(in crate::sandbox::snapshot) const MT_SNAPSHOT_CURRENT: &str = MT_SNAPSHOT_V1; +pub(in crate::sandbox::snapshot) const MT_TRANSPORT_V1: &str = + "application/vnd.hyperlight.snapshot.transport.v1"; +pub(in crate::sandbox::snapshot) const MT_TRANSPORT_CURRENT: &str = MT_TRANSPORT_V1; /// ABI version for the snapshot memory blob. Bumped when the /// host-guest contract for the snapshot bytes changes. See diff --git a/src/hyperlight_host/src/sandbox/snapshot/file/mod.rs b/src/hyperlight_host/src/sandbox/snapshot/file/mod.rs index 84fd337232..6049dcfb91 100644 --- a/src/hyperlight_host/src/sandbox/snapshot/file/mod.rs +++ b/src/hyperlight_host/src/sandbox/snapshot/file/mod.rs @@ -27,13 +27,14 @@ use self::media_types::{ }; pub(super) use self::media_types::{ MT_CONFIG_CURRENT, MT_CONFIG_V1, MT_CONFIG_V2, MT_SNAPSHOT_CURRENT, MT_SNAPSHOT_V1, - SNAPSHOT_ABI_VERSION, + MT_TRANSPORT_CURRENT, MT_TRANSPORT_V1, SNAPSHOT_ABI_VERSION, }; use self::reference::{OciDigest, OciReference, OciTag}; use super::{NextAction, Snapshot}; use crate::mem::layout::SandboxMemoryLayout; use crate::mem::memory_region::MemoryRegionFlags; use crate::mem::shared_mem::{ReadonlySharedMemory, SharedMemory}; +use crate::mem::virtq::VirtqSnapshot; pub(super) const OCI_LAYOUT_VERSION: &str = "1.0.0"; @@ -49,6 +50,10 @@ pub fn host_cpu_vendor_golden_tag() -> Option<&'static str> { /// `oci-layout`, `index.json`, the OCI image manifest, and the /// Hyperlight config blob. Bounds the allocation done before parsing. const MAX_JSON_BLOB_SIZE: u64 = 1024 * 1024; +const MAX_TRANSPORT_BLOB_SIZE: u64 = 2 * 1024 * 1024; +const TRANSPORT_MAGIC: [u8; 8] = *b"HLVQSNAP"; +const TRANSPORT_VERSION: u32 = 1; +const TRANSPORT_HEADER_LEN: usize = 40; /// Reject a JSON artifact larger than the cap the loader reads with /// [`read_bounded`]. The writer holds to the same cap so every layout @@ -65,6 +70,109 @@ fn check_json_blob_size(what: &str, len: usize) -> crate::Result<()> { Ok(()) } +fn encode_transport(snapshot: &VirtqSnapshot) -> crate::Result> { + let g2h_len = snapshot.g2h_ring().len(); + let h2g_len = snapshot.h2g_ring().len(); + + let total_len = TRANSPORT_HEADER_LEN + .checked_add(g2h_len) + .and_then(|len| len.checked_add(h2g_len)) + .ok_or_else(|| crate::new_error!("snapshot transport length overflow"))?; + + if total_len as u64 > MAX_TRANSPORT_BLOB_SIZE { + return Err(crate::new_error!( + "transport blob of {total_len} bytes exceeds the {MAX_TRANSPORT_BLOB_SIZE} byte maximum" + )); + } + + let mut bytes = Vec::new(); + bytes + .try_reserve_exact(total_len) + .map_err(|error| crate::new_error!("failed to allocate transport blob: {error}"))?; + + bytes.extend_from_slice(&TRANSPORT_MAGIC); + bytes.extend_from_slice(&TRANSPORT_VERSION.to_le_bytes()); + bytes.extend_from_slice(&0u32.to_le_bytes()); + bytes.extend_from_slice(&u64::try_from(snapshot.scratch_size())?.to_le_bytes()); + bytes.extend_from_slice(&u64::try_from(g2h_len)?.to_le_bytes()); + bytes.extend_from_slice(&u64::try_from(h2g_len)?.to_le_bytes()); + bytes.extend_from_slice(snapshot.g2h_ring()); + bytes.extend_from_slice(snapshot.h2g_ring()); + Ok(bytes) +} + +fn read_transport_field(bytes: &mut &[u8], field: &str) -> crate::Result<[u8; N]> { + let (value, remaining) = bytes + .split_at_checked(N) + .ok_or_else(|| crate::new_error!("snapshot transport {field} is truncated"))?; + + let mut array = [0; N]; + array.copy_from_slice(value); + + *bytes = remaining; + Ok(array) +} + +fn decode_transport(bytes: &[u8]) -> crate::Result { + let total_len = bytes.len(); + let mut bytes = bytes; + + if read_transport_field(&mut bytes, "magic")? != TRANSPORT_MAGIC { + return Err(crate::new_error!("snapshot transport magic is invalid")); + } + + let version = u32::from_le_bytes(read_transport_field(&mut bytes, "version")?); + if version != TRANSPORT_VERSION { + return Err(crate::new_error!( + "snapshot transport version mismatch: file has version {version}, this build expects {TRANSPORT_VERSION}" + )); + } + + let reserved = u32::from_le_bytes(read_transport_field(&mut bytes, "reserved field")?); + if reserved != 0 { + return Err(crate::new_error!( + "snapshot transport reserved field is nonzero" + )); + } + + let scratch_size = usize::try_from(u64::from_le_bytes(read_transport_field( + &mut bytes, + "scratch size", + )?))?; + + let g2h_len = usize::try_from(u64::from_le_bytes(read_transport_field( + &mut bytes, + "G2H ring length", + )?))?; + + let h2g_len = usize::try_from(u64::from_le_bytes(read_transport_field( + &mut bytes, + "H2G ring length", + )?))?; + + let expected_len = TRANSPORT_HEADER_LEN + .checked_add(g2h_len) + .and_then(|len| len.checked_add(h2g_len)) + .ok_or_else(|| crate::new_error!("snapshot transport length overflow"))?; + + if total_len != expected_len { + return Err(crate::new_error!( + "snapshot transport length {} does not match header length {expected_len}", + total_len + )); + } + + let (g2h_ring, h2g_ring) = bytes + .split_at_checked(g2h_len) + .ok_or_else(|| crate::new_error!("snapshot transport G2H ring is truncated"))?; + + Ok(VirtqSnapshot::new( + scratch_size, + g2h_ring.to_vec(), + h2g_ring.to_vec(), + )) +} + /// Select one manifest descriptor from `index` by `reference`. /// /// A tag matches the `org.opencontainers.image.ref.name` annotation @@ -273,6 +381,27 @@ fn open_snapshot_blob( Ok(snap_file) } +fn load_transport_blob( + blobs_dir: &Path, + transport_desc: &Descriptor, + verify_blobs: bool, +) -> crate::Result> { + let transport_hex = parse_oci_digest(transport_desc.digest())?; + let transport_path = blobs_dir.join(&transport_hex); + let bytes = read_bounded(&transport_path, MAX_TRANSPORT_BLOB_SIZE)?; + if bytes.len() as u64 != transport_desc.size() { + return Err(crate::new_error!( + "transport blob size mismatch: descriptor says {}, file is {}", + transport_desc.size(), + bytes.len() + )); + } + if verify_blobs { + verify_blob_bytes("transport", &bytes, &transport_hex)?; + } + Ok(bytes) +} + impl Snapshot { /// Save this snapshot into an OCI Image Layout directory on disk. /// The saved snapshot can be loaded later with @@ -503,6 +632,14 @@ impl Snapshot { let snapshot_digest = Digest256::from_bytes(memory_bytes); put_blob_if_absent(&blobs_dir, &snapshot_digest, memory_bytes)?; + // Transport blob: the canonical ring image omitted from memory. + let transport = self.virtq.as_ref().ok_or_else(|| { + crate::new_error!("initialized snapshot has no canonical transport state") + })?; + let transport_bytes = encode_transport(transport)?; + let transport_digest = Digest256::from_bytes(&transport_bytes); + put_blob(&blobs_dir, &transport_digest, &transport_bytes)?; + // Config blob. let cfg_digest = Digest256::from_bytes(cfg_bytes); put_blob(&blobs_dir, &cfg_digest, cfg_bytes)?; @@ -520,6 +657,12 @@ impl Snapshot { .size(memory_size as u64) .build() .map_err(|e| crate::new_error!("failed to build snapshot descriptor: {}", e))?; + let transport_descriptor = DescriptorBuilder::default() + .media_type(MediaType::Other(MT_TRANSPORT_CURRENT.to_string())) + .digest(oci_digest(&transport_digest)?) + .size(transport_bytes.len() as u64) + .build() + .map_err(|e| crate::new_error!("failed to build transport descriptor: {}", e))?; // `artifactType` is set equal to `config.mediaType` per OCI // image-spec "Guidelines for Artifact Usage". Registries // surface this on the distribution-spec referrers API. Tools @@ -529,7 +672,7 @@ impl Snapshot { .media_type(MediaType::ImageManifest) .artifact_type(MediaType::Other(MT_CONFIG_CURRENT.to_string())) .config(config_descriptor) - .layers(vec![snapshot_descriptor]) + .layers(vec![snapshot_descriptor, transport_descriptor]) .build() .map_err(|e| crate::new_error!("failed to build OCI manifest: {}", e))?; let manifest_bytes = serde_json::to_vec_pretty(&manifest) @@ -578,6 +721,10 @@ impl Snapshot { )); } }; + let transport = self.virtq.as_ref().ok_or_else(|| { + crate::new_error!("initialized snapshot has no canonical transport state") + })?; + transport.preflight(&self.layout)?; let host_functions = match &self.host_functions.host_functions { Some(v) => v.iter().map(HostFunction::from).collect(), @@ -603,8 +750,6 @@ impl Snapshot { .ok_or_else(|| crate::new_error!("snapshot has no MSR state"))? .clone(), layout: MemoryLayout { - input_data_size: l.input_data_size(), - output_data_size: l.output_data_size(), heap_size: l.heap_size(), code_size: l.code_size(), init_data_size: l.init_data_size(), @@ -661,7 +806,7 @@ impl Snapshot { /// /// # Verification /// - /// This method does not check the manifest, config, or snapshot + /// This method does not check the manifest, config, memory, or transport /// blobs against their recorded sha256 digests. Load only from a /// layout you trust. /// @@ -697,7 +842,7 @@ impl Snapshot { /// Loads a snapshot like [`Snapshot::load`]. See its rustdoc for /// `path`, `reference`, portability, and the file-mutation /// hazard. This method additionally checks the manifest, config, - /// and snapshot blobs against their recorded sha256 digests + /// memory, and transport blobs against their recorded sha256 digests /// before use, at the expense of some performance. /// /// # Trust @@ -779,9 +924,9 @@ impl Snapshot { } } let layers = manifest.layers(); - if layers.len() != 1 { + if layers.len() != 2 { return Err(crate::new_error!( - "expected exactly one OCI layer (the snapshot), found {}", + "expected exactly two OCI layers (memory and transport), found {}", layers.len() )); } @@ -797,6 +942,18 @@ impl Snapshot { )); } } + let transport_desc = &layers[1]; + let transport_media = transport_desc.media_type().to_string(); + match transport_media.as_str() { + MT_TRANSPORT_V1 => {} + other => { + return Err(crate::new_error!( + "unexpected transport layer media type {:?} (supported: {:?})", + other, + MT_TRANSPORT_V1 + )); + } + } // 4. config blob let cfg = load_config(&blobs_dir, cfg_desc, verify_blobs)?; @@ -805,11 +962,11 @@ impl Snapshot { // handle so an attacker cannot swap the file between // verification and mapping. let snap_file = open_snapshot_blob(&blobs_dir, snap_desc, cfg.memory_size, verify_blobs)?; + let transport_bytes = load_transport_blob(&blobs_dir, transport_desc, verify_blobs)?; + let virtq = decode_transport(&transport_bytes)?; // 6. Reconstruct layout. let mut sbox_cfg = crate::sandbox::SandboxConfiguration::default(); - sbox_cfg.set_input_data_size(cfg.layout.input_data_size); - sbox_cfg.set_output_data_size(cfg.layout.output_data_size); sbox_cfg.set_heap_size(cfg.layout.heap_size as u64); sbox_cfg.set_scratch_size(cfg.layout.scratch_size); sbox_cfg.set_g2h_queue_size(cfg.layout.g2h_queue_size); @@ -883,6 +1040,7 @@ impl Snapshot { // 8. Build the next action + sregs back from the config. let next_action = NextAction::Call(cfg.entrypoint_addr); + virtq.preflight(&layout)?; // 9. Reconstitute host_functions metadata. let snapshot_generation = cfg.snapshot_generation; @@ -911,7 +1069,78 @@ impl Snapshot { original_entrypoint: cfg.original_entrypoint_addr, snapshot_generation, host_functions, - virtq: None, + virtq: Some(virtq), }) } } + +#[cfg(test)] +mod transport_tests { + use super::*; + + #[test] + fn transport_blob_round_trips() { + let snapshot = VirtqSnapshot::new(0x20_000, vec![1, 2, 3], vec![4, 5]); + let bytes = encode_transport(&snapshot).unwrap(); + let decoded = decode_transport(&bytes).unwrap(); + + assert_eq!(decoded.scratch_size(), snapshot.scratch_size()); + assert_eq!(decoded.g2h_ring(), snapshot.g2h_ring()); + assert_eq!(decoded.h2g_ring(), snapshot.h2g_ring()); + } + + #[test] + fn transport_blob_rejects_header_corruption() { + let snapshot = VirtqSnapshot::new(0x20_000, vec![1], vec![2]); + let mut bytes = encode_transport(&snapshot).unwrap(); + + bytes[8..12].copy_from_slice(&TRANSPORT_VERSION.wrapping_add(1).to_le_bytes()); + assert!( + decode_transport(&bytes) + .unwrap_err() + .to_string() + .contains("version mismatch") + ); + + bytes[8..12].copy_from_slice(&TRANSPORT_VERSION.to_le_bytes()); + bytes[12] = 1; + assert!( + decode_transport(&bytes) + .unwrap_err() + .to_string() + .contains("reserved") + ); + } + + #[test] + fn transport_blob_rejects_truncated_header_fields() { + let snapshot = VirtqSnapshot::new(0x20_000, vec![1], vec![2]); + let bytes = encode_transport(&snapshot).unwrap(); + + for (len, field) in [ + (0, "magic"), + (8, "version"), + (12, "reserved field"), + (16, "scratch size"), + (24, "G2H ring length"), + (32, "H2G ring length"), + ] { + let error = decode_transport(&bytes[..len]).unwrap_err(); + assert!(error.to_string().contains(field), "{error:?}"); + } + } + + #[test] + fn transport_blob_rejects_length_mismatch() { + let snapshot = VirtqSnapshot::new(0x20_000, vec![1], vec![2]); + let mut bytes = encode_transport(&snapshot).unwrap(); + bytes.push(3); + + assert!( + decode_transport(&bytes) + .unwrap_err() + .to_string() + .contains("does not match") + ); + } +} diff --git a/src/hyperlight_host/src/sandbox/snapshot/file_tests.rs b/src/hyperlight_host/src/sandbox/snapshot/file_tests.rs index f9900ef922..45f0242509 100644 --- a/src/hyperlight_host/src/sandbox/snapshot/file_tests.rs +++ b/src/hyperlight_host/src/sandbox/snapshot/file_tests.rs @@ -100,6 +100,25 @@ fn find_snapshot_blob(oci_dir: &std::path::Path) -> std::path::PathBuf { oci_dir.join("blobs").join("sha256").join(snap_digest) } +/// Locate the transport (layer 1) blob inside `oci_dir`. +fn find_transport_blob(oci_dir: &std::path::Path) -> std::path::PathBuf { + let index: Value = + serde_json::from_slice(&std::fs::read(oci_dir.join("index.json")).unwrap()).unwrap(); + let manifest_digest = index["manifests"][0]["digest"] + .as_str() + .unwrap() + .strip_prefix("sha256:") + .unwrap(); + let manifest_path = oci_dir.join("blobs").join("sha256").join(manifest_digest); + let manifest: Value = serde_json::from_slice(&std::fs::read(&manifest_path).unwrap()).unwrap(); + let transport_digest = manifest["layers"][1]["digest"] + .as_str() + .unwrap() + .strip_prefix("sha256:") + .unwrap(); + oci_dir.join("blobs").join("sha256").join(transport_digest) +} + // In-memory `from_snapshot` round-trips. #[test] @@ -1405,8 +1424,7 @@ fn save_same_tag_same_content_is_idempotent() { ); } -/// Two tags written from one in-memory snapshot share all three blobs -/// (manifest, config, snapshot). +/// Two tags written from one in-memory snapshot share all four blobs. #[test] fn save_shares_blobs_across_tags_with_identical_content() { let snap = create_snapshot(); @@ -1420,7 +1438,7 @@ fn save_shares_blobs_across_tags_with_identical_content() { .unwrap() .filter_map(|e| e.ok().map(|e| e.file_name())) .collect(); - assert_eq!(blobs.len(), 3, "expected 3 deduped blobs, got {:?}", blobs); + assert_eq!(blobs.len(), 4, "expected 4 deduped blobs, got {:?}", blobs); } /// Replacing one tag in a three-tag layout keeps the other two @@ -1580,6 +1598,28 @@ fn checked_load_rejects_snapshot_blob_byte_mutation() { ); } +#[test] +fn checked_load_rejects_transport_blob_byte_mutation() { + let snapshot = create_snapshot(); + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("snap"); + snapshot + .save(&path, &OciTag::new("latest").unwrap()) + .unwrap(); + + let transport_path = find_transport_blob(&path); + let mut bytes = std::fs::read(&transport_path).unwrap(); + let mid = bytes.len() / 2; + bytes[mid] ^= 0xFF; + std::fs::write(&transport_path, bytes).unwrap(); + + let err = unwrap_err_snapshot(Snapshot::checked_load( + &path, + OciTag::new("latest").unwrap(), + )); + assert_err_contains(err, "digest"); +} + /// Config-blob byte mutation must be caught by digest verification /// before any structural validator runs. #[test] @@ -1884,6 +1924,19 @@ fn unknown_snapshot_layer_media_type_rejected() { assert_err_contains(err, "snapshot layer media type"); } +#[test] +fn unknown_transport_layer_media_type_rejected() { + let (_dir, path) = save_for_mutation(); + rewrite_manifest(&path, |m| { + m["layers"][1]["mediaType"] = Value::from("application/vnd.example.unknown.v1"); + }); + let err = unwrap_err_snapshot(Snapshot::checked_load( + &path, + OciTag::new("latest").unwrap(), + )); + assert_err_contains(err, "transport layer media type"); +} + /// Annotations injected by third-party tools (cosign, ORAS, build /// pipelines) must not break load. The OCI envelope around /// `OciSnapshotConfig` is parsed via `oci-spec`'s lenient types. @@ -2312,11 +2365,15 @@ fn manifest_uses_correct_config_and_layer_media_types() { manifest["config"]["mediaType"].as_str().unwrap(), "application/vnd.hyperlight.snapshot.config.v2+json" ); - assert_eq!(manifest["layers"].as_array().unwrap().len(), 1); + assert_eq!(manifest["layers"].as_array().unwrap().len(), 2); assert_eq!( manifest["layers"][0]["mediaType"].as_str().unwrap(), "application/vnd.hyperlight.snapshot.memory.v1" ); + assert_eq!( + manifest["layers"][1]["mediaType"].as_str().unwrap(), + "application/vnd.hyperlight.snapshot.transport.v1" + ); // `artifactType` mirrors `config.mediaType` so registries that surface // the distribution-spec referrers API report a useful type, and tooling // that falls back to `config.mediaType` sees the same value. @@ -2804,8 +2861,8 @@ fn persisted_non_default_layout_loads_and_runs() { use crate::sandbox::SandboxConfiguration; let mut config = SandboxConfiguration::default(); - config.set_input_data_size(0x8000); - config.set_output_data_size(0x8000); + config.set_g2h_pool_pages(16); + config.set_h2g_pool_pages(16); config.set_heap_size(0x40_000); config.set_scratch_size(0x90_000); let mut source = UninitializedSandbox::new( @@ -2824,8 +2881,8 @@ fn persisted_non_default_layout_loads_and_runs() { .save(&path, &OciTag::new("latest").unwrap()) .unwrap(); let loaded = Arc::new(Snapshot::checked_load(&path, OciTag::new("latest").unwrap()).unwrap()); - assert_eq!(loaded.layout().input_data_size(), 0x8000); - assert_eq!(loaded.layout().output_data_size(), 0x8000); + assert_eq!(loaded.layout().get_g2h_pool_pages(), 16); + assert_eq!(loaded.layout().get_h2g_pool_pages(), 16); assert_eq!(loaded.layout().heap_size(), 0x40_000); assert_eq!(loaded.layout().get_scratch_size(), 0x90_000); @@ -3251,14 +3308,10 @@ fn read_blob_dir( fn from_snapshot_silently_ignores_layout_overrides() { let mut sbox = create_test_sandbox(); let snapshot = sbox.snapshot().unwrap(); - let original_input = snapshot.layout().input_data_size(); - let original_output = snapshot.layout().output_data_size(); let original_heap = snapshot.layout().heap_size(); let original_scratch = snapshot.layout().get_scratch_size(); let mut sbox2 = SandboxBuilder::from_snapshot(snapshot.clone()) - .input_data_size(original_input * 2) - .output_data_size(original_output * 2) .heap_size((original_heap as u64) * 2) .scratch_size(original_scratch * 2) .build() @@ -3267,8 +3320,6 @@ fn from_snapshot_silently_ignores_layout_overrides() { sbox2.call::("GetStatic", ()).unwrap(); let new_snap = sbox2.snapshot().unwrap(); - assert_eq!(new_snap.layout().input_data_size(), original_input); - assert_eq!(new_snap.layout().output_data_size(), original_output); assert_eq!(new_snap.layout().heap_size(), original_heap); assert_eq!(new_snap.layout().get_scratch_size(), original_scratch); } diff --git a/src/hyperlight_host/src/sandbox/snapshot/tripwires.rs b/src/hyperlight_host/src/sandbox/snapshot/tripwires.rs index 7c7103884b..efb646386e 100644 --- a/src/hyperlight_host/src/sandbox/snapshot/tripwires.rs +++ b/src/hyperlight_host/src/sandbox/snapshot/tripwires.rs @@ -12,12 +12,14 @@ //! When an assertion fires, see `docs/snapshot-versioning.md`. use super::file::{ - MT_CONFIG_CURRENT, MT_SNAPSHOT_CURRENT, OCI_LAYOUT_VERSION, SNAPSHOT_ABI_VERSION, + MT_CONFIG_CURRENT, MT_SNAPSHOT_CURRENT, MT_TRANSPORT_CURRENT, OCI_LAYOUT_VERSION, + SNAPSHOT_ABI_VERSION, }; const EXPECTED_ABI_VERSION: u32 = 3; const EXPECTED_MT_CONFIG: &str = "application/vnd.hyperlight.snapshot.config.v2+json"; const EXPECTED_MT_SNAPSHOT: &str = "application/vnd.hyperlight.snapshot.memory.v1"; +const EXPECTED_MT_TRANSPORT: &str = "application/vnd.hyperlight.snapshot.transport.v1"; const EXPECTED_OCI_LAYOUT_VERSION: &str = "1.0.0"; /// `assert!` with the shared tripwire failure message. The message must @@ -37,6 +39,7 @@ const _: () = { abi_assert!(SNAPSHOT_ABI_VERSION == EXPECTED_ABI_VERSION); abi_assert!(str_eq(MT_CONFIG_CURRENT, EXPECTED_MT_CONFIG)); abi_assert!(str_eq(MT_SNAPSHOT_CURRENT, EXPECTED_MT_SNAPSHOT)); + abi_assert!(str_eq(MT_TRANSPORT_CURRENT, EXPECTED_MT_TRANSPORT)); abi_assert!(str_eq(OCI_LAYOUT_VERSION, EXPECTED_OCI_LAYOUT_VERSION)); }; @@ -54,8 +57,6 @@ const _: () = { const _: () = { use hyperlight_common::outb::OutBAction; - abi_assert!(OutBAction::Log as u16 == 99); - abi_assert!(OutBAction::CallFunction as u16 == 101); abi_assert!(OutBAction::Abort as u16 == 102); abi_assert!(OutBAction::DebugPrint as u16 == 103); #[cfg(feature = "trace_guest")] @@ -64,6 +65,7 @@ const _: () = { abi_assert!(OutBAction::TraceMemoryAlloc as u16 == 105); #[cfg(feature = "mem_profile")] abi_assert!(OutBAction::TraceMemoryFree as u16 == 106); + abi_assert!(OutBAction::VirtqNotify as u16 == 109); }; const _: () = { diff --git a/src/hyperlight_host/src/sandbox/uninitialized.rs b/src/hyperlight_host/src/sandbox/uninitialized.rs index 756078e02e..035c18c5cf 100644 --- a/src/hyperlight_host/src/sandbox/uninitialized.rs +++ b/src/hyperlight_host/src/sandbox/uninitialized.rs @@ -460,8 +460,6 @@ mod tests { // Non default memory configuration let cfg = { let mut cfg = SandboxConfiguration::default(); - cfg.set_input_data_size(0x1000); - cfg.set_output_data_size(0x1000); cfg.set_heap_size(0x1000); Some(cfg) }; @@ -1163,7 +1161,6 @@ mod tests { { let mut cfg = SandboxConfiguration::default(); cfg.set_heap_size(16 * 1024 * 1024); // 16MB heap - cfg.set_scratch_size(SandboxConfiguration::DEFAULT_SCRATCH_SIZE + 256 * 1024); let env = GuestEnvironment::new(GuestBinary::FilePath(binary_path.clone()), None); @@ -1206,38 +1203,11 @@ mod tests { let _evolved = sandbox.evolve().expect("Failed to evolve sandbox"); } - // Test 4: Create snapshot with custom input/output buffer sizes - { - let mut cfg = SandboxConfiguration::default(); - cfg.set_scratch_size(SandboxConfiguration::DEFAULT_SCRATCH_SIZE + 128 * 1024); - cfg.set_input_data_size(64 * 1024); // 64KB input - cfg.set_output_data_size(64 * 1024); // 64KB output - - let env = GuestEnvironment::new(GuestBinary::FilePath(binary_path.clone()), None); - - let snapshot = Arc::new( - Snapshot::from_env(env, cfg) - .expect("Failed to create snapshot with custom buffer sizes"), - ); - - let sandbox = UninitializedSandbox::from_snapshot( - snapshot, - None, - #[cfg(crashdump)] - Some(binary_path.clone()), - ) - .expect("Failed to create sandbox from snapshot with custom buffers"); - - let _evolved = sandbox.evolve().expect("Failed to evolve sandbox"); - } - - // Test 5: Create snapshot with all custom settings + // Test 4: Create snapshot with custom heap and scratch sizes { let mut cfg = SandboxConfiguration::default(); cfg.set_heap_size(32 * 1024 * 1024); // 32MB heap - cfg.set_scratch_size(SandboxConfiguration::DEFAULT_SCRATCH_SIZE + 1024 * 1024); - cfg.set_input_data_size(128 * 1024); // 128KB input - cfg.set_output_data_size(128 * 1024); // 128KB output + cfg.set_scratch_size(512 * 1024); // 512KB scratch let env = GuestEnvironment::new(GuestBinary::FilePath(binary_path.clone()), None); @@ -1274,7 +1244,7 @@ mod tests { let _evolved3 = sandbox3.evolve().expect("Failed to evolve sandbox3"); } - // Test 6: Create snapshot from binary buffer instead of file path + // Test 5: Create snapshot from binary buffer instead of file path { let binary_bytes = fs::read(&binary_path).expect("Failed to read binary file"); @@ -1294,7 +1264,7 @@ mod tests { let _evolved = sandbox.evolve().expect("Failed to evolve sandbox"); } - // Test 7: Register host functions on sandboxes created from snapshot + // Test 6: Register host functions on sandboxes created from snapshot { let env = GuestEnvironment::new(GuestBinary::FilePath(binary_path.clone()), None); @@ -1333,7 +1303,7 @@ mod tests { assert_eq!(result, ReturnValue::Int(30)); } - // Test 8: Create snapshot with init data (guest blob) + // Test 7: Create snapshot with init data (guest blob) { let init_data = [0xCA, 0xFE, 0xBA, 0xBE]; let guest_env = @@ -1355,7 +1325,7 @@ mod tests { let _evolved = sandbox.evolve().expect("Failed to evolve sandbox"); } - // Test 9: Create snapshot from existing sandbox + // Test 8: Create snapshot from existing sandbox { let env = GuestEnvironment::new(GuestBinary::FilePath(binary_path.clone()), None); let orig_snapshot = Arc::new( diff --git a/src/hyperlight_host/src/sandbox/uninitialized_evolve.rs b/src/hyperlight_host/src/sandbox/uninitialized_evolve.rs index 718d7e5c4f..63ae195478 100644 --- a/src/hyperlight_host/src/sandbox/uninitialized_evolve.rs +++ b/src/hyperlight_host/src/sandbox/uninitialized_evolve.rs @@ -22,10 +22,6 @@ use crate::{MultiUseSandbox, Result, UninitializedSandbox}; #[instrument(err(Debug), skip_all, parent = Span::current(), level = "Trace")] pub(super) fn evolve_impl_multi_use(u_sbox: UninitializedSandbox) -> Result { let (mut hshm, gshm) = u_sbox.mgr.build()?; - let attach_virtq = matches!( - hshm.next_action, - crate::sandbox::snapshot::NextAction::Initialise(_) - ); // Get the host page size. Narrowed to u32 because the guest ABI // passes it via a 32-bit register (rdx), but widened back to usize @@ -89,10 +85,6 @@ pub(super) fn evolve_impl_multi_use(u_sbox: UninitializedSandbox) -> Result(f: F) +where + F: FnOnce(UninitializedSandbox), +{ + with_rust_uninit_sandbox_cfg(SandboxConfiguration::default(), f); +} + +/// Runs a test with a Rust guest UninitializedSandbox using custom configuration. +pub fn with_rust_uninit_sandbox_cfg(cfg: SandboxConfiguration, f: F) +where + F: FnOnce(UninitializedSandbox), +{ + let sandbox = + UninitializedSandbox::new(GuestBinary::FilePath(rust_guest_path()), Some(cfg)).unwrap(); + f(sandbox); +} + // ============================================================================= // C guest helpers // ============================================================================= diff --git a/src/hyperlight_host/tests/integration_test.rs b/src/hyperlight_host/tests/integration_test.rs index e0f2aff339..a9ef3c38dd 100644 --- a/src/hyperlight_host/tests/integration_test.rs +++ b/src/hyperlight_host/tests/integration_test.rs @@ -6,7 +6,9 @@ use std::thread; use std::time::Duration; use hyperlight_common::flatbuffer_wrappers::guest_error::ErrorCode; +use hyperlight_common::func::Bytes; use hyperlight_common::log_level::GuestLogFilter; +use hyperlight_host::sandbox::SandboxConfiguration; use hyperlight_host::{HyperlightError, MultiUseSandbox, SandboxBuilder}; use hyperlight_testing::simplelogger::{LOGGER, SimpleLogger}; use serial_test::serial; @@ -15,7 +17,7 @@ use tracing_core::LevelFilter; pub mod common; // pub to disable dead_code warning use crate::common::{ build_rust_sandbox, new_rust_sandbox, with_all_sandboxes, with_c_sandbox, with_c_sandbox_from, - with_rust_sandbox, with_rust_sandbox_from, + with_rust_sandbox, with_rust_sandbox_from, with_rust_uninit_sandbox_cfg, }; // A host function cannot be interrupted, but we can at least make sure after requesting to interrupt a host call, @@ -523,8 +525,8 @@ fn guest_malloc_abort() { }); // allocate a vector (on heap) that is bigger than the heap - let heap_size = 128 * 1024; - let size_to_allocate = 256 * 1024; + let heap_size = 40 * 1024; + let size_to_allocate = 0x10000; assert!( size_to_allocate > heap_size, "precondition: size_to_allocate ({size_to_allocate}) must be > heap_size ({heap_size})" @@ -565,43 +567,9 @@ fn guest_outb_with_invalid_port_poisons_sandbox() { }); } -#[test] -fn corrupt_output_size_prefix_rejected() { - with_rust_sandbox(|mut sbox| { - let res = sbox.call::("CorruptOutputSizePrefix", ()); - assert!( - res.is_err(), - "Expected error when guest corrupts size prefix, got: {:?}", - res, - ); - let err_msg = format!("{:?}", res.unwrap_err()); - assert!( - err_msg.contains("SharedMemory(Stack(CorruptPrefix(4294967295, 8)))"), - "Unexpected error message: {err_msg}" - ); - }); -} - -#[test] -fn corrupt_output_back_pointer_rejected() { - with_rust_sandbox(|mut sbox| { - let res = sbox.call::("CorruptOutputBackPointer", ()); - assert!( - res.is_err(), - "Expected error when guest corrupts back-pointer, got: {:?}", - res, - ); - let err_msg = format!("{:?}", res.unwrap_err()); - assert!( - err_msg.contains("SharedMemory(Stack(CorruptBackPointer(57005, 8)))"), - "Unexpected error message: {err_msg}" - ); - }); -} - #[test] fn guest_panic_no_alloc() { - let heap_size = 128 * 1024; + let heap_size = 40 * 1024; let configure = |builder: SandboxBuilder| builder.heap_size(heap_size); with_rust_sandbox_from(configure, |mut sbox| { @@ -612,15 +580,10 @@ fn guest_panic_no_alloc() { ) .unwrap_err(); - // Legacy transport may report its own allocation failure. assert!( matches!( &res, - HyperlightError::GuestAborted(code, msg) - if (*code == ErrorCode::UnknownError as u8 - && msg.contains("memory allocation of ") - && msg.contains("bytes failed")) - || *code == ErrorCode::MallocFailed as u8 + HyperlightError::GuestAborted(code, msg) if *code == ErrorCode::UnknownError as u8 && msg.contains("memory allocation of ") && msg.contains("bytes failed") ), "unexpected error: {res:?}" ); @@ -733,19 +696,9 @@ fn recursive_stack_allocate_overflow() { #[test] #[ignore] fn log_message() { - // The magic numbers below represent the number of fixed log messages that are emitted as - // follows: - // - logs from trace level tracing spans created as logs because of the tracing `log` feature - // - 4 from evolve call (generic_init + hyperlight_main) - // - 8 from guest call - // and are multiplied because we make 6 calls to `log_test_messages` - // NOTE: These numbers need to be updated if log messages or spans are added/removed - let num_fixed_trace_log = 12 * 6; - - // Calculate fixed info logs - // - 4 logs per iteration from infrastructure at Info level (internal_dispatch_function) - // (dispatch x 1 + call_guest x 1) * 2 logs (Enter/Exit) = 4 logs - // - 6 iterations + // Each of the six sandboxes emits eight fixed records at trace level. + // Dispatch and call spans emit four fixed records at info level. + let num_fixed_trace_log = 8 * 6; let num_fixed_info_log = 4 * 6; let tests = vec![ @@ -823,6 +776,31 @@ fn log_test_messages(levelfilter: Option) { } } +#[test] +#[ignore] +fn virtq_repeated_log_delivery_small_ring() { + SimpleLogger::initialize_test_logger(); + LOGGER.clear_log_calls(); + + let mut cfg = SandboxConfiguration::default(); + cfg.set_g2h_queue_size(4); + cfg.set_g2h_pool_pages(2); + + with_rust_uninit_sandbox_cfg(cfg, |mut sandbox| { + sandbox.set_max_guest_log_level(LevelFilter::INFO); + let mut sandbox = sandbox.evolve().unwrap(); + + sandbox.call::<()>("LogMessageN", 20_i32).unwrap(); + + let count = (0..LOGGER.num_log_calls()) + .filter_map(|index| LOGGER.get_log_call(index)) + .filter(|call| call.target == "hyperlight_guest" && call.args.contains("log entry")) + .count(); + assert_eq!(count, 20); + LOGGER.clear_log_calls(); + }); +} + /// Tests whether host is able to return Bool as return type /// or not #[test] @@ -894,6 +872,38 @@ fn test_if_guest_is_able_to_get_string_return_values_from_host() { }); } +#[test] +fn c_guest_accesses_byte_chunks() { + let configure = |builder: SandboxBuilder| { + builder.host_function("HostEchoByteChunks", |value: Vec| value) + }; + with_c_sandbox_from(configure, |mut sandbox| { + let expected = (0..10 * 1024) + .map(|index| (index % 251) as u8) + .collect::>(); + + let input = vec![ + Bytes::copy_from_slice(&expected[..2047]), + Bytes::copy_from_slice(&expected[2047..4097]), + Bytes::copy_from_slice(&expected[4097..]), + ]; + + for _ in 0..2 { + let output: Vec = sandbox + .call("RoundTripHostByteChunks", input.clone()) + .unwrap(); + + assert_eq!( + output + .iter() + .flat_map(|chunk| chunk.iter().copied()) + .collect::>(), + expected + ); + } + }); +} + /// Test that validates interrupt behavior with random kill timing under concurrent load /// Uses a pool of 100 sandboxes, 100 threads, and 500 iterations per thread. /// Randomly decides to kill some calls at random times during execution. @@ -1669,8 +1679,6 @@ fn fill_heap_and_cause_exception() { let err = result.unwrap_err(); match &err { - // Legacy transport may report its own allocation failure. - HyperlightError::GuestAborted(code, _) if *code == ErrorCode::MallocFailed as u8 => {} HyperlightError::GuestAborted(code, message) => { assert_eq!(*code, ErrorCode::GuestError as u8, "Full error: {:?}", err); diff --git a/src/hyperlight_host/tests/sandbox_host_tests.rs b/src/hyperlight_host/tests/sandbox_host_tests.rs index 6bfe2e5dc2..715c9a0734 100644 --- a/src/hyperlight_host/tests/sandbox_host_tests.rs +++ b/src/hyperlight_host/tests/sandbox_host_tests.rs @@ -4,11 +4,15 @@ use core::f64; use std::sync::mpsc::channel; use std::sync::{Arc, Mutex}; +use hyperlight_common::func::Bytes; +use hyperlight_host::sandbox::SandboxConfiguration; use hyperlight_host::{HyperlightError, Result, SandboxBuilder, new_error}; use hyperlight_testing::simple_guest_as_pathbuf; pub mod common; // pub to disable dead_code warning -use crate::common::{with_all_guests, with_all_sandboxes}; +use crate::common::{ + with_all_guests, with_all_sandboxes, with_rust_uninit_sandbox, with_rust_uninit_sandbox_cfg, +}; #[test] fn pass_byte_array() { @@ -199,9 +203,7 @@ fn incorrect_parameter_num() { #[test] fn small_scratch_sandbox() { let a = SandboxBuilder::from_file(simple_guest_as_pathbuf()) - .scratch_size(0x48000) - .input_data_size(0x24000) - .output_data_size(0x24000) + .scratch_size(0x1000) .build(); assert!(matches!( @@ -211,7 +213,7 @@ fn small_scratch_sandbox() { } #[test] -fn iostack_is_working() { +fn custom_guest_dispatch_is_working() { with_all_sandboxes(|mut sandbox| { let res: i32 = sandbox .call::("ThisIsNotARealFunctionButTheNameIsImportant", ()) @@ -316,6 +318,167 @@ fn callback_test() { callback_test_helper(); } +#[test] +fn host_external_bytes_round_trip() { + with_rust_uninit_sandbox(|mut sandbox| { + sandbox + .register("HostEchoVecBytes", |value: Vec| value) + .unwrap(); + sandbox + .register("HostEchoByteChunks", |value: Vec| value) + .unwrap(); + sandbox.register("HostNoOp", || {}).unwrap(); + let mut sandbox = sandbox.evolve().unwrap(); + let expected: Vec = (0..6 * 1024).map(|index| (index % 251) as u8).collect(); + + let contiguous: Vec = sandbox + .call("RoundTripHostVecBytes", expected.clone()) + .unwrap(); + assert_eq!(contiguous, expected); + + let input = vec![ + Bytes::copy_from_slice(&expected[..2047]), + Bytes::copy_from_slice(&expected[2047..4097]), + Bytes::copy_from_slice(&expected[4097..]), + ]; + for _ in 0..2 { + let chunks: Vec = sandbox + .call("RoundTripHostByteChunks", input.clone()) + .unwrap(); + let flattened: Vec = chunks + .iter() + .flat_map(|chunk| chunk.iter().copied()) + .collect(); + assert_eq!(flattened, expected); + } + }); +} + +#[test] +fn guest_external_bytes_round_trip_and_retention() { + with_rust_uninit_sandbox(|sandbox| { + let mut sandbox = sandbox.evolve().unwrap(); + let expected: Vec = (0..9 * 1024).map(|index| (index % 251) as u8).collect(); + + let contiguous: Vec = sandbox.call("EchoGuestVecBytes", expected.clone()).unwrap(); + assert_eq!(contiguous, expected); + + let input = vec![ + Bytes::copy_from_slice(&expected[..2047]), + Bytes::copy_from_slice(&expected[2047..4097]), + Bytes::copy_from_slice(&expected[4097..]), + ]; + let chunks: Vec = sandbox.call("EchoGuestByteChunks", input.clone()).unwrap(); + assert_eq!( + chunks + .iter() + .flat_map(|chunk| chunk.iter().copied()) + .collect::>(), + expected + ); + + let retained: Vec = (0..12_000).map(|index| (index % 251) as u8).collect(); + let retained_len: i32 = sandbox + .call( + "RetainGuestByteChunks", + vec![Bytes::copy_from_slice(&retained)], + ) + .unwrap(); + assert_eq!(retained_len as usize, retained.len()); + + let released_len: i32 = sandbox.call("ReleaseGuestByteChunks", ()).unwrap(); + assert_eq!(released_len as usize, retained.len()); + + let retried: Vec = sandbox.call("EchoGuestVecBytes", expected.clone()).unwrap(); + assert_eq!(retried, expected); + }); +} + +#[test] +fn h2g_capacity_failure_does_not_poison_sandbox() { + let mut cfg = SandboxConfiguration::default(); + cfg.set_h2g_pool_pages(4); + + with_rust_uninit_sandbox_cfg(cfg, |sandbox| { + let mut sandbox = sandbox.evolve().unwrap(); + let retained = vec![0u8; 12_000]; + + sandbox + .call::( + "RetainGuestByteChunks", + vec![Bytes::copy_from_slice(&retained)], + ) + .unwrap(); + + let error = sandbox + .call::>("EchoGuestVecBytes", vec![0u8; 9 * 1024]) + .unwrap_err(); + + assert!(error.to_string().contains("H2G capacity")); + + let released: i32 = sandbox.call("ReleaseGuestByteChunks", ()).unwrap(); + assert_eq!(released as usize, retained.len()); + }); +} + +#[test] +fn oversized_host_response_returns_transport_error() { + with_rust_uninit_sandbox(|mut sandbox| { + sandbox + .register("HostOversizedVecBytes", || vec![0u8; 64 * 1024]) + .unwrap(); + sandbox.register("HostNoOp", || {}).unwrap(); + let mut sandbox = sandbox.evolve().unwrap(); + + let error = sandbox + .call::>("GetOversizedHostVecBytes", ()) + .unwrap_err(); + assert!(matches!( + error, + HyperlightError::GuestError(_, message) + if message == "Host response exceeds virtqueue capacity" + )); + sandbox.call::<()>("RoundTripHostNoOp", ()).unwrap(); + }); +} + +#[test] +fn log_then_host_call_with_small_rings() { + let mut cfg = SandboxConfiguration::default(); + cfg.set_g2h_queue_size(4); + cfg.set_h2g_queue_size(4); + cfg.set_g2h_pool_pages(2); + + with_rust_uninit_sandbox_cfg(cfg, |mut sandbox| { + sandbox.set_max_guest_log_level(tracing_core::LevelFilter::INFO); + sandbox.register("HostNoOp", || {}).unwrap(); + let mut sandbox = sandbox.evolve().unwrap(); + + for _ in 0..20 { + sandbox.call::<()>("LogThenHostNoOp", ()).unwrap(); + } + }); +} + +#[test] +fn oversized_fixed_host_error_returns_transport_error() { + with_rust_uninit_sandbox(|mut sandbox| { + sandbox + .register("HostNoOp", || -> Result<()> { + Err(new_error!("host error {}", "x".repeat(1024))) + }) + .unwrap(); + let mut sandbox = sandbox.evolve().unwrap(); + + let error = sandbox.call::<()>("RoundTripHostNoOp", ()).unwrap_err(); + assert!(matches!( + error, + HyperlightError::GuestError(_, message) + if message == "Host response exceeds virtqueue capacity" + )); + }); +} + #[test] fn callback_test_parallel() { let n_threads = 100; diff --git a/src/hyperlight_host/tests/snapshot_goldens/fixtures.rs b/src/hyperlight_host/tests/snapshot_goldens/fixtures.rs index fe1606962c..7ad4cd3ebd 100644 --- a/src/hyperlight_host/tests/snapshot_goldens/fixtures.rs +++ b/src/hyperlight_host/tests/snapshot_goldens/fixtures.rs @@ -9,8 +9,9 @@ use std::path::PathBuf; use std::sync::Arc; use hyperlight_host::func::Registerable; +use hyperlight_host::sandbox::SandboxConfiguration; use hyperlight_host::sandbox::snapshot::Snapshot; -use hyperlight_host::{HostFunctions, MultiUseSandbox, SandboxBuilder}; +use hyperlight_host::{GuestBinary, MultiUseSandbox, UninitializedSandbox}; use hyperlight_testing::simple_guest_as_pathbuf; /// Heap pattern length used by the golden. Small enough to @@ -21,16 +22,17 @@ pub(crate) const HEAP_PATTERN_LEN: u64 = 1024; /// Set by `AddToStatic(CALL_COUNTER_BUMP)` at generate time. pub(crate) const CALL_COUNTER_BUMP: i32 = 42; -/// Canonical builder configuration used to produce the goldens. +/// Canonical configuration used to produce the goldens. /// Layout knobs are deliberately bumped away from defaults so any /// silent arithmetic change in `SandboxMemoryLayout::new` shifts at /// least one region between generate-time and load-time. -fn golden_builder() -> SandboxBuilder { - SandboxBuilder::from_file(simpleguest_path()) - .input_data_size(64 * 1024) - .output_data_size(64 * 1024) - .heap_size(256 * 1024) - .scratch_size(512 * 1024) +fn golden_config() -> SandboxConfiguration { + let mut cfg = SandboxConfiguration::default(); + cfg.set_heap_size(256 * 1024); + cfg.set_scratch_size(512 * 1024); + cfg.set_g2h_pool_pages(16); + cfg.set_h2g_pool_pages(16); + cfg } fn simpleguest_path() -> PathBuf { @@ -38,12 +40,13 @@ fn simpleguest_path() -> PathBuf { } pub(crate) fn generate() -> Arc { - let mut funcs = HostFunctions::default(); - register_host_echo_fns(&mut funcs); - let mut sbox = golden_builder() - .host_functions(funcs) - .build() - .expect("build golden sandbox"); + let mut sandbox = UninitializedSandbox::new( + GuestBinary::FilePath(simpleguest_path()), + Some(golden_config()), + ) + .expect("UninitializedSandbox::new"); + register_host_echo_fns(&mut sandbox); + let mut sbox = sandbox.evolve().expect("evolve"); run_canonical_calls(&mut sbox); sbox.snapshot().expect("snapshot") } diff --git a/src/schema/function_types.fbs b/src/schema/function_types.fbs index 091ff59180..296c1e52d2 100644 --- a/src/schema/function_types.fbs +++ b/src/schema/function_types.fbs @@ -57,13 +57,6 @@ table hlvecbytes { value:[ubyte]; } -// hlbytechunks is the embedded compatibility representation of a chunked byte -// value. Embedded transport does not preserve chunk boundaries. - -table hlbytechunks { - value:[ubyte]; -} - // hlexternalbytes declares a logical byte value stored outside the FlatBuffer. // chunked distinguishes ByteChunks from the default VecBytes logical type. @@ -79,14 +72,6 @@ table hlsizeprefixedbuffer { value:[ubyte]; } -// hlsizeprefixedbytechunks is the embedded compatibility representation of a -// chunked return byte value. Embedded transport does not preserve boundaries. - -table hlsizeprefixedbytechunks { - size:int; - value:[ubyte]; -} - // hlvoid is a void (used for functions that return nothing) table hlvoid { @@ -105,7 +90,6 @@ union ParameterValue { hlbool, hlvecbytes, hlexternalbytes, - hlbytechunks, } // This represents a parameter type in a function definition @@ -149,5 +133,4 @@ union ReturnValue { hlvoid, hlsizeprefixedbuffer, hlexternalbytes, - hlsizeprefixedbytechunks, } diff --git a/src/tests/c_guests/c_simpleguest/main.c b/src/tests/c_guests/c_simpleguest/main.c index 6ac12d1692..4bf89a9907 100644 --- a/src/tests/c_guests/c_simpleguest/main.c +++ b/src/tests/c_guests/c_simpleguest/main.c @@ -336,6 +336,36 @@ const char* guest_fn_checks_if_host_returns_string_value() { return hl_get_host_return_value_as_String(); } +hl_ReturnValue *round_trip_host_byte_chunks(const hl_FunctionCall *params) { + hl_ByteChunks input = params->parameters[0].value.ByteChunks; + assert(input.count > 1); + + for (uintptr_t i = 0; i < input.count; i++) { + assert(input.chunks[i].data != NULL || input.chunks[i].len == 0); + } + + hl_Parameter host_param = { + .tag = hl_ParameterType_ByteChunks, + .value = {.ByteChunks = input}, + }; + + const hl_FunctionCall host_call = { + .function_name = "HostEchoByteChunks", + .parameters = &host_param, + .parameters_len = 1, + .return_type = hl_ReturnType_ByteChunks, + }; + hl_call_host_function(&host_call); + + hl_ByteChunks *output = hl_get_host_return_value_as_ByteChunks(); + assert(output != NULL); + assert(output->count > 1); + + hl_ReturnValue *result = hl_result_from_ByteChunks(*output); + hl_free_byte_chunks(output); + return result; +} + HYPERLIGHT_WRAP_FUNCTION(guest_fn_checks_if_host_returns_float_value, Float, 2, Float, Float) HYPERLIGHT_WRAP_FUNCTION(guest_fn_checks_if_host_returns_double_value, Double, 2, Double, Double) HYPERLIGHT_WRAP_FUNCTION(guest_fn_checks_if_host_returns_string_value, String, 0) @@ -417,6 +447,7 @@ void hyperlight_main(void) // HYPERLIGHT_REGISTER_FUNCTION macro does not work for functions that return VecBytes, // so we use hl_register_function_definition directly hl_register_function_definition("24K_in_8K_out", twenty_four_k_in_eight_k_out, 1, (hl_ParameterType[]){hl_ParameterType_VecBytes}, hl_ReturnType_VecBytes); + hl_register_function_definition("RoundTripHostByteChunks", round_trip_host_byte_chunks, 1, (hl_ParameterType[]){hl_ParameterType_ByteChunks}, hl_ReturnType_ByteChunks); } // This dispatch function is only used when the host dispatches a guest function diff --git a/src/tests/rust_guests/simpleguest/src/main.rs b/src/tests/rust_guests/simpleguest/src/main.rs index 28f92ee75e..4bd3c6f640 100644 --- a/src/tests/rust_guests/simpleguest/src/main.rs +++ b/src/tests/rust_guests/simpleguest/src/main.rs @@ -25,11 +25,10 @@ use core::sync::atomic::{AtomicU64, Ordering}; use hyperlight_common::flatbuffer_wrappers::function_call::{FunctionCall, FunctionCallType}; use hyperlight_common::flatbuffer_wrappers::function_types::{ - ParameterType, ParameterValue, ReturnType, ReturnValue, + Bytes, ParameterType, ParameterValue, ReturnType, ReturnValue, }; use hyperlight_common::flatbuffer_wrappers::guest_error::ErrorCode; use hyperlight_common::flatbuffer_wrappers::guest_log_level::LogLevel; -use hyperlight_common::flatbuffer_wrappers::util::get_flatbuffer_result; use hyperlight_common::log_level::GuestLogFilter; use hyperlight_common::vmem::{BasicMapping, MappingKind}; use hyperlight_guest::error::{HyperlightGuestError, Result}; @@ -39,11 +38,10 @@ use hyperlight_guest_bin::exception::arch::{Context, ExceptionInfo}; use hyperlight_guest_bin::guest_function::definition::{GuestFunc, GuestFunctionDefinition}; use hyperlight_guest_bin::guest_function::register::register_function; use hyperlight_guest_bin::host_comm::{ - call_host_function, call_host_function_without_returning_result, get_host_return_value_raw, - print_output_with_host_print, read_n_bytes_from_user_memory, + call_host_function, print_output_with_host_print, read_n_bytes_from_user_memory, }; use hyperlight_guest_bin::memory::malloc; -use hyperlight_guest_bin::{GUEST_HANDLE, guest_function, guest_logger, host_function}; +use hyperlight_guest_bin::{guest_function, guest_logger, host_function}; // `log` is intentionally kept here: the LogMessage guest function exercises the // guest-side `log` crate path to verify that guests using `log` are still supported. use log::LevelFilter; @@ -390,6 +388,58 @@ fn get_size_prefixed_buffer(data: Vec) -> Vec { data } +#[guest_function("EchoGuestVecBytes")] +fn echo_guest_vec_bytes(data: Vec) -> Vec { + data +} + +#[guest_function("EchoGuestByteChunks")] +fn echo_guest_byte_chunks(data: Vec) -> Vec { + data +} + +static mut RETAINED_GUEST_CHUNKS: Option> = None; +static mut RETAINED_HOST_CHUNKS: Option> = None; + +#[guest_function("RetainGuestByteChunks")] +fn retain_guest_byte_chunks(data: Vec) -> i32 { + let len = data.iter().map(Bytes::len).sum::(); + // SAFETY: the guest is single threaded, so the static has no concurrent access. + unsafe { RETAINED_GUEST_CHUNKS = Some(data) }; + len as i32 +} + +#[guest_function("ReleaseGuestByteChunks")] +fn release_guest_byte_chunks() -> i32 { + // SAFETY: the guest is single threaded, so the static has no concurrent access. + #[allow(static_mut_refs)] + unsafe { + RETAINED_GUEST_CHUNKS.take().map_or(0, |chunks| { + chunks.iter().map(Bytes::len).sum::() as i32 + }) + } +} + +#[guest_function("RetainHostByteChunks")] +fn retain_host_byte_chunks(data: Vec) -> Result { + let chunks = host_echo_byte_chunks(data)?; + let len = chunks.iter().map(Bytes::len).sum::(); + // SAFETY: the guest is single threaded, so the static has no concurrent access. + unsafe { RETAINED_HOST_CHUNKS = Some(chunks) }; + Ok(len as i32) +} + +#[guest_function("ReleaseHostByteChunks")] +fn release_host_byte_chunks() -> i32 { + // SAFETY: the guest is single threaded, so the static has no concurrent access. + #[allow(static_mut_refs)] + unsafe { + RETAINED_HOST_CHUNKS.take().map_or(0, |chunks| { + chunks.iter().map(Bytes::len).sum::() as i32 + }) + } +} + #[guest_function("EchoI32")] fn echo_i32(v: i32) -> i32 { v @@ -445,6 +495,12 @@ fn host_echo_string(v: String) -> Result; #[host_function("HostEchoVecBytes")] fn host_echo_vec_bytes(v: Vec) -> Result>; +#[host_function("HostEchoByteChunks")] +fn host_echo_byte_chunks(v: Vec) -> Result>; + +#[host_function("HostOversizedVecBytes")] +fn host_oversized_vec_bytes() -> Result>; + #[host_function("HostNoOp")] fn host_noop() -> Result<()>; @@ -493,11 +549,29 @@ fn round_trip_host_vec_bytes(v: Vec) -> Result> { host_echo_vec_bytes(v) } +#[guest_function("RoundTripHostByteChunks")] +fn round_trip_host_byte_chunks(v: Vec) -> Result> { + let chunks = host_echo_byte_chunks(v)?; + host_noop()?; + Ok(chunks) +} + +#[guest_function("GetOversizedHostVecBytes")] +fn get_oversized_host_vec_bytes() -> Result> { + host_oversized_vec_bytes() +} + #[guest_function("RoundTripHostNoOp")] fn round_trip_host_noop() -> Result<()> { host_noop() } +#[guest_function("LogThenHostNoOp")] +fn log_then_host_noop() -> Result<()> { + log::info!("log before host call"); + host_noop() +} + static mut HEAP_PATTERN: Option> = None; #[guest_function("AllocAndWritePattern")] @@ -616,6 +690,13 @@ fn log_message(message: String, level: i32) { } } +#[guest_function("LogMessageN")] +fn log_message_n(count: i32) { + for i in 0..count { + log::info!("log entry {}", i); + } +} + #[guest_function("TriggerException")] fn trigger_exception() { // trigger an undefined instruction exception @@ -1508,41 +1589,8 @@ fn fuzz_guest_trace(max_depth: u32, msg: String) -> u32 { fuzz_traced_function(0, max_depth, &msg) } -#[guest_function("CorruptOutputSizePrefix")] -fn corrupt_output_size_prefix() -> i32 { - unsafe { - let peb_ptr = core::ptr::addr_of!(GUEST_HANDLE).read().peb().unwrap(); - let output_stack_ptr = (*peb_ptr).output_stack.ptr as *mut u8; - - // Write a fake stack entry with a ~4 GB size prefix (0xFFFF_FFFB + 4). - let buf = core::slice::from_raw_parts_mut(output_stack_ptr, 24); - buf[0..8].copy_from_slice(&24_u64.to_le_bytes()); - buf[8..12].copy_from_slice(&0xFFFF_FFFBu32.to_le_bytes()); - buf[12..16].copy_from_slice(&[0u8; 4]); - buf[16..24].copy_from_slice(&8_u64.to_le_bytes()); - outb_with_port(hyperlight_common::outb::VmAction::Halt as u32, 0u32); - unreachable!(); - } -} - -#[guest_function("CorruptOutputBackPointer")] -fn corrupt_output_back_pointer() -> i32 { - unsafe { - let peb_ptr = core::ptr::addr_of!(GUEST_HANDLE).read().peb().unwrap(); - let output_stack_ptr = (*peb_ptr).output_stack.ptr as *mut u8; - - // Write a fake stack entry with back-pointer 0xDEAD (past stack pointer 24). - let buf = core::slice::from_raw_parts_mut(output_stack_ptr, 24); - buf[0..8].copy_from_slice(&24_u64.to_le_bytes()); - buf[8..16].copy_from_slice(&[0u8; 8]); - buf[16..24].copy_from_slice(&0xDEAD_u64.to_le_bytes()); - outb_with_port(hyperlight_common::outb::VmAction::Halt as u32, 0u32); - unreachable!(); - } -} - // Interprets the given guest function call as a host function call and dispatches it to the host. -fn fuzz_host_function(func: FunctionCall) -> Result> { +fn fuzz_host_function(func: FunctionCall) -> Result { let mut params = func.parameters.unwrap(); // first parameter must be string (the name of the host function to call) let host_func_name = match params.remove(0) { @@ -1557,44 +1605,12 @@ fn fuzz_host_function(func: FunctionCall) -> Result> { } }; - // Because we do not know at compile time the actual return type of the host function to be called - // we cannot use the `call_host_function` generic function. - // We need to use the `call_host_function_without_returning_result` function that does not retrieve the return - // value - call_host_function_without_returning_result( - &host_func_name, - Some(params), - func.expected_return_type, - ) - .expect("failed to call host function"); - - let host_return = get_host_return_value_raw(); - match host_return { - Ok(return_value) => match return_value { - ReturnValue::Int(i) => Ok(get_flatbuffer_result(i)), - ReturnValue::UInt(i) => Ok(get_flatbuffer_result(i)), - ReturnValue::Long(i) => Ok(get_flatbuffer_result(i)), - ReturnValue::ULong(i) => Ok(get_flatbuffer_result(i)), - ReturnValue::Float(i) => Ok(get_flatbuffer_result(i)), - ReturnValue::Double(i) => Ok(get_flatbuffer_result(i)), - ReturnValue::String(str) => Ok(get_flatbuffer_result(str.as_str())), - ReturnValue::Bool(bool) => Ok(get_flatbuffer_result(bool)), - ReturnValue::Void(()) => Ok(get_flatbuffer_result(())), - ReturnValue::VecBytes(byte) => Ok(get_flatbuffer_result(byte.as_slice())), - ReturnValue::ByteChunks(chunks) => Ok(get_flatbuffer_result(chunks)), - }, - Err(e) => Err(e), - } + call_host_function::(&host_func_name, Some(params), func.expected_return_type) } #[hyperlight_guest_bin::dispatch] #[instrument(skip_all, parent = Span::current(), level= "Trace")] -fn dispatch(function_call: FunctionCall) -> Result> { - // This test checks the stack behavior of the input/output buffer - // by calling the host before serializing the function call. - // If the stack is not working correctly, the input or output buffer will be - // overwritten before the function call is serialized, and we will not be able - // to verify that the function call name is "ThisIsNotARealFunctionButTheNameIsImportant" +fn dispatch(function_call: FunctionCall) -> Result { if function_call.function_name == "FuzzHostFunc" { return fuzz_host_function(function_call); } @@ -1630,5 +1646,5 @@ fn dispatch(function_call: FunctionCall) -> Result> { )); } - Ok(get_flatbuffer_result(99)) + Ok(ReturnValue::Int(99)) } diff --git a/src/tests/rust_guests/witguest/src/main.rs b/src/tests/rust_guests/witguest/src/main.rs index a77f1c8231..a4329e177a 100644 --- a/src/tests/rust_guests/witguest/src/main.rs +++ b/src/tests/rust_guests/witguest/src/main.rs @@ -233,10 +233,11 @@ pub extern "C" fn hyperlight_main() { use ::alloc::vec::Vec; use ::hyperlight_common::flatbuffer_wrappers::function_call::FunctionCall; +use ::hyperlight_common::flatbuffer_wrappers::function_types::ReturnValue; use ::hyperlight_common::flatbuffer_wrappers::guest_error::ErrorCode; use ::hyperlight_guest::error::{HyperlightGuestError, Result}; #[no_mangle] -pub fn guest_dispatch_function(function_call: FunctionCall) -> Result> { +pub fn guest_dispatch_function(function_call: FunctionCall) -> Result { Err(HyperlightGuestError::new( ErrorCode::GuestFunctionNotFound, function_call.function_name.clone(),