Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,15 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
## [Prerelease] - Unreleased

### Added
* `Sandbox::snapshot_with_options` takes a snapshot with `SnapshotOptions`. `SnapshotOptions::scratch_size` gives a restored sandbox a different scratch size. A guest can boot with the scratch its initialisation needs and run guest function calls from a snapshot with only the scratch they need, so each restore costs less. It can also give a guest more scratch than it booted with.

### Changed
* Core dumps no longer record the guest binary path. The process name and command line are always `<unknown>`, as they already were for sandboxes created from snapshots by @ludfjig in https://github.com/hyperlight-dev/hyperlight/pull/1975

* **Breaking:** Generated libc bindings use `usize` for `size_t` arguments and return values, including `strlen` and `memcpy`. Rust callers that use `u64` or `c_ulong` need explicit conversions. The C ABI is unchanged.

* **Breaking:** The transport arena sits one unmapped guard page below the reserved scratch-top pages, at an address independent of the scratch size, and the page tables at the scratch base. The guest's page allocator stops at the arena GPA the host publishes in the scratch-top metadata, so guests must be rebuilt. `hyperlight_common::layout::scratch_allocator_limit_gpa` is renamed `scratch_reserved_top_gpa`, the start of the reserved pages. An allocator that stops there runs into the arena. The minimum scratch size grows by that guard page. Snapshot ABI 7: existing snapshots must be regenerated.

### Removed

### Fixed
Expand Down
40 changes: 28 additions & 12 deletions docs/paging-development-notes.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,8 @@ original virtual address to point to the new page.
The page table entries to do this will likely need to be copied themselves, and so a
ready supply of already-mapped scratch pages to use for replacement
page tables is needed. Currently, the guest accomplishes this by
keeping an identity mapping of the entire scratch memory around.
keeping an identity mapping of scratch memory around, except one guard
page below the reserved top pages.

The host and the guest need to agree on the location of this mapping,
so that (a) the host can create it when first setting up a blank guest
Expand Down Expand Up @@ -128,9 +129,18 @@ scratch region that will be used when this snapshot is next restored
into a sandbox. This will then be baked into the guest page tables
created in the snapshot.

TODO: add support, if found to be useful operationally, for either
dynamically growing the scratch region, or changing its size between
taking a snapshot and restoring it.
`Sandbox::snapshot_with_options`, with `SnapshotOptions::scratch_size`,
takes a snapshot whose scratch size differs from the sandbox's. Its
page tables map a window of that size, and restoring it replaces the
scratch region. A guest can boot with the scratch its initialisation
needs and run guest function calls from a snapshot with only the
scratch they need, or with more.

The scratch window grows downward from the top of the address space. A
larger window covers guest-virtual addresses below the current one, so
the host rejects the size when the guest has a mapping there. The
physical window needs no such check: scratch hangs from a fixed top,
far above the largest snapshot region.

### Call descriptors

Expand All @@ -139,14 +149,20 @@ 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.

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
that function for the detailed breakdown of required overhead.
Host function calls and guest function calls use two virtqueues in a
fixed transport arena below the reserved scratch-top pages. One
unmapped guard page between them makes an amd64 exception stack
overrun fault before it reaches the arena. On aarch64 nested faults
lower SP past it, since the exception vectors store below SP before
any check. The arena's address does not depend on the scratch size.
The arena contains both rings and their fixed-slot buffer pools.
Copied page tables start at the scratch base. Dynamic scratch
allocations begin after the copied page tables and stop at the arena.

The minimum scratch size is the architecture's fixed overhead
(`min_scratch_size()` in the layout modules under `hyperlight_common`,
which breaks it down) plus the transport arena and the guard page above
it (`SandboxMemoryLayout::min_scratch`), plus the copied page tables.

## Creating a fresh guest

Expand Down
4 changes: 4 additions & 0 deletions docs/snapshot-versioning.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,10 @@ A snapshot carries four independently evolvable version markers:
ABI 6 requires checkpoint readiness after guest initialization and permits
H2G prefill to omit retained slots. Earlier snapshots must be regenerated.

ABI 7 places the transport arena below the reserved scratch-top pages, at an
address independent of the scratch size, and the page tables at the scratch
base. Earlier snapshots must be regenerated.

The `OCI_LAYOUT_VERSION` constant is pinned by the OCI image-layout
spec at `1.0.0`.

Expand Down
30 changes: 16 additions & 14 deletions docs/virtio-host-guest-communication.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,16 +42,17 @@ optional writable response capacity.

## Transport arena

Both rings, the transport mailbox, and both pools occupy one fixed prefix of
guest scratch memory.
Both rings, the transport mailbox, and both pools occupy one fixed range of
guest scratch memory, below the reserved scratch-top pages with one unmapped
guard page between them. Its address does not depend on the scratch size.
Guest page allocations stop at its base.

```text
scratch base
|
v
+----------+-----+----------+-----+-----+-----+----------+----------+
| G2H ring | pad | H2G ring | pad | mbx | pad | G2H pool | H2G pool |
+----------+-----+----------+-----+-----+-----+----------+----------+
reserved top pages
|
+----------+-----+----------+-----+-----+-----+----------+----------+-------+
| G2H ring | pad | H2G ring | pad | mbx | pad | G2H pool | H2G pool | guard |
+----------+-----+----------+-----+-----+-----+----------+----------+-------+
```

The host derives this layout from `SandboxConfiguration`. Ring starts follow
Expand Down Expand Up @@ -365,8 +366,8 @@ Guest `SlotPool` instances own all transport buffers. Pool clones share one
allocation bitmap with each producer.
Each producer pairs its backend with that pool. Completion leases carry the
original slot addresses and full capacities. Backing owns alias allocation and
paging. The backend holds only scratch bounds.
Guest completion mapping checks scratch bounds. Allocation ownership and
paging. The backend holds only the transport arena's bounds.
Guest completion mapping checks those bounds. Allocation ownership and
initialized lengths follow the `BufferMap` safety contract.

```text
Expand Down Expand Up @@ -475,8 +476,9 @@ not stored.

### Restore

Capture and load validate scratch size, ring lengths, and canonical transport
state against the layout. Admitted images and their layout remain immutable.
Capture and load validate ring lengths and canonical transport state against
the layout. Load also checks the scratch size the transport blob records.
Admitted images and their layout remain immutable.

Transport admission precedes changes to sandbox status, the cached snapshot,
and memory mappings.
Expand Down Expand Up @@ -527,8 +529,8 @@ both consumers.
## Placement and relocation limitations

The fixed-pool runtime places both rings, the mailbox, and both pools in one
host-owned arena at the scratch base. The guest reconstructs that layout from
host metadata.
host-owned arena below the reserved scratch-top pages. The guest reconstructs
that layout from host metadata.

Descriptors, pool owners, and producer state contain absolute GVAs. Restore
adopts the snapshot's scratch size, queue geometry, and transport addresses,
Expand Down
33 changes: 29 additions & 4 deletions src/hyperlight_common/src/layout.rs
Original file line number Diff line number Diff line change
Expand Up @@ -113,11 +113,35 @@ const _: () = {
assert!((SCRATCH_TOP_GPA as u64) < VIRTQ_BUFFER_GVA_END - VIRTQ_BUFFER_GVA_START);
};

/// Exclusive upper GPA boundary for dynamic scratch allocations.
pub const fn scratch_allocator_limit_gpa() -> u64 {
/// Unmapped pages between the transport arena and the reserved top pages.
/// On amd64 an exception stack overrun faults in them before it reaches the
/// arena. aarch64 exception vectors lower SP before their first store, so
/// nested faults there can step past them.
pub const SCRATCH_TRANSPORT_GAP_PAGES: usize = 1;
Comment thread
danbugs marked this conversation as resolved.

/// Start of the reserved pages at the top of scratch. The transport arena
/// sits [`SCRATCH_TRANSPORT_GAP_PAGES`] below it. A guest's page allocator
/// stops at the arena GPA the host publishes.
pub const fn scratch_reserved_top_gpa() -> u64 {
(SCRATCH_TOP_GPA + 1 - SCRATCH_TOP_RESERVED_PAGES * crate::vmem::PAGE_SIZE) as u64
}

/// [`scratch_reserved_top_gpa`] in the scratch mapping.
pub const fn scratch_reserved_top_gva() -> u64 {
SCRATCH_TOP_GVA as u64 + 1 - (SCRATCH_TOP_RESERVED_PAGES * crate::vmem::PAGE_SIZE) as u64
}

/// Exclusive end of the transport arena: the reserved top pages, less the
/// guard pages between them.
pub const fn scratch_transport_end_gpa() -> u64 {
scratch_reserved_top_gpa() - (SCRATCH_TRANSPORT_GAP_PAGES * crate::vmem::PAGE_SIZE) as u64
}

/// [`scratch_transport_end_gpa`] in the scratch mapping.
pub const fn scratch_transport_end_gva() -> u64 {
scratch_reserved_top_gva() - (SCRATCH_TRANSPORT_GAP_PAGES * crate::vmem::PAGE_SIZE) as u64
}

pub fn scratch_base_gpa(size: usize) -> u64 {
(SCRATCH_TOP_GPA - size + 1) as u64
}
Expand All @@ -127,8 +151,9 @@ pub const fn scratch_base_gva(size: usize) -> u64 {

/// Compute fixed scratch overhead for a sandbox, saturating at [`usize::MAX`].
///
/// `transport_len` covers rings and buffer pools. Snapshot page tables and
/// runtime allocations need additional scratch.
/// `transport_len` covers the transport arena (rings, mailbox and buffer
/// pools) and the [`SCRATCH_TRANSPORT_GAP_PAGES`] above it. Snapshot page
/// tables and runtime allocations need additional scratch.
pub fn min_scratch_size(transport_len: usize) -> usize {
arch::min_scratch_size()
.and_then(|fixed| fixed.checked_add(transport_len))
Expand Down
4 changes: 2 additions & 2 deletions src/hyperlight_guest/src/arch/aarch64/prim_alloc.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
// Copyright 2025 The Hyperlight Authors.

use hyperlight_common::flatbuffer_wrappers::guest_error::ErrorCode;
use hyperlight_common::{layout, vmem};
use hyperlight_common::vmem;

// There are no notable architecture-specific safety considerations
// here, and the general conditions are documented in the
Expand All @@ -22,7 +22,7 @@ pub unsafe fn alloc_phys_pages(n: u64) -> u64 {
prev_base = out(reg) prev_base,
);
}
let limit = layout::scratch_allocator_limit_gpa();
let limit = crate::layout::allocator_limit_gpa();
if prev_base.checked_add(nbytes).is_none_or(|end| end > limit) {
unsafe {
crate::exit::abort_with_code_and_message(
Expand Down
4 changes: 2 additions & 2 deletions src/hyperlight_guest/src/arch/amd64/prim_alloc.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
// Copyright 2025 The Hyperlight Authors.

use hyperlight_common::flatbuffer_wrappers::guest_error::ErrorCode;
use hyperlight_common::{layout, vmem};
use hyperlight_common::vmem;

// There are no notable architecture-specific safety considerations
// here, and the general conditions are documented in the
Expand All @@ -19,7 +19,7 @@ pub unsafe fn alloc_phys_pages(n: u64) -> u64 {
x = inout(reg) x
);
}
let limit = layout::scratch_allocator_limit_gpa();
let limit = crate::layout::allocator_limit_gpa();
if x.checked_add(nbytes).is_none_or(|end| end > limit) {
unsafe {
crate::exit::abort_with_code_and_message(
Expand Down
27 changes: 27 additions & 0 deletions src/hyperlight_guest/src/layout.rs
Original file line number Diff line number Diff line change
Expand Up @@ -52,3 +52,30 @@ pub fn guest_log_level_gva() -> *mut u64 {
(SCRATCH_TOP_GVA as u64 - SCRATCH_TOP_GUEST_LOG_LEVEL_OFFSET + 1) as *mut u64
}
pub use arch::{scratch_base_gpa, scratch_base_gva};

/// Exclusive upper GPA for the scratch page allocator: the transport arena,
/// which sits below the reserved top pages.
///
/// Aborts if the host has not published an arena between the scratch base
/// and the end of the transport range, so allocations never reach the
/// rings.
pub fn allocator_limit_gpa() -> u64 {
// SAFETY: the host writes the arena GPA into scratch metadata before entry.
let arena = unsafe { transport_arena_gpa_gva().read_volatile() };
if arena <= scratch_base_gpa()
|| arena >= hyperlight_common::layout::scratch_transport_end_gpa()
{
// SAFETY: aborting is always sound.
unsafe {
crate::exit::abort_with_code_and_message(
&[
hyperlight_common::flatbuffer_wrappers::guest_error::ErrorCode::MallocFailed
as u8,
],
c"Transport arena not published between the scratch base and the transport end"
.as_ptr(),
)
}
}
arena
}
64 changes: 52 additions & 12 deletions src/hyperlight_guest/src/transport/context.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ use hyperlight_common::flatbuffer_wrappers::function_types::{
FunctionCallResult, ParameterValue, ReturnType, ReturnValue,
};
use hyperlight_common::flatbuffer_wrappers::util::estimate_flatbuffer_capacity;
use hyperlight_common::layout::SCRATCH_TOP_GVA;
use hyperlight_common::layout::scratch_transport_end_gva;
use hyperlight_common::outb::OutBAction;
use hyperlight_common::transport::{
EncodedMessage, ExternalValues, MailboxValue, MsgHeader, MsgKind,
Expand Down Expand Up @@ -119,23 +119,28 @@ impl GuestContext {
/// The caller must run in an initialized single-vCPU Hyperlight guest and
/// exclusively own both queues, their pools, and the writable `u64` mailbox
/// at `mbx_gva`. The mailbox must lie in scratch, disjoint from both queues
/// and pools. Scratch must remain mapped while the context or any returned
/// buffer views exist.
/// and pools. Both rings and pools must lie in the transport arena, which
/// starts at the G2H ring and ends below the reserved scratch-top pages,
/// with the pools after the rings. Scratch must remain mapped while the
/// context or any returned buffer views exist.
pub unsafe fn new(g2h: QueueConfig, h2g: QueueConfig, mbx_gva: u64) -> Result<Self> {
// SAFETY: Generation has been initialized by host.
let generation = unsafe { layout::snapshot_generation_gva().read_volatile() };

let g2h_pool = g2h_pool(g2h.pool_gva, g2h.pool_pages, g2h.buffer_size)
.with_context(|| "failed to create G2H pool")?;
// The G2H ring starts the transport arena.
let arena_gva = g2h.layout.desc_table_addr();
let scratch_gva = layout::scratch_base_gva();
// SAFETY: The caller owns the pool's scratch pages.
let mem = unsafe { pool_mem(&g2h_pool) }
let mem = unsafe { pool_mem(&g2h_pool, arena_gva, scratch_gva) }
.with_context(|| "failed to initialize G2H pool access")?;
let g2h_producer = VirtqProducer::new(g2h.layout, mem, OutbNotifier, g2h_pool);

let h2g_pool = h2g_pool(h2g.pool_gva, h2g.pool_pages, h2g.buffer_size)
.with_context(|| "failed to create H2G slot pool")?;
// SAFETY: The caller owns this disjoint pool.
let mem = unsafe { pool_mem(&h2g_pool) }
let mem = unsafe { pool_mem(&h2g_pool, arena_gva, scratch_gva) }
.with_context(|| "failed to initialize H2G pool access")?;
// H2G prefill supplies writable buffers for host-initiated messages.
let h2g_producer = VirtqProducer::new(h2g.layout, mem, NoopNotifier, h2g_pool);
Expand Down Expand Up @@ -548,24 +553,38 @@ fn pool_len(pages: usize) -> result::Result<usize, AllocError> {
.ok_or(AllocError::Overflow)
}

/// Validate pool placement and bind scratch access.
/// Validate pool placement and bind access to the arena up to the pool's end.
///
/// The bounds start at the arena, whose address does not depend on the
/// scratch size. The pools follow the rings, so a producer's rings and pool
/// lie in [arena, end of its pool). That range must start in scratch above
/// `scratch_gva`, where the copied page tables sit, and end below the guard
/// page under the reserved scratch-top pages.
///
/// # Safety
///
/// Run on the initialized guest vCPU and own the pool's scratch.
Comment thread
danbugs marked this conversation as resolved.
unsafe fn pool_mem(pool: &SlotPool) -> result::Result<GuestMemOps, AllocError> {
let scratch_gva = layout::scratch_base_gva();
let scratch_end = SCRATCH_TOP_GVA as u64 + 1;
unsafe fn pool_mem(
pool: &SlotPool,
arena_gva: u64,
scratch_gva: u64,
) -> result::Result<GuestMemOps, AllocError> {
let limit = scratch_transport_end_gva();

let base = pool.base_addr();
let end = base + pool.byte_len() as u64;

if !base.is_multiple_of(PAGE_SIZE as u64) || base < scratch_gva || end > scratch_end {
if !arena_gva.is_multiple_of(PAGE_SIZE as u64)
|| arena_gva <= scratch_gva
|| !base.is_multiple_of(PAGE_SIZE as u64)
|| base < arena_gva
|| end > limit
{
return Err(AllocError::InvalidArg);
}

// SAFETY: The validated pool lies in live scratch on the serialized guest vCPU.
unsafe { Ok(GuestMemOps::new(scratch_gva, scratch_end)) }
// SAFETY: The validated pool lies in the arena on the serialized guest vCPU.
unsafe { Ok(GuestMemOps::new(arena_gva, end)) }
}

/// Build the uniform H2G pool.
Expand Down Expand Up @@ -611,6 +630,27 @@ mod tests {

use super::*;

#[test]
fn pool_mem_binds_the_arena_below_the_reserved_top() {
let limit = scratch_transport_end_gva();
let page = PAGE_SIZE as u64;
let arena = limit - 8 * page;
let scratch = arena - 16 * page;
let pool = |base, pages| h2g_pool(base, pages, 256).unwrap();
// SAFETY: only bounds are computed. No memory is touched.
unsafe {
assert!(pool_mem(&pool(arena + 4 * page, 4), arena, scratch).is_ok());
// Into the guard page or the reserved pages.
assert!(pool_mem(&pool(arena + 4 * page, 5), arena, scratch).is_err());
// Below the arena.
assert!(pool_mem(&pool(arena - page, 1), arena, scratch).is_err());
// An arena below scratch, at its base, or not page aligned.
assert!(pool_mem(&pool(arena + 4 * page, 4), 0, scratch).is_err());
assert!(pool_mem(&pool(arena + 4 * page, 4), scratch, scratch).is_err());
assert!(pool_mem(&pool(arena + 4 * page, 4), arena + 8, scratch).is_err());
}
}

fn encoded_message(external: &[u8]) -> EncodedMessage<'_> {
let mut values = ExternalValues::new();
values.push_bytes(external).unwrap();
Expand Down
Loading
Loading