diff --git a/CHANGELOG.md b/CHANGELOG.md index a3522da6..c7e2f3c1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,22 +9,25 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), - The `java` runtime (tier 2): Java 25 on a jlinked runtime, with Java source run by JShell in the guest's JVM, so statements, expressions and declarations mix and stay for the next call, and a call that only declares a `main` (a compact source file, or a class) runs it. It serves guest function calls (a static method, its input converted to a record, collection or simple value, a future awaited) and makes host function calls through `hyperlight.Host.call`; `System.getenv()` sees the host's variables, and `System.exit()` ends the call with its status; the next call starts a fresh JVM while the guest has the memory for one (two restarts at 512 MiB: the kernel keeps an exited process's memory), and fails without crashing when it doesn't. The image is 84 MiB and runs in 512 MiB; a CDS archive of the classes the dispatcher loads while it warms up comes with it. `hluk init --template java` starts a project. - A `dev` channel: each push to `main` whose CI passes replaces the `dev` pre-release (the `hluk` binaries and a `SHA256SUMS`) and publishes the images of that commit (`:dev-`, `:initrd-dev-`), so `main` can be installed and run between releases: `HLUK_VERSION=dev` with `install.sh` or `install.ps1`. A dev `hluk` pulls the images of its own commit and prints its build with `--version` (`0.17.0+dev.44ee170`); only the last two builds' images are kept. Releases and their tags are unchanged (`dev.yml`, docs/RELEASE.md). - A project site, : startup times from CI, a density measurement (1,000 Python sandboxes in one process, about 2.8 MiB of host memory each), every template, and terminal recordings of `hluk init`, `hluk build` and a guest function call. Its source is `site/`, and `site.yml` publishes it to the `gh-pages` branch on pushes to `main`, beside the CI benchmark history. `just site-serve` previews it, `just site-record` re-records the demos and `site/tools/density.sh` repeats the density measurement. -- `AppSandbox::drain_stderr`, the guest's standard error apart from its standard output, and the host function behind it, `HostPrintErr` (see Changed). +- `AppSandbox::drain_stderr`, the guest's standard error apart from its standard output, and the host function behind it, `HostWriteErr` (see Changed). - `SandboxBuilder::output_limit(bytes)`: a bound, enforced on the host, on the guest output a sandbox keeps between drains, for its output (`drain_output`, both streams) and its standard error (`drain_stderr`) each. The host keeps the beginning and counts the rest, which `AppSandbox::take_output` and `take_stderr` report with the text (`CapturedOutput`); the guest carries on. Unlimited by default. `SandboxBuilder::echo_output(false)` stops the copy to the host process's stdout and stderr, so the capture is all there is. ### Changed -- A guest's standard error is a stream of its own, through a new host function, `HostPrintErr`: `hluk run` writes it to its own stderr instead of its stdout, and `AppSandbox::drain_stderr` returns it apart from standard output. `drain_output` still returns both, in the order written, and drops what `drain_stderr` has not taken, so take standard error first. The kernel's own messages go to standard error too, out of the program's standard output, and so does the report of `hluk run --profile`, which shares stderr with them. A host of your own that does not register `HostPrintErr` gets standard error through `HostPrint`, as before. +- A guest's standard error is a stream of its own, through a new host function, `HostWriteErr`: `hluk run` writes it to its own stderr instead of its stdout, and `AppSandbox::drain_stderr` returns it apart from standard output. `drain_output` still returns both, in the order written, and drops what `drain_stderr` has not taken, so take standard error first. The kernel's own messages go to standard error too, out of the program's standard output, and so does the report of `hluk run --profile`, which shares stderr with them. A host of your own that does not register `HostWriteErr` gets standard error through `HostWrite`. - Guest output keeps its newlines: `drain_output`, `drain_stderr` and `hluk run` no longer have a `\r` before each `\n`. -- Snapshots saved by an earlier release are refused (the kernel and the host functions changed); save them again. - The Linux release binaries are static, built with musl (`hluk-v-x86_64-unknown-linux-musl.tar.gz` and `aarch64-unknown-linux-musl`), so they run on any distribution. The glibc builds of earlier releases need the glibc of the runner that built them, 2.39, which Debian 12 and Ubuntu 22.04 do not have (#141). `install.sh` installs the static one and, for a release before this, the glibc one. +- Hyperlight's virtqueue transport (hyperlight v0.18): the kernel and the host talk over two VIRTIO packed rings in scratch memory instead of the PEB's I/O stacks. Host function calls are a request and its reply in one G2H chain; guest function calls arrive in buffers the kernel posts on H2G, and their results go back on G2H. Byte vectors travel after the FlatBuffer rather than inside it. The pools are sized for 64 KiB host-call payloads, rounded up to whole pages: one host call now carries 69,620 bytes, and a guest function call 73,716. hostfs reads and writes move 64 KiB per host call instead of 32 KiB: against v0.17, reading a 64 MiB file from Python is about 55% faster and writing one about 50% (KVM). `hyperlight-host` and `hyperlight-common` move to 0.18. +- Snapshots saved by an earlier release or build are refused (the kernel, the host functions and the host transport changed); save them again. A native Unikraft kernel (`--kernel`, `SandboxBuilder::from_kernel`) built against an earlier `plat/hyperlight` no longer boots: rebuild it against this kernel tree. ### Fixed - `hluk run`, `hluk snapshot` and `hluk bench` kept everything a guest printed in memory until they exited, though they never read it: a long-running guest that kept printing grew the host process without end. They keep none of it now; the output still reaches the terminal. +- A path below a host mount's root longer than 1,023 bytes was cut short, and the syscall made on what was left: `mkdir` reported success and created a directory with a truncated name. It now fails with `ENAMETOOLONG`. +- A path on a host mount that was not UTF-8 (`os.stat(b'/mnt/data/\xff')`) failed the guest function call that used it, since the host takes paths as strings: the kernel now refuses it, and the syscall fails with `EILSEQ`. +- Console output written in one piece longer than 4 KiB (a long line, say) reached the host cut to its first 4 KiB, with nothing to say so; and output that was not UTF-8, or a write that ended inside a character (`cat` of a large UTF-8 file can do it), failed the guest function call that printed it. The kernel now sends console output as bytes, in as many host calls as it takes, through new `HostWrite` and `HostWriteErr` host functions, and the host decodes them, a character split between writes included, with invalid bytes as U+FFFD, for stdout and `drain_output()` alike. Stdin, the other way, had the same flaw: a character cut at a 4 KiB read, or bytes that were not UTF-8, reached the guest as U+FFFD. `ReadStdin` now hands over bytes too (the guest's terminal layer still treats them as a terminal does: it echoes them, and reads Ctrl-D as end of input). - `hluk init NAME --template go` wrote a `go.mod` that Go refused when the name was `go`, `toolchain` or a standard-library package such as `fmt`: the module path is now `example.com/NAME`. - A condition variable broadcast, or several signals, woke one waiter and left the others asleep in programs linked with musl, which most images use (node, dotnet-jit, java, quickjs, bash, and programs built on Alpine): the kernel refused `FUTEX_REQUEUE`, which musl's condition variables use to hand each woken waiter's successor to the mutex. It showed as rare, timing-dependent hangs, such as Java's `HttpClient` stalling on its first request on Windows hosts. `FUTEX_CMP_REQUEUE` also woke a waiter when asked to wake none, looped when requeuing onto the same futex, and crashed the kernel when a requeued waiter timed out. All four are generic Unikraft fixes, each reproduced and verified on QEMU/KVM. -- A program's write of more than 4 KiB at once reached the host cut to its first 4 KiB, while the program was told all of it was written: `print("x" * 10000)` printed 4,096 bytes. The kernel sends such a write in host function calls of at most 4 KiB each, cut between UTF-8 characters, never inside one. - Standard error reached the host as standard output: the kernel opened fd 2 as a copy of fd 1. It is a stream of its own now (see Changed). - Every newline a guest printed reached the host as CR-LF (`\r\n`), from a workaround for serial consoles that need a carriage return. The host gets the bytes the program wrote (see Changed). - A command that failed to execute (a mistyped command in the bash image, `subprocess.run(['/missing'])` in python-shell) crashed the guest the next time the parent waited: the vfork child freed its parent's stack on its way out, as if it were its own. A subprocess started from a thread other than the main one failed with `ENOMEM`, for want of an allocator for the new program's stack. Both are generic Unikraft fixes, verified on QEMU/KVM. diff --git a/Cargo.lock b/Cargo.lock index 54fab04b..10427416 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -773,9 +773,9 @@ dependencies = [ [[package]] name = "hyperlight-common" -version = "0.17.0" +version = "0.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ad5576d6ef947dc2822c2f5038ddff8325c158ddef4cbabf9d0b125294cdb0f" +checksum = "360d5876788b26af66bf055bca6b4f2c9c930a0fdcaebda4bd7f808c6bd93068" dependencies = [ "anyhow", "bitflags 2.13.1", @@ -793,9 +793,9 @@ dependencies = [ [[package]] name = "hyperlight-host" -version = "0.17.0" +version = "0.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d85dcf5ae7a578e1b09bdeb76cc3b3ca8e6e57d6395d2f57204aec45c1bbbde9" +checksum = "40f8b85ddd688890477158b9921b228973d1b70c84b866698838df0f32d2aa44" dependencies = [ "anyhow", "bindgen", @@ -844,6 +844,8 @@ dependencies = [ name = "hyperlight-unikraft" version = "0.17.0" dependencies = [ + "anyhow", + "bytes", "cap-std", "clap", "flate2", @@ -1151,9 +1153,9 @@ dependencies = [ [[package]] name = "mshv-bindings" -version = "0.6.9" +version = "0.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83303108160c2b7a7bdd25000ee679384e19471386d23e501ed832574c9229ef" +checksum = "9b4b2414499597c6e31b1cd25239f19e8e8c55023b3bf8fcfa44d927cff6a123" dependencies = [ "libc", "num_enum", @@ -1163,9 +1165,9 @@ dependencies = [ [[package]] name = "mshv-ioctls" -version = "0.6.9" +version = "0.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1db4449ac7012237b133da366f5b32ce4af1f8caf770486e5a9d54f7f6b73c4c" +checksum = "c30d1b5b76a709e30b46248f52bbb44dde04a659d6d58255c7f06c88d528d210" dependencies = [ "libc", "mshv-bindings", diff --git a/Cargo.toml b/Cargo.toml index 7adc5910..d332a5f9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -34,7 +34,7 @@ cli = [ mshv = ["hyperlight-host/mshv3"] [dependencies] -hyperlight-common = "0.17" +hyperlight-common = "0.18" serde_json = "1" thiserror = "2" tracing = "0.1" @@ -76,12 +76,12 @@ sha2 = { version = "0.11", optional = true } # Windows — WHP, compiled in unconditionally for the target (no feature). # macOS — Hypervisor.framework via the default `hvf` feature. [target.'cfg(target_os = "linux")'.dependencies] -hyperlight-host = { version = "0.17", default-features = false, features = ["kvm", "build-metadata"] } +hyperlight-host = { version = "0.18", default-features = false, features = ["kvm", "build-metadata"] } # Non-Linux keeps hyperlight's defaults: HVF (macOS) is on, WHP (Windows) is # unconditional, and the Linux-only kvm/mshv3 features are inert here. [target.'cfg(not(target_os = "linux"))'.dependencies] -hyperlight-host = "0.17" +hyperlight-host = "0.18" [target.'cfg(windows)'.dependencies] # Private working set for the bench RSS line (already in the tree via clap). @@ -89,3 +89,6 @@ windows-sys = { version = "0.61", features = ["Win32_System_ProcessStatus", "Win [dev-dependencies] tempfile = "3" +# The FlatBuffer decoder's external-value source, mirrored in the tests. +anyhow = "1" +bytes = "1" diff --git a/build.rs b/build.rs index baaaf928..09833508 100644 --- a/build.rs +++ b/build.rs @@ -11,11 +11,11 @@ use std::path::{Path, PathBuf}; use sha2::{Digest, Sha256}; /// The host side of the guest contract: the host functions and their -/// meaning, the I/O stack sizes, the heap and scratch layout, the guest -/// MSR list, and the hyperlight-host release that reads a snapshot. Bump -/// it when any of those changes in a way a snapshot would notice; a +/// meaning, the virtqueue pool sizes, the heap and scratch layout, the +/// guest MSR list, and the hyperlight-host release that reads a snapshot. +/// Bump it when any of those changes in a way a snapshot would notice; a /// kernel change rolls the key by itself. -const SNAPSHOT_CONTRACT: u32 = 4; +const SNAPSHOT_CONTRACT: u32 = 5; /// The embedded kernel, whose code and host-call protocol a snapshot /// carries in its memory: one per architecture, named as Unikraft names diff --git a/defconfig-elfloader b/defconfig-elfloader index f0ffc12b..d51c437c 100644 --- a/defconfig-elfloader +++ b/defconfig-elfloader @@ -87,7 +87,7 @@ CONFIG_LIBHOSTSOCK=y # can read host-provided input via sys.stdin, process.stdin, etc. CONFIG_LIBPOSIX_TTY_STDIN_SERIAL=y CONFIG_LIBPOSIX_TTY_SERIAL_EOF_FINAL=y -# stderr is a stream of its own (HostPrintErr), and newlines reach the +# stderr is a stream of its own (HostWriteErr), and newlines reach the # host as the program wrote them, not as CR-LF. Pinned here on purpose, # though the kernel defaults them so for Hyperlight. CONFIG_LIBPOSIX_TTY_SERIAL_STDERR=y diff --git a/demos/pptx-gen/Cargo.lock b/demos/pptx-gen/Cargo.lock index ca4cb45c..78571b54 100644 --- a/demos/pptx-gen/Cargo.lock +++ b/demos/pptx-gen/Cargo.lock @@ -2,6 +2,12 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + [[package]] name = "aho-corasick" version = "1.1.5" @@ -105,7 +111,7 @@ checksum = "1b17e44510f88a904cb3102a020d7ea11929dcddadfaff756e71112cb2cb7bd0" dependencies = [ "async-convert", "backoff", - "base64", + "base64 0.22.1", "bytes", "derive_builder", "eventsource-stream", @@ -166,6 +172,12 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "base64" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" + [[package]] name = "bindgen" version = "0.72.1" @@ -472,6 +484,15 @@ dependencies = [ "libc", ] +[[package]] +name = "crc32fast" +version = "1.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01a7799fd6b852db0e61728dde9a204c423b44d689dbd432522543614b490e78" +dependencies = [ + "cfg-if", +] + [[package]] name = "crossbeam-channel" version = "0.5.16" @@ -617,6 +638,12 @@ version = "1.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34" +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + [[package]] name = "errno" version = "0.3.14" @@ -644,6 +671,16 @@ version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" +[[package]] +name = "filetime" +version = "0.2.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759" +dependencies = [ + "cfg-if", + "libc", +] + [[package]] name = "find-msvc-tools" version = "0.1.11" @@ -666,6 +703,17 @@ dependencies = [ "rustc_version", ] +[[package]] +name = "flate2" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb" +dependencies = [ + "crc32fast", + "miniz_oxide", + "zlib-rs", +] + [[package]] name = "fnv" version = "1.0.7" @@ -866,6 +914,12 @@ dependencies = [ "scroll", ] +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + [[package]] name = "heck" version = "0.5.0" @@ -968,7 +1022,7 @@ version = "0.1.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" dependencies = [ - "base64", + "base64 0.22.1", "bytes", "futures-channel", "futures-util", @@ -987,9 +1041,9 @@ dependencies = [ [[package]] name = "hyperlight-common" -version = "0.17.0" +version = "0.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ad5576d6ef947dc2822c2f5038ddff8325c158ddef4cbabf9d0b125294cdb0f" +checksum = "360d5876788b26af66bf055bca6b4f2c9c930a0fdcaebda4bd7f808c6bd93068" dependencies = [ "anyhow", "bitflags 2.13.1", @@ -1007,9 +1061,9 @@ dependencies = [ [[package]] name = "hyperlight-host" -version = "0.17.0" +version = "0.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d85dcf5ae7a578e1b09bdeb76cc3b3ca8e6e57d6395d2f57204aec45c1bbbde9" +checksum = "40f8b85ddd688890477158b9921b228973d1b70c84b866698838df0f32d2aa44" dependencies = [ "anyhow", "bindgen", @@ -1074,14 +1128,20 @@ version = "0.17.0" dependencies = [ "cap-std", "clap", + "flate2", + "getrandom 0.4.3", "hyperlight-common", "hyperlight-host", "rustix", + "serde", "serde_json", "sha2", + "tar", "thiserror 2.0.20", + "toml", "tracing", "tracing-subscriber", + "ureq", "windows-sys 0.61.2", ] @@ -1219,6 +1279,16 @@ dependencies = [ "icu_properties", ] +[[package]] +name = "indexmap" +version = "2.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" +dependencies = [ + "equivalent", + "hashbrown", +] + [[package]] name = "instant" version = "0.1.13" @@ -1475,6 +1545,16 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" +[[package]] +name = "miniz_oxide" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c" +dependencies = [ + "adler2", + "simd-adler32", +] + [[package]] name = "mio" version = "1.2.3" @@ -1488,9 +1568,9 @@ dependencies = [ [[package]] name = "mshv-bindings" -version = "0.6.9" +version = "0.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83303108160c2b7a7bdd25000ee679384e19471386d23e501ed832574c9229ef" +checksum = "9b4b2414499597c6e31b1cd25239f19e8e8c55023b3bf8fcfa44d927cff6a123" dependencies = [ "libc", "num_enum", @@ -1500,9 +1580,9 @@ dependencies = [ [[package]] name = "mshv-ioctls" -version = "0.6.9" +version = "0.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1db4449ac7012237b133da366f5b32ce4af1f8caf770486e5a9d54f7f6b73c4c" +checksum = "c30d1b5b76a709e30b46248f52bbb44dde04a659d6d58255c7f06c88d528d210" dependencies = [ "libc", "mshv-bindings", @@ -1891,7 +1971,7 @@ version = "0.12.28" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" dependencies = [ - "base64", + "base64 0.22.1", "bytes", "futures-core", "futures-util", @@ -2038,6 +2118,7 @@ version = "0.23.43" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06" dependencies = [ + "log", "once_cell", "ring", "rustls-pki-types", @@ -2217,6 +2298,15 @@ dependencies = [ "zmij", ] +[[package]] +name = "serde_spanned" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" +dependencies = [ + "serde_core", +] + [[package]] name = "serde_urlencoded" version = "0.7.1" @@ -2280,6 +2370,12 @@ dependencies = [ "libc", ] +[[package]] +name = "simd-adler32" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" + [[package]] name = "slab" version = "0.4.12" @@ -2389,6 +2485,17 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "tar" +version = "0.4.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840" +dependencies = [ + "filetime", + "libc", + "xattr", +] + [[package]] name = "tempfile" version = "3.27.0" @@ -2548,6 +2655,45 @@ dependencies = [ "tokio", ] +[[package]] +name = "toml" +version = "1.1.6+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "920602543f0911ab71da12c50d59701da54c196d1a2bf5cb4b75667f137a406a" +dependencies = [ + "indexmap", + "serde_core", + "serde_spanned", + "toml_datetime", + "toml_parser", + "toml_writer", + "winnow", +] + +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_parser" +version = "1.1.3+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56" +dependencies = [ + "winnow", +] + +[[package]] +name = "toml_writer" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2" + [[package]] name = "tower" version = "0.5.3" @@ -2691,6 +2837,34 @@ version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" +[[package]] +name = "ureq" +version = "3.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a7ac20be9b7726e0bbdbf974c059676d9acb1cd414961f570a4e8231cacd7fc" +dependencies = [ + "base64 0.23.1", + "log", + "percent-encoding", + "rustls", + "rustls-pki-types", + "ureq-proto", + "utf8-zero", + "webpki-roots", +] + +[[package]] +name = "ureq-proto" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f86fd172ccca569e458f61b6bdd6220965a9ef36e672a6852953b51a0e1583be" +dependencies = [ + "base64 0.23.1", + "http", + "httparse", + "log", +] + [[package]] name = "url" version = "2.5.8" @@ -2703,6 +2877,12 @@ dependencies = [ "serde", ] +[[package]] +name = "utf8-zero" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8c0a043c9540bae7c578c88f91dda8bd82e59ae27c21baca69c8b191aaf5a6e" + [[package]] name = "utf8_iter" version = "1.0.4" @@ -2861,6 +3041,15 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "webpki-roots" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "winapi" version = "0.3.9" @@ -3176,6 +3365,12 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" +[[package]] +name = "winnow" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" + [[package]] name = "winx" version = "0.36.4" @@ -3192,6 +3387,16 @@ version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" +[[package]] +name = "xattr" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156" +dependencies = [ + "libc", + "rustix", +] + [[package]] name = "yoke" version = "0.8.3" @@ -3295,6 +3500,12 @@ dependencies = [ "syn 3.0.4", ] +[[package]] +name = "zlib-rs" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b268e58e7c693d7c271f93ffc4ba3b380412554231c85bf61ca7af91042a4112" + [[package]] name = "zmij" version = "1.0.23" diff --git a/docs/RELEASE.md b/docs/RELEASE.md index 1dfe23da..a445d5bd 100644 --- a/docs/RELEASE.md +++ b/docs/RELEASE.md @@ -14,7 +14,7 @@ User-facing changes are recorded in [`CHANGELOG.md`](../CHANGELOG.md) (Keep a Ch ## Cutting a release 1. In [`CHANGELOG.md`](../CHANGELOG.md), rename the top `## [Prerelease] - Unreleased` heading to the version being cut (`## [v]`), confirm it captures what shipped, and add a fresh empty `## [Prerelease] - Unreleased` above it for the next cycle. (`just changelog-notes v` prints exactly what the release notes will be.) -2. If the host side of the guest contract changed since the last release (a host function's meaning, the I/O stack sizes, the layout, the guest MSRs (system registers on arm64), or the hyperlight-host dependency), bump `SNAPSHOT_CONTRACT` in [`build.rs`](../build.rs) so saved snapshots are refused rather than misread. A kernel change rolls the snapshot key by itself, and a release that changes neither keeps every saved snapshot loadable. +2. If the host side of the guest contract changed since the last release (a host function's meaning, the virtqueue pool sizes, the layout, the guest MSRs (system registers on arm64), or the hyperlight-host dependency), bump `SNAPSHOT_CONTRACT` in [`build.rs`](../build.rs) so saved snapshots are refused rather than misread. A kernel change rolls the snapshot key by itself, and a release that changes neither keeps every saved snapshot loadable. 3. Bump `version` in `Cargo.toml` on `main` (semver, e.g. `0.2.0`), commit, and push (the `CHANGELOG.md` edit can ride in the same commit). 4. Actions → **Create release** → **Run workflow**. It: - validates the version and that the tag doesn't exist, diff --git a/docs/calls.md b/docs/calls.md index 488d8b1a..35f52bb8 100644 --- a/docs/calls.md +++ b/docs/calls.md @@ -80,7 +80,7 @@ The CLI registers no host functions, so a host function call fails there. Use th - **Host function calls are synchronous.** The guest waits while your function runs on the thread driving the sandbox. In Python, only the thread running the code or guest function can make a host function call; others get a `RuntimeError`. - **Snapshots.** Functions belong to the sandbox, not the guest. A restored guest calls the functions of the builder that restored it, so different hosts can serve the same snapshot with different functions. One catch: a quickjs `host:` module is built the first time it is imported, so a snapshot taken after `import ... from "host:db"` keeps that module's exports. -- **Limits.** A host function call's name and arguments together, and its reply, are at most 64 KiB, as is a guest function's result. The empty name is reserved: it lists the registered functions. +- **Limits.** A host function call's name and arguments together are at most 69,620 bytes (the 64 KiB the transport's pool is sized for, plus what rounding the pool up to whole pages leaves over), as is a guest function's result; what your function returns, at most 69,619, since the reply also says whether it succeeded. A longer reply reaches the guest as an error saying so. The empty name is reserved: it lists the registered functions. ### WebAssembly imports @@ -159,3 +159,17 @@ Wasmtime 49 marks its 0.3 support as experimental, so treat it as such. Build a ## How it works A guest function call is a `Call(function, input)` that the kernel queues on `/dev/hlcall`, like `Exec`. The driver writes the result after the status, and the kernel reports both with the entry's final `Yield`, so a call that returns within one entry costs a single VM exit. Host function calls all travel as one kernel host function, `HostCall(name, args)`, which the library dispatches by name, so adding one needs no kernel change. [driver.md](driver.md) has the device protocol, and [execution.md](execution.md) has the host side. + +### Transport + +The kernel and the host talk over Hyperlight's two virtqueues (VIRTIO packed rings in scratch memory). A host function call is one chain on the guest-to-host queue: the request, then a buffer for the reply. A guest function call arrives in 4 KiB buffers the kernel keeps posted on the host-to-guest queue, and its result goes back on the guest-to-host one. The kernel copies every message out of the pools, so it never holds a pool buffer past the call that used it. + +Hyperlight leaves the sizes to the host. hluk sets only the pool sizes (`apply_transport` in `src/lib.rs`): each half of the guest-to-host pool, request and reply, holds a 64 KiB payload plus framing, and the host-to-guest pool as much. Buffer sizes and queue depths keep Hyperlight's defaults. Measured on KVM with Python: + +| Knob | Effect | +|---|---| +| Payload per host call (hostfs chunk) | 32 → 64 KiB: reads and writes about 40% faster. 256 KiB: about 15% more, for 0.8 MiB more pool per sandbox. 1 MiB: no further gain. | +| Host-to-guest buffer size (4, 16 or 72 KiB) | No measurable change, even for a 60 KB guest function call. | +| Queue depths | Guest-to-host: one chain (a request and its reply buffer) at a time, so any depth of 2 or more serves; the kernel refuses less at boot. Host-to-guest: the kernel posts one buffer per descriptor, so the depth caps the largest guest function call; the default (32) posts the whole pool. | + +Against the PEB I/O stacks of Hyperlight 0.17, with the same 64 KiB chunks, hostfs reads and writes are about 25% faster; against v0.17 as released (32 KiB chunks), about 55% (reads) and 50% (writes). Guest function calls take the same time (47 µs for a small one). A small host function call takes about 1 µs more. diff --git a/docs/driver.md b/docs/driver.md index 899fab69..2b680dd3 100644 --- a/docs/driver.md +++ b/docs/driver.md @@ -9,7 +9,7 @@ The whole contract is one character device, `/dev/hlcall`, and six operations on | Operation | Meaning | |---|---| | `open()` | The driver is here. The kernel tells the host `DriverReady`; a call that arrives before any open is refused with `CallRejected`, which is what an image without a driver produces. | -| `ioctl(HLCALL_IOC_MAXLEN)` | How large a call can be, a `uint64_t` the kernel knows from the host's PEB. The driver allocates its read buffer from it, once. | +| `ioctl(HLCALL_IOC_MAXLEN)` | How large a call can be, a `uint64_t` the kernel knows from the transport geometry the host published. The driver allocates its read buffer from it, once. | | `read()` | Blocks until the host issues a call, then returns it whole: the FunctionCall FlatBuffer as the host encoded it. Reading again completes the previous call. | | `ioctl(HLCALL_IOC_GETENV)` | The variables the embedder set (`--env`, `set_env_vars`) as they are now, `KEY=VALUE` entries separated by NUL, into a buffer the driver provides. Asked at the top of every call; the kernel answers from its own copy unless the embedder changed the variables since (each call carries the host's version of them), so an unchanged environment costs no host call. | | `ioctl(HLCALL_IOC_HOSTCALL)` | Call one of the embedder's functions (`SandboxBuilder::host_function`) by name and get its reply. The kernel forwards it as the host function `HostCall(name, args)`. Only while a call is in flight. | @@ -30,7 +30,7 @@ sequenceDiagram D->>K: open(/dev/hlcall) K-->>H: DriverReady D->>K: ioctl(MAXLEN) - K-->>D: 64 KiB + K-->>D: 73,716 bytes Note over D: Py_Initialize() D->>K: read() Note over D: parked, nothing left to run @@ -58,7 +58,7 @@ A call can block. `run("time.sleep(2)")` puts the driver thread to sleep, and t `Exec` carries source: `run("print(6*7)")` arrives as `Exec` with that string as its one parameter, and the callback runs it in the runtime (an `Exec::File` is read on the host and sent the same way). `GuestExec` carries a command line for a program already in the image: `run(Exec::Guest("/app/server --port 8080"))` arrives as `GuestExec` with that line, the callback runs the file with that argv, and an empty line runs the image's conventional entrypoint, `/entrypoint.py` in the python image. [`hl_fc.h`](../drivers/hl_fc.h) reads the name and the parameters out of the FlatBuffer. One call at a time: the host finishes one before it issues the next. -`Call` carries a function name and an input. `call("greet", r#"{"name":"World"}"#)` asks the driver to run the guest's `greet` and send back its result with `hl_set_result()`. A driver opts in with `hl_driver_serve_calls()`. Without it, `hl_driver_run` fails a `Call` itself, so a callback written for `Exec` never runs a function name as code. A result is at most 64 KiB; the kernel refuses a larger one and the call fails. [calls.md](calls.md) covers what each image does with a call. +`Call` carries a function name and an input. `call("greet", r#"{"name":"World"}"#)` asks the driver to run the guest's `greet` and send back its result with `hl_set_result()`. A driver opts in with `hl_driver_serve_calls()`. Without it, `hl_driver_run` fails a `Call` itself, so a callback written for `Exec` never runs a function name as code. A result is at most 69,620 bytes, what one host call carries; the kernel refuses a larger one and the call fails. [calls.md](calls.md) covers what each image does with a call. ## Host functions @@ -68,7 +68,7 @@ Call it from the thread serving the call. The node, dotnet-jit and java runtimes ## The environment -`hluk run --env GREETING="it's me"` reaches the guest as the entry `GREETING=it's me` from `HLCALL_IOC_GETENV`. Only the embedder's variables travel this way; what the image's runtime sets for itself stays, unless the embedder sets the same key, which then wins. Together they must fit one host call, 64 KiB by default, since the kernel fetches them with one. [`hl_env.h`](../drivers/hl_env.h) does `setenv()` for the C side and hands each variable to the runtime: Python sets `os.environ["GREETING"]` through the C API, and bash, whose shell is a separate process fed with source, gets `export GREETING='it'\''s me'` in front of the call, quoted so the shell takes the value as it is. A variable the embedder removed stays set in the guest. +`hluk run --env GREETING="it's me"` reaches the guest as the entry `GREETING=it's me` from `HLCALL_IOC_GETENV`. Only the embedder's variables travel this way; what the image's runtime sets for itself stays, unless the embedder sets the same key, which then wins. Together, with a separator after each, they must fit one host call: under 69,620 bytes, since the kernel fetches them with one. [`hl_env.h`](../drivers/hl_env.h) does `setenv()` for the C side and hands each variable to the runtime: Python sets `os.environ["GREETING"]` through the C API, and bash, whose shell is a separate process fed with source, gets `export GREETING='it'\''s me'` in front of the call, quoted so the shell takes the value as it is. A variable the embedder removed stays set in the guest. ## Snapshots diff --git a/docs/fs.md b/docs/fs.md index caf1339c..57694c89 100644 --- a/docs/fs.md +++ b/docs/fs.md @@ -67,7 +67,9 @@ open("/mnt/host/foo.txt") Each mount corresponds to a `cap_std::fs::Dir` on the host side. The guest kernel injects a mount index into every host call so the host routes operations to the correct directory. -Reads and writes are transferred in chunks: the host's preferred size (`GetHostFsChunkSize`, 32 KiB), or less if one host call cannot carry that much — the guest reads the limit out of the PEB I/O stack sizes the host chose. +Reads and writes are transferred in chunks: the host's preferred size (`GetHostFsChunkSize`, 64 KiB), or less if one host call cannot carry that much — the guest reads the limit out of the virtqueue pool sizes the host chose. + +Paths travel to the host as UTF-8 strings: a name that is not UTF-8 fails with `EILSEQ`, and a host file whose name is not UTF-8 lists with U+FFFD in its place (and cannot be opened by that name). The path below the mount's root is at most 1,023 bytes; a longer one fails with `ENAMETOOLONG`. Two limits of the protocol as it stands. A directory listing is one host call, so a directory whose names do not fit in it (about 3,000 entries) does not list: `readdir` fails with `EOVERFLOW` and the guest goes on (TODO: page `fs_list`). `stat` carries the size and whether the entry is a file or a directory, nothing more: mode reads as `755` (`555` on a read-only file), timestamps as the epoch, and a symlink as the file it points at (TODO: carry the real mode, the times and an `lstat`). diff --git a/docs/net.md b/docs/net.md index 1bf9310c..d4c25f7a 100644 --- a/docs/net.md +++ b/docs/net.md @@ -80,7 +80,7 @@ All functions return negative `-errno` values on error. ## Limits - **Max sockets:** 1024 open sockets per sandbox (host-enforced). -- **Transfer size:** 64 KiB per `recvfrom` / `write` / `sendmsg` call, the most one host call carries; a longer write returns a short count and the caller's loop continues. The guest takes the figure from the PEB I/O stack sizes the host chose. +- **Transfer size:** 69,620 bytes per `write` / `sendmsg` call, the most one host call carries, and 69,593 per `recvfrom` (its reply also carries the source address); a longer write returns a short count and the caller's loop continues. The guest takes the figure from the virtqueue pool sizes the host chose. - **Protocols:** `AF_INET`/`AF_INET6` with `SOCK_STREAM`/`SOCK_DGRAM` reach the host. No raw sockets. `AF_UNIX` exists only inside the guest (`socketpair`, which asyncio's event loop needs), served by the kernel itself. ## Network policy diff --git a/drivers/hl_driver.h b/drivers/hl_driver.h index 8427ff96..858b7560 100644 --- a/drivers/hl_driver.h +++ b/drivers/hl_driver.h @@ -58,8 +58,8 @@ * itself, so a layout mismatch with the kernel reads as ENOTTY. */ /* The largest call a read() on the queue can return (a uint64_t). The - * kernel takes it from the host's PEB, so the driver never hard-codes a - * call size. */ + * kernel takes it from the transport geometry the host published, so the + * driver never hard-codes a call size. */ #define HLCALL_IOC_MAXLEN _IOR('H', 1, uint64_t) /* The host's environment as it is now: KEY=VALUE entries separated by NUL @@ -163,7 +163,7 @@ static inline void hl_driver_serve_calls(void) * call's status once the callback returns, and read there as the call's * result. -1 if it exceeds the buffer (a call's size, HLCALL_IOC_MAXLEN) * or the buffer cannot be allocated; - * the kernel takes at most what a host call carries (64 KiB by default), + * the kernel takes at most what a host call carries (69,620 bytes in hluk), * and refuses a larger one, which hl_driver_run then reports as a failed * call. */ diff --git a/drivers/hl_env.h b/drivers/hl_env.h index c8271dfa..080bd5c5 100644 --- a/drivers/hl_env.h +++ b/drivers/hl_env.h @@ -32,9 +32,9 @@ */ typedef void (*hl_env_cb_t)(const char *key, const char *val, void *ctx); -/* The entries arrive on the same PEB stack as a call, so a call-sized - * buffer always holds them; allocated on the first refresh, kept since - * this runs on every call. */ +/* The host sizes calls to hold any host call reply, the entries + * included, so a call-sized buffer always holds them; allocated on the + * first refresh, kept since this runs on every call. */ static char *g_hl_env_buf; /* diff --git a/examples/python/fs_large.py b/examples/python/fs_large.py index f6da972f..325ddf0b 100644 --- a/examples/python/fs_large.py +++ b/examples/python/fs_large.py @@ -1,9 +1,9 @@ """Large file I/O — exercises chunked hostfs reads and writes. -Writes and reads back data larger than a single transfer chunk (32 KB) -to verify that multi-chunk I/O works end-to-end. The host's PEB I/O -stacks must be sized to hold at least one chunk plus FlatBuffer -overhead; this test catches silent failures from undersized stacks. +Writes and reads back data larger than a single transfer chunk (64 KiB) +to verify that multi-chunk I/O works end-to-end. The host's virtqueue +pools must hold at least one chunk plus FlatBuffer overhead; this test +catches silent failures from undersized pools. """ import os @@ -11,8 +11,8 @@ MOUNT = "/mnt/host" PATH = os.path.join(MOUNT, "large.bin") -# 96 KB — three full 32 KB chunks. -SIZE = 96 * 1024 +# 192 KiB — three full 64 KiB chunks. +SIZE = 192 * 1024 PATTERN = b"ABCDEFGHIJKLMNOP" # 16 bytes, repeats evenly into SIZE data = PATTERN * (SIZE // len(PATTERN)) assert len(data) == SIZE diff --git a/kernel/elfloader_hyperlight-arm64 b/kernel/elfloader_hyperlight-arm64 index b812574c..94f5cc3c 100755 Binary files a/kernel/elfloader_hyperlight-arm64 and b/kernel/elfloader_hyperlight-arm64 differ diff --git a/kernel/elfloader_hyperlight-x86_64 b/kernel/elfloader_hyperlight-x86_64 index a952c99e..8714ec6e 100755 Binary files a/kernel/elfloader_hyperlight-x86_64 and b/kernel/elfloader_hyperlight-x86_64 differ diff --git a/kernel/unikraft b/kernel/unikraft index 0521c2a0..3c363e9d 160000 --- a/kernel/unikraft +++ b/kernel/unikraft @@ -1 +1 @@ -Subproject commit 0521c2a07e2024964a1747acc4d90d1c221634c7 +Subproject commit 3c363e9d5937e820f2a5de9236c2895b75a008a3 diff --git a/src/hostfs.rs b/src/hostfs.rs index 4a388e2c..af0bfd1e 100644 --- a/src/hostfs.rs +++ b/src/hostfs.rs @@ -43,8 +43,13 @@ use crate::{HOST_CALL_MAX, Mount, errno}; /// Maximum bytes per read/write host call. The guest queries this /// value via `GetHostFsChunkSize` at mount time — changing it here -/// does not require a kernel rebuild. -pub(crate) const CHUNK: usize = 32768; +/// does not require a kernel rebuild. 64 KiB: over the virtqueues a +/// 64 KiB chunk reads and writes about 40% faster than a 32 KiB one, +/// and larger ones gain little for the pool memory they cost. +pub(crate) const CHUNK: usize = 64 * 1024; + +// The chunk and its 4-byte status are one host call's reply. +const _: () = assert!(CHUNK + 4 <= HOST_CALL_MAX); /// Register all `fs_*` host functions. /// diff --git a/src/hostnet.rs b/src/hostnet.rs index e7d95363..c7730ec1 100644 --- a/src/hostnet.rs +++ b/src/hostnet.rs @@ -49,6 +49,11 @@ use crate::net_policy::{ListenPorts, NetworkPolicy}; const MAX_SOCKETS: usize = 1024; +/// What `net_recvfrom`'s reply carries besides the data: its length (4 +/// bytes) and the packed source address (at most 7 + 16). The kernel +/// keeps the same room (`HOSTSOCK_RECV_HDR` in lib/hostsock). +const RECVFROM_HDR: usize = 4 + 7 + 16; + /// Linux ABI constants the guest speaks. mod abi { pub const AF_INET: i32 = 2; @@ -649,9 +654,10 @@ impl Net { /// Receive up to `len` bytes plus the source address when the /// socket has one (datagrams). `len` is capped at what one host - /// call can carry back; the guest asks for at most that anyway. + /// call can carry back besides the reply's length and address + /// ([`RECVFROM_HDR`]); the guest asks for at most that anyway. fn recvfrom(&self, fd: i32, len: usize) -> Res<(Vec, Option)> { - let mut buf = vec![0u8; len.min(HOST_CALL_MAX)]; + let mut buf = vec![0u8; len.min(HOST_CALL_MAX - RECVFROM_HDR)]; let tbl = self.table(); let fd = tbl.get(fd)?; let (n, _, from) = match net::recvfrom(fd, &mut buf[..], RecvFlags::empty()) { diff --git a/src/lib.rs b/src/lib.rs index 353ce1b9..667875ba 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -26,7 +26,7 @@ use std::time::{Duration, Instant}; pub use hyperlight_host; use hyperlight_host::{ - GuestBinary, HyperlightError, MultiUseSandbox, UninitializedSandbox, func::Registerable, + GuestBinary, HyperlightError, Sandbox, UninitializedSandbox, func::Registerable, sandbox::SandboxConfiguration, sandbox::snapshot::OciTag, }; @@ -204,8 +204,9 @@ fn dispatch_host_call(table: &HostFunctionTable, name: &str, args: &[u8]) -> Vec Ok(text) => (HOST_CALL_OK, text), Err(message) => (HOST_CALL_ERR, message), }; - // A reply the guest's buffer cannot take would arrive cut short; it - // becomes an error the guest can report instead. + // A reply the guest's buffer cannot take would be refused by the + // kernel, and the call fail with nothing to say why; it becomes an + // error the guest can report instead. let (tag, text) = if text.len() + 1 > HOST_CALL_MAX { ( HOST_CALL_ERR, @@ -364,26 +365,165 @@ mod scratch_tests { } } -/// Largest payload of one host call, in either direction. -/// -/// The host decides this alone: it sizes the PEB I/O stacks -/// ([`IO_STACK_SIZE`]) and the guest sizes every transfer buffer from -/// the stack sizes it reads back out of the PEB -/// (`hl_hcall_max_payload()` in `plat/hyperlight/hcall.c`: the smaller -/// stack less a 4 KiB reserve for the FlatBuffer framing). So a guest -/// never asks for, or sends, more than this, and the host functions -/// here only cap what they hand back (`net_recvfrom`) as a courtesy to -/// a guest that asks for more. -pub(crate) const HOST_CALL_MAX: usize = 64 * 1024; - -/// PEB I/O stack size for host-call data transfer. -/// -/// Both the input stack (host→guest results) and output stack -/// (guest→host calls) must hold a FlatBuffer-encoded message carrying -/// a [`HOST_CALL_MAX`] payload plus its framing, the same 4 KiB reserve -/// the guest subtracts. Default Hyperlight stacks are only 16 KiB — -/// too small for large file or network transfers. -const IO_STACK_SIZE: usize = HOST_CALL_MAX + 4096; +/// Guest page size, the unit the virtqueue pools are sized in. +const PAGE_SIZE: usize = hyperlight_common::vmem::PAGE_SIZE; + +/// What the guest keeps back from each half of the G2H pool: the +/// message header, and a 4 KiB reserve for the FlatBuffer framing +/// (`HL_HCALL_FRAMING` in `plat/hyperlight/hcall.c`). +const MSG_OVERHEAD: usize = hyperlight_common::transport::MsgHeader::SIZE + 4096; + +/// Pages in each half of the guest-to-host pool. The guest splits the +/// pool in two, a host call's request and its reply, and each half +/// carries a 64 KiB payload (a file or socket transfer) plus +/// [`MSG_OVERHEAD`]. Hyperlight's default pool (12 pages) is too small +/// for that. +const G2H_HALF_PAGES: usize = (64 * 1024 + MSG_OVERHEAD).div_ceil(PAGE_SIZE); +const G2H_POOL_PAGES: usize = 2 * G2H_HALF_PAGES; + +/// Largest payload of one host call, in either direction: exactly what +/// the guest computes from the pool geometry (`hl_hcall_max_payload()` +/// in `plat/hyperlight/hcall.c`), 69,620 bytes. The host decides it +/// alone, through the pool sizes, and the guest sizes every transfer +/// buffer from it. So a guest never sends more than this, and the host +/// functions that hand back data (`fs_*` reads and listings, +/// `net_recvfrom`, `HostCall` results) keep their whole reply within it, +/// data and what comes with it; the kernel refuses a larger result rather +/// than cut it short. +pub(crate) const HOST_CALL_MAX: usize = G2H_HALF_PAGES * PAGE_SIZE - MSG_OVERHEAD; + +/// Pages of the host-to-guest pool, cut into receive buffers of +/// Hyperlight's default size. A guest function call can be as large as +/// a host call's whole message (the kernel sizes `/dev/hlcall` buffers, +/// which also take host call replies, from it), so it gets as many pages +/// as a G2H half. +const H2G_POOL_PAGES: usize = G2H_HALF_PAGES; + +// Hyperlight keeps one H2G buffer spare for a call with byte parameters; +// hluk's guest functions (`step`, `Exec`, `Call`, ...) take strings and +// integers only, and the kernel serves no other, so every buffer is the +// call's. +const _: () = { + assert!(HOST_CALL_MAX >= 64 * 1024); + // The guest posts min(queue size, pool / buffer size) receive + // buffers: the default queue must post all of them, or a call the + // size of a G2H half would not fit. + let buffers = H2G_POOL_PAGES * PAGE_SIZE / SandboxConfiguration::DEFAULT_H2G_BUFFER_SIZE; + assert!(buffers <= SandboxConfiguration::DEFAULT_H2G_QUEUE_SIZE); + assert!(buffers * SandboxConfiguration::DEFAULT_H2G_BUFFER_SIZE >= G2H_HALF_PAGES * PAGE_SIZE); +}; + +/// Size the virtqueue pools for [`HOST_CALL_MAX`] transfers. The other +/// knobs keep Hyperlight's defaults: this guest copies every message out +/// of the pools, so buffer sizes and queue depths change nothing it +/// measures (see the transport notes in `docs/`). +fn apply_transport(cfg: &mut SandboxConfiguration) { + cfg.set_g2h_pool_pages(G2H_POOL_PAGES); + cfg.set_h2g_pool_pages(H2G_POOL_PAGES); +} + +/// A decoder for one stream of guest console output. The guest sends +/// bytes; a character split between two writes is held back until its end +/// arrives, across drains (a program may print half of one in one step and +/// the rest in the next), or the guest ends; bytes that are not UTF-8 read +/// as U+FFFD. +#[derive(Default)] +struct GuestOutput { + /// The start of a character whose end is still to come. + partial: Vec, +} + +impl GuestOutput { + /// Take `bytes` in; returns the text they completed. + fn push(&mut self, bytes: &[u8]) -> String { + let mut text = String::new(); + self.join(bytes, &mut text); + text + } + + /// [`push`](Self::push), for a write the caller owns: with nothing held + /// and bytes that are UTF-8, perhaps but for a character cut short at + /// the end (most writes), the text is the bytes themselves, not a copy. + fn push_owned(&mut self, bytes: Vec) -> String { + if !self.partial.is_empty() { + return self.push(&bytes); + } + match String::from_utf8(bytes) { + Ok(text) => text, + Err(e) if e.utf8_error().error_len().is_none() => { + let valid = e.utf8_error().valid_up_to(); + let mut bytes = e.into_bytes(); + self.partial = bytes.split_off(valid); + // `valid_up_to` bytes are UTF-8: this never falls back. + String::from_utf8(bytes).unwrap_or_default() + } + Err(e) => self.push(e.as_bytes()), + } + } + + /// End the output: a character still cut short reads as U+FFFD. + /// Returns the text that adds. + fn finish(&mut self) -> String { + if self.partial.is_empty() { + return String::new(); + } + self.partial.clear(); + char::REPLACEMENT_CHARACTER.to_string() + } + + fn join(&mut self, mut bytes: &[u8], text: &mut String) { + // Finish a character cut short by the last write with the few + // bytes it lacks; the rest is decoded where it lies. The joined + // bytes can leave a new character cut short (the held one was + // not continued, and what follows starts another), so this goes + // on until nothing is held or the write is used up. + while let Some(&lead) = self.partial.first() { + if bytes.is_empty() { + return; + } + let width: usize = match lead { + 0xf0.. => 4, + 0xe0.. => 3, + _ => 2, + }; + let take = width + .saturating_sub(self.partial.len()) + .clamp(1, bytes.len()); + self.partial.extend_from_slice(&bytes[..take]); + bytes = &bytes[take..]; + let joined = std::mem::take(&mut self.partial); + self.decode(&joined, text); + } + self.decode(bytes, text); + } + + fn decode(&mut self, mut rest: &[u8], text: &mut String) { + loop { + match std::str::from_utf8(rest) { + Ok(s) => { + text.push_str(s); + return; + } + Err(e) => { + let (valid, after) = rest.split_at(e.valid_up_to()); + // `valid_up_to` bytes are UTF-8: this never falls back. + text.push_str(std::str::from_utf8(valid).unwrap_or_default()); + match e.error_len() { + Some(bad) => { + text.push(char::REPLACEMENT_CHARACTER); + rest = &after[bad..]; + } + // Cut short: keep it for the next write. + None => { + self.partial.extend_from_slice(after); + return; + } + } + } + } + } + } +} /// PEB heap size. /// @@ -396,9 +536,11 @@ const HEAP_SIZE: u64 = 0x10_0000; // 1 MiB /// the embedded kernel's SHA-256 (its first 16 hex digits; the kernel's /// code and host-call protocol are in the snapshot's memory) and the host /// contract number kept in `build.rs` (the host functions and their -/// meaning, the buffer sizes, the layout, the MSRs, the hyperlight-host +/// meaning, the pool sizes, the layout, the MSRs, the hyperlight-host /// release). A snapshot loads under any release with the same key, so a -/// release that changes neither keeps every saved snapshot. +/// release that changes neither keeps every saved snapshot. (Hyperlight +/// versions its own snapshot format besides, and refuses one it cannot +/// read.) pub const SNAPSHOT_KEY: &str = env!("HLUK_SNAPSHOT_KEY"); /// The name a snapshot is saved under in its directory: this release and @@ -751,15 +893,15 @@ struct Guest { /// A guest output stream, and the host function that carries it. #[derive(Clone, Copy)] enum Stream { - Out, - Err, + Out = 0, + Err = 1, } impl Stream { fn host_function(self) -> &'static str { match self { - Stream::Out => "HostPrint", - Stream::Err => "HostPrintErr", + Stream::Out => "HostWrite", + Stream::Err => "HostWriteErr", } } } @@ -772,8 +914,10 @@ pub struct CapturedOutput { /// its first bytes, up to [`SandboxBuilder::output_limit`], cut between /// characters. pub text: String, - /// How many bytes were left out: `text.len() + omitted` is all the - /// guest wrote since the last drain. + /// How many bytes of text were left out: `text.len() + omitted` is all + /// the guest wrote since the last drain, as text (UTF-8, with a byte + /// that is not read as U+FFFD, three bytes); a character the guest has + /// only begun to write is in neither until it ends. pub omitted: u64, } @@ -784,7 +928,7 @@ impl CapturedOutput { } } -/// What the print host functions keep of one stream until it is drained. +/// What the write host functions keep of one stream until it is drained. #[derive(Default)] struct Capture { text: String, @@ -824,7 +968,7 @@ impl Capture { } } -/// The guest's output on the host: where the print host functions send it, +/// The guest's output on the host: where the write host functions send it, /// and what they keep of it for the embedder ([`SandboxBuilder::output_limit`], /// [`SandboxBuilder::echo_output`]). struct Console { @@ -836,6 +980,10 @@ struct Console { output: Mutex, /// Standard error alone. stderr: Mutex, + /// Each stream's decoder (standard output, standard error), so a + /// character one leaves cut short is not joined to the other's next + /// write. + decoders: Mutex<[GuestOutput; 2]>, } impl Console { @@ -845,6 +993,60 @@ impl Console { echo, output: Mutex::default(), stderr: Mutex::default(), + decoders: Mutex::default(), + } + } + + /// Take `bytes` written to `stream` in; returns the text they completed. + fn decode(&self, stream: Stream, bytes: Vec) -> String { + self.decoders.lock().unwrap()[stream as usize].push_owned(bytes) + } + + /// Echo `text`, written to `stream`, to this process's stdout or stderr, + /// flushed, unless echo is off. `print!`/`eprint!`, which a test + /// harness captures and which panic on a failed write, so + /// `hluk run | head -1` ends when its reader does. + fn emit(&self, stream: Stream, text: &str) { + if !self.echo || text.is_empty() { + return; + } + match stream { + Stream::Out => { + use std::io::Write; + print!("{text}"); + let _ = std::io::stdout().flush(); + } + // Unbuffered: nothing to flush. + Stream::Err => eprint!("{text}"), + } + } + + /// Keep `text`, written to `stream`, up to the limit: with the rest, and + /// on its own for standard error. + fn capture(&self, stream: Stream, text: &str) { + if let Stream::Err = stream { + self.stderr.lock().unwrap().push(text, self.limit); + } + self.output.lock().unwrap().push(text, self.limit); + } + + /// End the output: a character either stream left cut short reads as + /// U+FFFD, echoed and captured like the rest. + fn finish(&self) { + for stream in [Stream::Out, Stream::Err] { + let text = self.decoders.lock().unwrap()[stream as usize].finish(); + self.emit(stream, &text); + self.capture(stream, &text); + } + } + + /// Drop a character cut short, on a restore: the bytes held may be the + /// discarded timeline's, and which they are is not known here, so a + /// character the snapshot's own timeline left cut short is dropped too + /// (its end, should the restored guest write it, reads as U+FFFD). + fn forget(&self) { + for decoder in self.decoders.lock().unwrap().iter_mut() { + decoder.partial.clear(); } } } @@ -871,7 +1073,7 @@ pub(crate) struct GuestConfig { initrd_size: u64, /// Host filesystem mounts. mounts: Vec, - /// The guest's output, captured by the HostPrint and HostPrintErr + /// The guest's output, captured by the HostWrite and HostWriteErr /// callbacks. console: Arc, /// NUL-separated KEY=VALUE pairs for guest env vars. @@ -891,9 +1093,9 @@ pub(crate) struct GuestConfig { result: Arc>>>, /// The embedder's functions, served through `HostCall`. host_functions: HostFunctionTable, - /// Whether to register `HostPrintErr`; off only to test a host - /// without it ([`SandboxBuilder::without_host_print_err`]). - host_print_err: bool, + /// Whether to register `HostWriteErr`; off only to test a host + /// without it ([`SandboxBuilder::without_host_write_err`]). + host_write_err: bool, /// What those events add up to. guest: Mutex, /// Where this sandbox's time goes (off unless asked for). @@ -932,7 +1134,7 @@ impl GuestConfig { events: Arc::new(Mutex::new(Vec::new())), result: Arc::new(Mutex::new(None)), host_functions: HostFunctionTable::default(), - host_print_err: true, + host_write_err: true, guest: Mutex::new(Guest::default()), profile: profile::Profile::new(), env_version: std::sync::atomic::AtomicU64::new(next_env_version()), @@ -945,8 +1147,8 @@ impl GuestConfig { self } - fn with_host_print_err(mut self, on: bool) -> Self { - self.host_print_err = on; + fn with_host_write_err(mut self, on: bool) -> Self { + self.host_write_err = on; self } @@ -1037,48 +1239,38 @@ impl GuestConfig { self.take_output().text } - /// The print host function for `stream`: the guest's text to the host's - /// stdout or stderr, flushed, unless echo is off, and captured up to the - /// limit — standard error both with the rest and on its own. It uses - /// `print!`/`eprint!`, which a test harness captures and which panic on - /// a failed write, so `hluk run | head -1` ends when its reader does. - /// The guest is told all of its text was written, kept or not. - fn print_host_function( + /// The write host function for `stream`: the guest's bytes, decoded as + /// they come (see [`GuestOutput`]), to the host's stdout or stderr, + /// flushed, unless echo is off, and captured up to the limit — standard + /// error both with the rest and on its own. Bytes, not a string: what a + /// program writes need not be UTF-8, and a write can end inside a + /// character. It uses `print!`/`eprint!`, which a test harness captures + /// and which panic on a failed write, so `hluk run | head -1` ends when + /// its reader does. The guest is told all of its bytes were written, + /// kept or not. + fn write_host_function( &self, stream: Stream, - ) -> impl Fn(String) -> hyperlight_host::Result + Send + Sync + 'static { + ) -> impl Fn(Vec) -> hyperlight_host::Result + Send + Sync + 'static { let prof = self.profile.clone(); let console = self.console.clone(); let name = stream.host_function(); - move |msg: String| { - use std::io::Write; + move |bytes: Vec| { let _profile = prof.host(name); - // Captured after the write, so a failed one leaves both - // captures as they were. - match stream { - Stream::Out => { - if console.echo { - print!("{msg}"); - let _ = std::io::stdout().flush(); - } - } - Stream::Err => { - // Unbuffered: nothing to flush. - if console.echo { - eprint!("{msg}"); - } - console.stderr.lock().unwrap().push(&msg, console.limit); - } - } - console.output.lock().unwrap().push(&msg, console.limit); - Ok(msg.len() as i32) + // Captured after the echo, so a failed one (a closed pipe) + // leaves both captures as they were; the decoder has moved on. + let written = bytes.len() as i32; + let text = console.decode(stream, bytes); + console.emit(stream, &text); + console.capture(stream, &text); + Ok(written) } } - /// Register `stream`'s print host function, name and closure from the + /// Register `stream`'s write host function, name and closure from the /// one value so they cannot disagree. - fn register_print(&self, target: &mut impl Registerable, stream: Stream) -> Result<()> { - target.register_host_function(stream.host_function(), self.print_host_function(stream))?; + fn register_write(&self, target: &mut impl Registerable, stream: Stream) -> Result<()> { + target.register_host_function(stream.host_function(), self.write_host_function(stream))?; Ok(()) } @@ -1087,15 +1279,26 @@ impl GuestConfig { /// Works for both the init path (`UninitializedSandbox`) and the /// snapshot-restore path (`HostFunctions`). pub fn register(&self, target: &mut impl Registerable) -> Result<()> { - // Override Hyperlight's default HostPrint (which wraps output in - // green ANSI on stdout) — send guest output to stdout uncolored, - // and capture it for programmatic access. - self.register_print(target, Stream::Out)?; + // Guest console output, uncolored (Hyperlight's own HostPrint wraps + // it in green ANSI), and captured for programmatic access. + self.register_write(target, Stream::Out)?; + + // Hyperlight registers a HostPrint of its own, which this kernel + // does not call; one that another guest or driver might is taken as + // standard output, so it is captured and echoed like the rest + // rather than printed in green past `echo_output(false)`. + // Through the same decoder, so it lands in order with HostWrite + // (and is timed as HostWrite by `--profile`). + let write = self.write_host_function(Stream::Out); + target.register_host_function( + "HostPrint", + move |msg: String| -> hyperlight_host::Result { write(msg.into_bytes()) }, + )?; // Optional only so a test can stand in for a host without it, whose - // guest then sends standard error through HostPrint. - if self.host_print_err { - self.register_print(target, Stream::Err)?; + // guest then sends standard error through HostWrite. + if self.host_write_err { + self.register_write(target, Stream::Err)?; } let cmdline = self.cmdline.clone(); @@ -1252,16 +1455,18 @@ impl GuestConfig { )?; // ── Stdin ───────────────────────────────────────────────── + // Bytes, as stdin has them: a read can end inside a character, and + // what is piped in need not be text. let prof = self.profile.clone(); target.register_host_function( "ReadStdin", - move || -> hyperlight_host::Result { + move || -> hyperlight_host::Result> { let _profile = prof.host("ReadStdin"); use std::io::Read; let mut data = vec![0u8; 4096]; let n = std::io::stdin().read(&mut data).unwrap_or(0); data.truncate(n); - Ok(String::from_utf8_lossy(&data).into_owned()) + Ok(data) }, )?; @@ -1362,10 +1567,13 @@ impl GuestConfig { )?; let events = self.events.clone(); let prof = self.profile.clone(); + let console = self.console.clone(); target.register_host_function( "Exited", move |status: i32| -> hyperlight_host::Result { let _profile = prof.host("Exited"); + // Nothing will finish a character the process left cut short. + console.finish(); events .lock() .unwrap() @@ -1410,15 +1618,23 @@ impl GuestConfig { /// One VM entry: call `name`, then read what the guest said during it. /// The guest runs its scheduler until every thread is blocked and /// halts; see [`absorb`](Self::absorb) for how the events reduce. - fn enter(&self, sandbox: &mut MultiUseSandbox, name: &str, args: Args) -> Result + fn enter(&self, sandbox: &mut Sandbox, name: &str, args: Args) -> Result where Args: hyperlight_host::func::ParameterTuple, { // An entry starts with an empty inbox: a previous one that failed // in the hypervisor may have left events behind. self.events.lock().unwrap().clear(); - self.profile - .entry(name, || sandbox.call::<()>(name, args))?; + if let Err(e) = self.profile.entry(name, || sandbox.call::<()>(name, args)) { + // A guest stopped short (an abort, a crash, a cancel) poisons + // the sandbox: its timeline ends here, and a character it left + // cut short with it. A call refused before the guest ran (too + // large for H2G, say) leaves it going, and the character too. + if sandbox.status().is_poisoned() { + self.console.finish(); + } + return Err(e.into()); + } let yielded = self.absorb()?; debug!(?yielded, name, "entry"); Ok(yielded) @@ -1474,8 +1690,10 @@ impl GuestConfig { /// follows has the guest say again what still holds. fn forget(&self) { self.events.lock().unwrap().clear(); - // A result from the timeline the restore discarded is not this one's. + // A result from the timeline the restore discarded is not this one's, + // nor is the start of a character it printed. *self.result.lock().unwrap() = None; + self.console.forget(); *self.guest.lock().unwrap() = Guest::default(); // The kernel's copy of the environment is the snapshot's. self.env_version @@ -1708,7 +1926,7 @@ fn assemble_sandbox( network: Option, listen_ports: Option, host_functions: HostFunctionTable, - host_print_err: bool, + host_write_err: bool, console: Console, ) -> Result<(UninitializedSandbox, GuestConfig)> { let scratch_size = scratch_mb * 1024 * 1024; @@ -1716,9 +1934,7 @@ fn assemble_sandbox( let mut cfg = SandboxConfiguration::default(); cfg.set_scratch_size(scratch_size); cfg.set_heap_size(HEAP_SIZE); - - cfg.set_input_data_size(IO_STACK_SIZE); - cfg.set_output_data_size(IO_STACK_SIZE); + apply_transport(&mut cfg); // Permit the guest to touch the MSRs the Unikraft kernel programs apply_guest_msrs(&mut cfg)?; @@ -1783,7 +1999,7 @@ fn assemble_sandbox( listen_ports, ) .with_host_functions(host_functions) - .with_host_print_err(host_print_err) + .with_host_write_err(host_write_err) .with_console(console); config.register(&mut usandbox)?; @@ -1833,7 +2049,7 @@ pub struct SandboxBuilder { resolv_conf: Option, host_functions: std::collections::BTreeMap, profile: Option, - host_print_err: bool, + host_write_err: bool, output_limit: usize, echo_output: bool, } @@ -1854,7 +2070,7 @@ impl SandboxBuilder { resolv_conf: None, host_functions: std::collections::BTreeMap::new(), profile: None, - host_print_err: true, + host_write_err: true, output_limit: usize::MAX, echo_output: true, } @@ -2062,13 +2278,13 @@ impl SandboxBuilder { self } - /// Leave out the `HostPrintErr` host function, as an older or + /// Leave out the `HostWriteErr` host function, as an older or /// third-party host does: the guest's standard error then reaches - /// `HostPrint`. For testing that fallback, on a fresh boot: a snapshot - /// saved with `HostPrintErr` will not restore without it. + /// `HostWrite`. For testing that fallback, on a fresh boot: a snapshot + /// saved with `HostWriteErr` will not restore without it. #[doc(hidden)] - pub fn without_host_print_err(mut self) -> Self { - self.host_print_err = false; + pub fn without_host_write_err(mut self) -> Self { + self.host_write_err = false; self } @@ -2092,7 +2308,7 @@ impl SandboxBuilder { resolv_conf, host_functions, profile, - host_print_err, + host_write_err, output_limit, echo_output, } = self; @@ -2130,7 +2346,7 @@ impl SandboxBuilder { network, listen_ports, host_functions, - host_print_err, + host_write_err, Console::new(output_limit, echo_output), )?; if let Some(on) = profile { @@ -2182,7 +2398,7 @@ impl SandboxBuilder { network, listen_ports, host_functions, - host_print_err, + host_write_err, Console::new(output_limit, echo_output), )?; // Before the boot: the kernel fetches the environment once @@ -2202,8 +2418,10 @@ impl SandboxBuilder { { Ok(sandbox) => sandbox, Err(e) => { - // The guest's output is the diagnosis: all of it, or - // under a limit its beginning and how much is missing. + // The guest's output is the diagnosis: all of it, a + // character cut short included, or under a limit its + // beginning and how much is missing. + cfg.console.finish(); let output = cfg.take_output(); if !output.text.is_empty() || output.truncated() { tracing::error!( @@ -2361,7 +2579,7 @@ impl From for Exec { /// bring it back. Dropping the sandbox tears /// the VM down and releases every host socket it held. pub struct AppSandbox { - sandbox: MultiUseSandbox, + sandbox: Sandbox, config: GuestConfig, /// The [`Yield::Exited`] the guest process ended with, once it has; /// every later step reports it again. @@ -2738,7 +2956,7 @@ impl AppSandbox { /// /// Creates a default [`GuestConfig`] (the snapshot already has the guest's /// cmdline/initrd), registers host functions, and rebuilds a -/// [`MultiUseSandbox`] from the snapshot. `mounts` are the restored +/// [`Sandbox`] from the snapshot. `mounts` are the restored /// guest's: the kernel reads them through `GetMounts` on its `resume` /// entry and makes its mount table match. fn restore_snapshot( @@ -2747,9 +2965,9 @@ fn restore_snapshot( network: Option, listen_ports: Option, host_functions: HostFunctionTable, - host_print_err: bool, + host_write_err: bool, console: Console, -) -> Result<(MultiUseSandbox, GuestConfig)> { +) -> Result<(Sandbox, GuestConfig)> { let config = GuestConfig::new( String::new(), DEFAULT_SCRATCH_MB * 1024 * 1024, @@ -2760,7 +2978,7 @@ fn restore_snapshot( listen_ports, ) .with_host_functions(host_functions) - .with_host_print_err(host_print_err) + .with_host_write_err(host_write_err) .with_console(console); let mut hf = HostFunctions::default(); config.register(&mut hf)?; @@ -2772,12 +2990,11 @@ fn restore_snapshot( // know to match (avoiding a spurious layout-override warning) and let // it override scratch. let mut sbcfg = SandboxConfiguration::default(); - sbcfg.set_input_data_size(IO_STACK_SIZE); - sbcfg.set_output_data_size(IO_STACK_SIZE); + apply_transport(&mut sbcfg); sbcfg.set_heap_size(HEAP_SIZE); apply_guest_msrs(&mut sbcfg)?; - let sandbox = MultiUseSandbox::from_snapshot(snapshot, hf, Some(sbcfg))?; + let sandbox = Sandbox::from_snapshot(snapshot, hf, Some(sbcfg))?; Ok((sandbox, config)) } @@ -2802,6 +3019,77 @@ mod tests { ); } + #[test] + fn guest_output_push_owned_matches_push() { + let writes: &[&[u8]] = &[ + b"plain", + "olé".as_bytes(), + b"ends mid \xe2\x82", + b"\xac done", + b"\xff bad", + b"\xc3", + b"A", + b"\xf0\x9f", + b"\x98\x80!", + b"", + b"tail \xe2", + ]; + let (mut a, mut b) = (GuestOutput::default(), GuestOutput::default()); + let (mut ta, mut tb) = (String::new(), String::new()); + for w in writes { + ta += &a.push(w); + tb += &b.push_owned(w.to_vec()); + assert_eq!(ta, tb); + assert_eq!(a.partial, b.partial); + } + assert_eq!(a.finish(), b.finish()); + } + + #[test] + fn guest_output_joins_split_characters_and_replaces_bad_bytes() { + let mut out = GuestOutput::default(); + let mut text = out.push(b"ol\xc3"); + assert_eq!(text, "ol"); + text += &out.push(b"\xa9 \xff!"); + assert_eq!(text, "olé \u{fffd}!"); + text += &out.push(b"\xe2\x82"); + text += &out.push(b"\xac"); + assert_eq!(text, "olé \u{fffd}!€"); + assert!(out.partial.is_empty()); + // Finished by the next write, even after the text so far is taken. + text += &out.push(b"x\xf0\x9f"); + assert_eq!(std::mem::take(&mut text), "olé \u{fffd}!€x"); + text += &out.push(b"\x98"); + text += &out.push(b"\x80 ok"); + assert_eq!(text, "\u{1f600} ok"); + // A lead byte the next write does not continue. + text += &out.push(b"\xc3"); + text += &out.push(b"A"); + assert_eq!(text, "\u{1f600} ok\u{fffd}A"); + assert!(out.partial.is_empty()); + + // A stray lead byte, then a write that starts a character of its + // own: the character survives, in order. + let mut out = GuestOutput::default(); + let mut text = out.push(b"ol\xe9"); + text += &out.push("€5".as_bytes()); + text += &out.push(b"\n"); + assert_eq!(text, "ol\u{fffd}€5\n"); + // Ones that leave a new cut-short character behind each time. + let mut out = GuestOutput::default(); + let mut text = out.push(b"\xc3"); + text += &out.push(b"\xe2a\xf0\x9f\x98"); + let tail = out.push(b"\x80"); + assert_eq!(tail, "\u{1f600}"); + text += &tail; + assert_eq!(text, "\u{fffd}\u{fffd}a\u{1f600}"); + assert!(out.partial.is_empty()); + // The guest ends with half a character: it reads as U+FFFD. + assert_eq!(out.push(b"!\xe2\x82"), "!"); + assert_eq!(out.finish(), "\u{fffd}"); + assert_eq!(out.finish(), ""); + } + #[test] fn snapshot_tag_key_reads_only_a_keyed_tag() { assert_eq!(snapshot_tag_key("0.14.0"), None); @@ -2810,6 +3098,10 @@ mod tests { Some("kdeadbeefdeadbeef-c2") ); assert_eq!(snapshot_tag_key("0.15.0-kappa-c2"), None); + assert_eq!( + snapshot_tag_key(snapshot_tag().as_ref()), + Some(SNAPSHOT_KEY) + ); assert_eq!(describe_snapshot_tag("0.14.0"), "0.14.0"); } @@ -3157,8 +3449,6 @@ mod tests { /// for `fs_stat(0, "hello.txt")` and verify Hyperlight can parse it. #[test] fn flatbuffer_generic_encoder_roundtrip() { - use hyperlight_common::flatbuffer_wrappers::function_call::FunctionCall; - // Construct the exact bytes the C fb_encode_generic would produce // for hl_hcall_vecbytes("fs_stat", [hlint(0), hlstring("hello.txt")], 2). // @@ -3180,7 +3470,7 @@ mod tests { } // Try to parse the C-style bytes. - let c_parsed = FunctionCall::try_from(c_bytes.as_slice()); + let c_parsed = decode_c_call(&c_bytes); assert!( c_parsed.is_ok(), "C-encoded FunctionCall should parse: {:?}", @@ -3193,8 +3483,6 @@ mod tests { /// Roundtrip test for fs_write_bytes with empty VecBytes. #[test] fn flatbuffer_generic_encoder_roundtrip_write() { - use hyperlight_common::flatbuffer_wrappers::function_call::FunctionCall; - let c_bytes = build_c_generic_fb( "fs_write_bytes", 2, // HL_FCT_HOST @@ -3217,7 +3505,7 @@ mod tests { eprintln!(); } - let result = FunctionCall::try_from(c_bytes.as_slice()); + let result = decode_c_call(&c_bytes); match &result { Ok(fc) => eprintln!("PARSED: name={}", fc.function_name), Err(e) => eprintln!("FAILED: {:?}", e), @@ -3295,14 +3583,16 @@ mod tests { for param in params.iter().take(np) { let pvt = align2(pos); let ptbl = align4(pvt + PM_VT_SZ); + // A byte vector is an hlexternalbytes: its length, laid out + // like a ulong; the bytes follow the FlatBuffer. let (vvtsz, vtblsz) = match param { CParam::Int(_) => (VW_SCALAR_VT_SZ, VW_INT_TBL_SZ), - CParam::ULong(_) => (VW_SCALAR_VT_SZ, VW_ULONG_TBL_SZ), - CParam::Str(_) | CParam::VecBytes(_) => (VW_SCALAR_VT_SZ, VW_REF_TBL_SZ), + CParam::ULong(_) | CParam::VecBytes(_) => (VW_SCALAR_VT_SZ, VW_ULONG_TBL_SZ), + CParam::Str(_) => (VW_SCALAR_VT_SZ, VW_REF_TBL_SZ), }; let vvt = align2(ptbl + PM_TBL_SZ); let vtbl = match param { - CParam::ULong(_) => align8_off4(vvt + vvtsz), + CParam::ULong(_) | CParam::VecBytes(_) => align8_off4(vvt + vvtsz), _ => align4(vvt + vvtsz), }; pos = vtbl + vtblsz; @@ -3317,19 +3607,11 @@ mod tests { }); } - // Variable-length data + // Strings for (i, param) in params.iter().enumerate().take(np) { - match param { - CParam::Str(s) => { - pl[i].vdata = align4(pos); - pos = pl[i].vdata + 4 + align4(s.len() + 1); - } - CParam::VecBytes(v) => { - pl[i].vdata = align4(pos); - let dlen = if v.is_empty() { 1 } else { v.len() }; - pos = pl[i].vdata + 4 + align4(dlen); - } - _ => {} + if let CParam::Str(s) = param { + pl[i].vdata = align4(pos); + pos = pl[i].vdata + 4 + align4(s.len() + 1); } } @@ -3381,10 +3663,10 @@ mod tests { // Parameter table ew32(&mut buf, layout.ptbl, (layout.ptbl - layout.pvt) as u32); let pv_type = match param { - CParam::Int(_) => 1u8, // HL_PV_HLINT - CParam::ULong(_) => 4u8, // HL_PV_HLULONG (was incorrectly 5=hlfloat!) - CParam::Str(_) => 7u8, // HL_PV_HLSTRING - CParam::VecBytes(_) => 9u8, // HL_PV_HLVECBYTES + CParam::Int(_) => 1u8, // HL_PV_HLINT + CParam::ULong(_) => 4u8, // HL_PV_HLULONG (was incorrectly 5=hlfloat!) + CParam::Str(_) => 7u8, // HL_PV_HLSTRING + CParam::VecBytes(_) => 10u8, // HL_PV_HLEXTERNALBYTES }; buf[layout.ptbl + 4] = pv_type; ew32( @@ -3418,15 +3700,7 @@ mod tests { buf[layout.vdata + 4..layout.vdata + 4 + s.len()].copy_from_slice(s.as_bytes()); } CParam::VecBytes(v) => { - ew32( - &mut buf, - layout.vtbl + 4, - (layout.vdata - (layout.vtbl + 4)) as u32, - ); - ew32(&mut buf, layout.vdata, v.len() as u32); - if !v.is_empty() { - buf[layout.vdata + 4..layout.vdata + 4 + v.len()].copy_from_slice(v); - } + ew64(&mut buf, layout.vtbl + 4, v.len() as u64); } } } @@ -3435,15 +3709,85 @@ mod tests { ew32(&mut buf, fnpos, nlen as u32); buf[fnpos + 4..fnpos + 4 + nlen].copy_from_slice(name.as_bytes()); + // The byte values, in parameter order + for param in params { + if let CParam::VecBytes(v) = param { + buf.extend_from_slice(v); + } + } + buf } + /// The bytes after a C-encoded FunctionCall, handed to the decoder the + /// way the host's transport does. + struct CExternals<'a>(&'a [u8]); + + impl hyperlight_common::flatbuffer_wrappers::ExternalValueSource for CExternals<'_> { + fn take_bytes(&mut self, length: usize) -> anyhow::Result> { + anyhow::ensure!(length <= self.0.len(), "external bytes cut short"); + let (value, rest) = self.0.split_at(length); + self.0 = rest; + Ok(value.to_vec()) + } + + fn take_chunks(&mut self, length: usize) -> anyhow::Result> { + Ok(vec![self.take_bytes(length)?.into()]) + } + + fn finish(&mut self) -> anyhow::Result<()> { + anyhow::ensure!(self.0.is_empty(), "unused external bytes"); + Ok(()) + } + } + + /// Decode what the C encoder produced: the size-prefixed FlatBuffer, + /// then the bytes of its VecBytes parameters. + fn decode_c_call( + payload: &[u8], + ) -> anyhow::Result { + let fb_len = 4 + u32::from_le_bytes(payload[..4].try_into()?) as usize; + let (control, externals) = payload.split_at(fb_len); + hyperlight_common::flatbuffer_wrappers::function_call::FunctionCall::decode( + control, + &mut CExternals(externals), + ) + } + + /// Byte parameters travel after the FlatBuffer and come back intact, + /// in order, around the other parameters. + #[test] + fn flatbuffer_generic_encoder_external_bytes() { + use hyperlight_common::flatbuffer_wrappers::function_types::ParameterValue; + + let payload = build_c_generic_fb( + "HostCall", + 2, + 9, + &[ + CParam::VecBytes(b"first"), + CParam::Str("name"), + CParam::VecBytes(&[]), + CParam::VecBytes(b"second"), + ], + ); + let call = decode_c_call(&payload).unwrap(); + assert_eq!(call.function_name, "HostCall"); + assert_eq!( + call.parameters.unwrap(), + vec![ + ParameterValue::VecBytes(b"first".to_vec()), + ParameterValue::String("name".into()), + ParameterValue::VecBytes(Vec::new()), + ParameterValue::VecBytes(b"second".to_vec()), + ] + ); + } + /// Roundtrip test for fs_read_bytes(mount_idx=0, path="test.txt", offset=0, len=32768) /// which uses u64 parameters. #[test] fn flatbuffer_generic_encoder_roundtrip_ulong() { - use hyperlight_common::flatbuffer_wrappers::function_call::FunctionCall; - let c_bytes = build_c_generic_fb( "fs_read_bytes", 2, // HL_FCT_HOST @@ -3465,7 +3809,7 @@ mod tests { eprintln!(); } - let c_parsed = FunctionCall::try_from(c_bytes.as_slice()); + let c_parsed = decode_c_call(&c_bytes); assert!( c_parsed.is_ok(), "C-encoded FunctionCall should parse: {:?}", @@ -3480,8 +3824,6 @@ mod tests { /// (not 5=hlfloat) and that 8-byte alignment is respected. #[test] fn c_encoder_ulong_alignment_check() { - use hyperlight_common::flatbuffer_wrappers::function_call::FunctionCall; - // First, fix the discriminant and test with type=4 (the REAL HL_PV_HLULONG) let c_bytes = build_c_generic_fb( "fs_write_bytes", @@ -3505,7 +3847,7 @@ mod tests { eprintln!(); } - let result = FunctionCall::try_from(c_bytes.as_slice()); + let result = decode_c_call(&c_bytes); match &result { Ok(fc) => eprintln!("PARSED: name={}", fc.function_name), Err(e) => eprintln!("FAILED: {:?}", e), diff --git a/tests/fixtures/native-kernel/helloworld-native_hyperlight-x86_64 b/tests/fixtures/native-kernel/helloworld-native_hyperlight-x86_64 index 67c14b09..d1f8a0db 100755 Binary files a/tests/fixtures/native-kernel/helloworld-native_hyperlight-x86_64 and b/tests/fixtures/native-kernel/helloworld-native_hyperlight-x86_64 differ diff --git a/tests/python.rs b/tests/python.rs index 9b2980b6..dc25aa5a 100644 --- a/tests/python.rs +++ b/tests/python.rs @@ -22,6 +22,52 @@ fn python_inline_code() { ); } +/// A line longer than one host call carries reaches the host whole, in +/// several HostWrite calls the kernel cuts between characters (it used +/// to drop all but the first 4 KiB). How the host joins a character +/// split between writes is covered by `python_non_utf8_output_...` and +/// the `GuestOutput` unit test. +#[test] +fn python_long_line_is_printed_whole() { + let rootfs = require_rootfs("python"); + let mut sandbox = SandboxBuilder::from_initrd(rootfs) + .scratch_mb(256) + .boot() + .unwrap(); + sandbox + .run("import sys; sys.stdout.write('x' + 'é' * 100000 + 'end\\n'); sys.stdout.flush()") + .unwrap(); + // The leading 'x' puts the 69,620-byte marks inside an 'é', so the + // kernel has to back the cuts off to where a character starts. + let output = sandbox.drain_output(); + let line = output + .lines() + .find(|l| l.ends_with("end")) + .unwrap_or_default(); + assert_eq!(line, format!("x{}end", "é".repeat(100000))); +} + +/// Output that is not UTF-8, or that ends a write inside a character, +/// is still output: the call goes on, and the text reads as it would on +/// a terminal (it used to fail the whole call). +#[test] +fn python_non_utf8_output_does_not_fail_the_call() { + let rootfs = require_rootfs("python"); + let mut sandbox = SandboxBuilder::from_initrd(rootfs) + .scratch_mb(256) + .boot() + .unwrap(); + sandbox + .run( + "import sys\n\ + out = sys.stdout.buffer\n\ + for chunk in (b'ol\\xc3', b'\\xa9 \\xff', b'!\\n'):\n\ + \x20 out.write(chunk); out.flush()", + ) + .unwrap(); + assert!(sandbox.drain_output().contains("olé \u{fffd}!")); +} + #[test] fn python_exec_file() { let rootfs = require_rootfs("python"); @@ -229,6 +275,39 @@ print(f'step3: STATEFUL_VAR={v}') ); } +/// Stdin reaches the guest as the bytes piped in: a character the host's +/// 4 KiB reads cut in two, and bytes that are not UTF-8 (they used to +/// arrive as U+FFFD). (Ones the terminal layer acts on, a chunk ending +/// in Ctrl-D or CR, are left out: that is the terminal, not the host.) +#[test] +fn python_stdin_is_bytes() { + let rootfs = require_rootfs("python"); + let dir = temp_dir("py-stdin-bytes"); + let script = dir.path().join("hash.py"); + std::fs::write( + &script, + "import sys\n\ + print('stdin', sys.stdin.buffer.read().hex())\n", + ) + .unwrap(); + // 'x' then 'é's: the 4 KiB reads end inside one; then bytes no UTF-8 + // decoder takes. + let mut data = b"x".to_vec(); + data.extend("é".repeat(5000).as_bytes()); + data.extend([0xff, 0x00, 0xc3]); + let hex: String = data.iter().map(|b| format!("{b:02x}")).collect(); + // The console echoes what it reads, so the line may start with that. + let output = hluk_with_stdin(&rootfs, &script, &data); + let want = format!("stdin {hex}"); + assert!( + output + .lines() + .any(|l| l.trim_end_matches('\r').ends_with(&want)), + "expected the {} bytes piped in, got: {output:?}", + data.len() + ); +} + #[test] fn python_stdin_piped() { let rootfs = require_rootfs("python"); @@ -316,6 +395,71 @@ fn python_stdin_empty_piped() { ); } +/// A path that is not UTF-8 fails the syscall, not the guest function +/// call: the host cannot take it as a string, and the kernel refuses it +/// before sending (it used to fail the whole call). +#[test] +fn python_non_utf8_path_fails_the_syscall_only() { + let rootfs = require_rootfs("python"); + let mount_dir = temp_dir("fs-non-utf8"); + let mut sandbox = SandboxBuilder::from_initrd(rootfs) + .scratch_mb(256) + .mounts(vec![Mount::rw(mount_dir.path(), "/mnt/host")]) + .boot() + .unwrap(); + sandbox + .run( + "import errno, os\n\ + try:\n\ + \x20 os.stat(b'/mnt/host/\\xff')\n\ + except OSError as e:\n\ + \x20 print('refused', errno.errorcode.get(e.errno))\n\ + print('still running')", + ) + .unwrap(); + let output = sandbox.drain_output(); + assert!(output.contains("refused EILSEQ"), "got: {output:?}"); + assert!(output.contains("still running"), "got: {output:?}"); +} + +/// A path too long for the kernel's hostfs buffers fails with +/// ENAMETOOLONG; it used to be cut short, and the syscall act on another +/// host directory. Linux only: the host directories it makes, about +/// 1,100 bytes deep in the temporary directory, are past the path limits +/// of macOS (1,024 bytes) and Windows. +#[cfg(target_os = "linux")] +#[test] +fn python_long_host_path_is_refused() { + let rootfs = require_rootfs("python"); + let mount_dir = temp_dir("fs-long-path"); + let mut sandbox = SandboxBuilder::from_initrd(rootfs) + .scratch_mb(256) + .mounts(vec![Mount::rw(mount_dir.path(), "/mnt/host")]) + .boot() + .unwrap(); + sandbox + .run( + "import errno, os\n\ + path = '/mnt/host'\n\ + try:\n\ + \x20 for i in range(6):\n\ + \x20 path += '/' + str(i) * 200\n\ + \x20 os.mkdir(path)\n\ + except OSError as e:\n\ + \x20 print('refused at', i, errno.errorcode.get(e.errno))", + ) + .unwrap(); + // 200-byte names: five make 1,004 bytes below the mount (1,005 with + // the NUL), which fits; six make 1,205, which does not. + assert!(sandbox.drain_output().contains("refused at 5 ENAMETOOLONG"),); + // Nothing was made under a name cut short. + let mut deepest = mount_dir.path().to_path_buf(); + for i in 0..5 { + deepest.push(i.to_string().repeat(200)); + } + assert_eq!(std::fs::read_dir(&deepest).unwrap().count(), 0); +} + #[test] fn python_fs_ops() { let rootfs = require_rootfs("python"); @@ -933,8 +1077,7 @@ fn python_stderr_apart_from_stdout() { let stderr = sandbox.drain_stderr(); assert!(stderr.contains("ZeroDivisionError"), "{stderr:?}"); - // Over 4 KiB of multi-byte characters: several HostPrintErr calls, - // each cut between characters. + // Multi-byte characters, well past a page: they arrive whole. sandbox .run("import sys\nsys.stderr.write('\u{20ac}' * 3000)\nsys.stderr.flush()") .unwrap(); @@ -949,14 +1092,14 @@ fn python_stderr_apart_from_stdout() { assert_eq!(sandbox.drain_output(), "True True\na\nb\n"); } -/// A host without `HostPrintErr` gets standard error through `HostPrint`, +/// A host without `HostWriteErr` gets standard error through `HostWrite`, /// all of it, as before the stream had its own. #[test] -fn python_stderr_reaches_a_host_without_host_print_err() { +fn python_stderr_reaches_a_host_without_host_write_err() { let rootfs = require_rootfs("python"); let mut sandbox = SandboxBuilder::from_initrd(rootfs) .scratch_mb(256) - .without_host_print_err() + .without_host_write_err() .boot() .unwrap(); sandbox diff --git a/tests/quickjs.rs b/tests/quickjs.rs index 4105d7b6..68ab241a 100644 --- a/tests/quickjs.rs +++ b/tests/quickjs.rs @@ -258,9 +258,12 @@ fn quickjs_rejections_and_oversized_results_fail_the_call() { .run("function big(n) { return 'x'.repeat(n) }") .unwrap(); assert_eq!(sandbox.call("big", "10").unwrap().len(), 12); - // Past the call buffer (the driver refuses it), and between the - // 64 KiB a host call carries and the buffer (the kernel does). - for n in ["70000", "67000"] { + // Past the call buffer (the driver refuses it), and between what a + // host call carries and the buffer (the kernel does). With hluk's + // virtqueue pools: a host call carries 69,620 bytes (half the G2H + // pool less the framing), a call buffer holds 73,716 (the H2G pool + // less a message header). + for n in ["75000", "71000"] { assert!( sandbox.call("big", n).is_err(), "a {n}-byte result did not fail the call" diff --git a/tests/step.rs b/tests/step.rs index 3aa8d6aa..092b34d7 100644 --- a/tests/step.rs +++ b/tests/step.rs @@ -130,8 +130,8 @@ fn python_call_yields_on_timers() { assert!(sandbox.drain_output().contains("again")); } -/// A call is only limited by the host's I/O buffer, which the kernel reads -/// from the PEB and the driver learns through the device: a script far +/// A call is only limited by the host's H2G pool, whose geometry the +/// kernel reads from scratch and the driver learns through the device: a script far /// larger than any guest-side constant still runs. (The previous kernel /// rejected anything over 16 KiB.) #[test]