Skip to content

v0.18.0

Latest

Choose a tag to compare

@github-actions github-actions released this 16 Oct 01:00
a2ad7ab

Added

  • 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-<sha7>, :initrd-dev-<sha7>), 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, https://hyperlight-dev.github.io/hyperlight-unikraft: startup times from CI, a density measurement (1,000 Python sandboxes in one process), 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, 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, 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.
  • The images the builds run are pinned by digest, as the CI actions are by commit: the base images of the drivers, the kernel builder, the examples, demos and hluk init templates, the images just build-test-bins runs, the urunc image's BuildKit frontend and the BuildKit image CI builds the kernel with (#126).
  • Dependabot opens grouped version updates once a month (see .github/dependabot.yml) for the crates, the CI actions and the Dockerfiles' base images, digest refreshes and patch releases only for those (#135). Dependabot labels its version updates ci/arm64. On a pull request with that label, ci-passed (the required check) waits for the arm64 run and fails unless it passes, so merges, auto-merge included, wait for the arm64 tests. A label added after ci-passed starts counts once that job is re-run.
  • The Linux release binaries are static, built with musl (hluk-v<version>-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.
  • Rust 1.99 builds the project (rust-toolchain.toml). The arm64 wasmtime image is a static PIE now, as on x86_64: the driver and nothing else, without the glibc it carried (#129).
  • --port (a manifest's [run.net] ports) no longer needs --net: on its own the guest serves TCP on the port and can open no connection of its own, as under an allow list that lists nothing (a UDP service's replies are outbound sends, so it still needs a policy); with a project, it replaces only the project's ports, not what the project may reach. In the library, SandboxBuilder::listen_ports without network does the same, where it used to leave the guest with no networking at all.

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.
  • 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.
  • Kernel messages carried NUL bytes, and one longer than 191 bytes carried bytes past its buffer; it is cut there now, at a whole character, and keeps its line end. nolibc's printf past 1 KiB also read past its buffer; it is cut at 1 KiB.
  • A pipe, a $(...) substitution or a redirect of an external command crashed the guest in the bash image on x86_64, and so did one in Python's subprocess.run(..., shell=True) in the python-shell image: a vfork child returned to userland on the kernel's TLS rather than its parent's, and musl, reading its thread pointer in the system calls the child made before execve (open, dup2, close), read kernel memory as its own; so did Python's subprocess once any thread had run, when libc starts taking its locks. The child now runs on its parent's TLS until execve, as it runs on its parent's stack; arm64 had the same code and gets the same fix.
  • The agent image got 256 MiB when booted by path (--initrd, or a project built FROM it) rather than the 1536 MiB it is tested with, too little to unpack it into, and the guest crashed during boot. hluk sizes a guest by the runtime marker an image carries, etc/hluk-runtime, which no image wrote, and then by its driver, which agent shares with python-shell. The agent image now writes the marker, and images built from it inherit it.
  • A warm snapshot kept the environment of the run that saved it: hluk run --runtime agent --env PYTHONPATH=/x left /x on sys.path for every later run of that snapshot. The guest saved as the warm snapshot now boots with the project's [run] env only, which is part of the snapshot's key, and a run's --env reaches its runtime driver after the save, as it does a restored one. An image with no driver, whose program reads its environment once as it starts, or a run with a warm_exec, which runs before the save, boots with all of it, and its snapshot is keyed by it (the newest four are kept per image). A variable a runtime reads only as it starts (NODE_OPTIONS, say) therefore belongs in the project's [run] env, or needs --cold.
  • PYTHONPATH given to a restored Python guest (hluk snapshot run --env PYTHONPATH=..., set_env_vars) was in os.environ but not on sys.path, which Python reads once at startup. The python, python-shell and agent drivers now put its entries where startup would have, in place of the previous call's, before each call, and a variable the host stops sending goes back to the guest's own value, or away if it had none.
  • Waiting for and stopping processes: wait() for a child that was still running slept for good (the guest deadlocked) unless it waited for any child, and with two threads waiting for two children one could sleep on; a parent paused in sigsuspend() for a child's SIGCHLD slept for good, so a shell's sleep 1 & wait hung the guest, a later pause() or sigsuspend() could return at once, for a signal already handled, and sigsuspend() put the caller's mask back before the signal it waited for was delivered, so its handler never ran and while (!got) sigsuspend(&old) never ended; a killed process lived on until its threads next left a system call, so a timeout's kill waited out a sleep and never ended a cat reading a pipe (a killed process's blocked calls are now interrupted and unwind, releasing the files they hold), and a process ended while one of its threads lent its stack to a vfork child (a thread running subprocess while another called os._exit) freed that stack under the child, and a thread blocked in vfork() resumed on that stack while its child still ran on it whenever another thread or child it had made exited or exec'd (the guest crashed); an exec'd program kept the signal handlers its parent had set, so a signal it did not catch ran its parent's code; and there was one process group and one session, so setsid() (Python's start_new_session) failed, and killpg() and kill(0, ...) signalled every process in the guest, the caller's supervisor and init included. Each process now has a group and a session as on Linux, kill and wait4/waitid take groups, a signal that ends a process ends it when it is sent, execve resets caught signals, init is spared the signals it does not handle, and waiting on a group a child leaves gives ECHILD rather than a hang.
  • pause() returned at once on arm64 hosts, where libcs make it as ppoll(NULL, 0, NULL, NULL), which the kernel refused: Python's signal.pause() never slept, and a program paused for a child's signal ran on. A poll(), ppoll(), select() or pselect6() with no fd to watch and no timeout now waits for a signal, as on Linux (select(0, NULL, NULL, NULL, NULL) hung on both architectures), and poll(NULL, 0, ms) sleeps instead of failing. A generic Unikraft fix, verified on QEMU/KVM.
  • execve of a file that does not exist failed with EINVAL, not ENOENT, so Python raised OSError instead of FileNotFoundError for a missing program; kill() of a pid past the process table (os.kill(99999, 0)) crashed the kernel, and so did pthread_kill(thread, 0), the check whether a thread exists; a signal sent to one thread was also marked pending on the process, so the same signal sent to the process later was dropped; a standard signal sent while one was pending leaked memory; a process with 32 signals queued could not be killed (SIGKILL and SIGTERM were refused with the rest), and crashed the kernel when one of its children exited, since the exit signal could not be queued; a signal to a process group stopped at the first member that refused it; and a SIGCHLD left at its default stayed queued, so a handler installed later ran for children long reaped, while one installed with SA_NOCLDWAIT never ran.
  • A program that ran the guest out of memory crashed the kernel with a fault; it is now killed as Linux's OOM killer would (Out of memory: killed process N), and the guest exits with 137 when it was the last. A child process out of memory with others still running is killed too, but the guest still crashes freeing its memory.
  • select() and poll() called a socket readable that was not: a new listener, a listener whose last connection had been accepted, and a connection whose data had been read. The accept() or recv() that followed blocked, and a socketserver loop never got back to its shutdown().

Pull requests

  • drivers: add the java runtime by @danbugs in #139
  • Static Linux release binaries, built with musl by @danbugs in #144
  • Keep guest output whole, stderr apart and newlines as written; fix vfork crashes by @danbugs in #145
  • Bound the guest output the host keeps by @danbugs in #146
  • Move to Hyperlight 0.18's virtqueue transport by @danbugs in #142
  • build(deps): bump crate-ci/typos from 1.50.1 to 1.51.1 by @dependabot[bot] in #150
  • build(deps): bump the wasmtime group across 1 directory with 3 updates by @dependabot[bot] in #147
  • build(deps): bump the cargo group across 2 directories with 5 updates by @dependabot[bot] in #151
  • build(deps): bump the actions group across 1 directory with 3 updates by @dependabot[bot] in #148
  • Pin the images the builds run by digest by @danbugs in #152
  • build(deps): bump dtolnay/rust-toolchain from 02cb101ec7c40f2c49e1d9714d64511d8e1b74de to e2a55d2ffb04f378e9626c28d38b36d230d1e12f by @dependabot[bot] in #149
  • Make running, waiting for and killing processes work in the guest by @danbugs in #153
  • kernel: fix pause() on arm64, and polls with nothing to watch by @danbugs in #156
  • tests: retry the HTTP GET examples when the server fails by @danbugs in #159
  • ci: make ci-passed wait for arm64 on pull requests labelled ci/arm64 by @danbugs in #154
  • build(deps): bump rustls from 0.23.43 to 0.23.46 in /demos/pptx-gen in the cargo group across 1 directory by @dependabot[bot] in #160
  • build(deps): bump wasmparser from 0.259.0 to 0.261.0 in /drivers/wasmtime/driver in the wasmtime group across 1 directory by @dependabot[bot] in #161
  • v0.18.0: java, Hyperlight 0.18's virtqueue transport, separate stderr, static Linux binaries by @danbugs in #155

Full Changelog: v0.17.0...v0.18.0