From 45791518c8dbf460247a1b66f95ade0a6104cbb9 Mon Sep 17 00:00:00 2001 From: danbugs Date: Thu, 18 Jun 2026 19:14:04 +0000 Subject: [PATCH 01/15] feat(host): poll sockets during __hl_sleep for async networking When networking is enabled, __hl_sleep now uses libc::poll() on all open host sockets instead of a plain sleep. This lets the guest's scheduler wake early when socket I/O is ready, enabling async networking with a single vCPU. Also adds net_poll hcall for non-blocking socket readiness checks. Signed-off-by: danbugs --- host/src/lib.rs | 141 +++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 135 insertions(+), 6 deletions(-) diff --git a/host/src/lib.rs b/host/src/lib.rs index d785f34..c43d2de 100644 --- a/host/src/lib.rs +++ b/host/src/lib.rs @@ -1030,15 +1030,27 @@ fn register_internal_tools( ec.store(code, Ordering::Relaxed); Ok(serde_json::json!({})) }); + // Create socket table early so __hl_sleep can poll sockets while sleeping. + let socket_table = network.map(|_| Arc::new(Mutex::new(SocketTable::new()))); + let sc = sleep_cancel.clone(); + let st_for_sleep = socket_table.clone(); tools.register("__hl_sleep", move |args| { let ns = args["ns"].as_u64().unwrap_or(0).min(MAX_SLEEP_NS); if ns > 0 { + #[cfg(unix)] + if let Some(ref table) = st_for_sleep { + return hl_sleep_poll_sockets(&sc, table, ns); + } sc.wait(Duration::from_nanos(ns)); } Ok(serde_json::json!({})) }); - network.map(|policy| register_net_tools(tools, policy, listen_ports)) + + if let (Some(policy), Some(ref table)) = (network, &socket_table) { + register_net_tools(tools, policy, listen_ports, table.clone()); + } + socket_table } // --------------------------------------------------------------------------- @@ -1605,14 +1617,125 @@ fn handle_net_getsockname( } } +/// Poll host sockets for readiness using the real OS `poll()` syscall. +/// +/// Input: `{"fds": [{"fd": N, "events": N}, ...], "timeout_ms": N}` +/// Output: `{"ready": [{"fd": N, "revents": N}, ...]}` +/// +/// `events`/`revents` use standard POSIX poll flags (POLLIN=1, POLLOUT=4). +#[cfg(unix)] +fn handle_net_poll( + table: &Mutex, + args: &serde_json::Value, +) -> Result { + use serde_json::json; + use std::os::unix::io::AsRawFd; + + let fds_val = args + .get("fds") + .and_then(|v| v.as_array()) + .ok_or_else(|| anyhow!("net_poll: missing 'fds' array"))?; + let timeout_ms = args + .get("timeout_ms") + .and_then(|v| v.as_i64()) + .unwrap_or(0) as libc::c_int; + + let tbl = table.lock().unwrap(); + let mut pollfds: Vec = Vec::new(); + let mut guest_fds: Vec = Vec::new(); + + for entry in fds_val { + let fd = entry + .get("fd") + .and_then(|v| v.as_u64()) + .ok_or_else(|| anyhow!("net_poll: entry missing 'fd'"))?; + let events = entry + .get("events") + .and_then(|v| v.as_i64()) + .unwrap_or(0) as i16; + if let Ok(sock) = tbl.get_socket(fd) { + pollfds.push(libc::pollfd { + fd: sock.as_raw_fd(), + events, + revents: 0, + }); + guest_fds.push(fd); + } + } + drop(tbl); + + if pollfds.is_empty() { + return Ok(json!({"ready": []})); + } + + let _ret = unsafe { + libc::poll( + pollfds.as_mut_ptr(), + pollfds.len() as libc::nfds_t, + timeout_ms, + ) + }; + + let mut ready = Vec::new(); + for (i, pfd) in pollfds.iter().enumerate() { + if pfd.revents != 0 { + ready.push(json!({ + "fd": guest_fds[i], + "revents": pfd.revents as i64, + })); + } + } + Ok(json!({"ready": ready})) +} + +/// `__hl_sleep` variant: poll all sockets in the table while sleeping. +/// Returns early if any socket becomes ready. +#[cfg(unix)] +fn hl_sleep_poll_sockets( + sc: &SleepCancel, + table: &Mutex, + ns: u64, +) -> Result { + use serde_json::json; + use std::os::unix::io::AsRawFd; + + let tbl = table.lock().unwrap(); + let mut pollfds: Vec = tbl + .sockets + .values() + .map(|hs| libc::pollfd { + fd: hs.socket.as_raw_fd(), + events: libc::POLLIN | libc::POLLOUT | libc::POLLERR, + revents: 0, + }) + .collect(); + drop(tbl); + + if pollfds.is_empty() { + sc.wait(Duration::from_nanos(ns)); + return Ok(json!({})); + } + + let timeout_ms = ((ns / 1_000_000) as libc::c_int).max(1).min(30_000); + let ret = unsafe { + libc::poll( + pollfds.as_mut_ptr(), + pollfds.len() as libc::nfds_t, + timeout_ms, + ) + }; + + Ok(json!({"socket_ready": ret > 0})) +} + // --------------------------------------------------------------------------- fn register_net_tools( tools: &mut ToolRegistry, policy: &NetworkPolicy, listen_ports: Option<&ListenPorts>, -) -> Arc> { - let table = Arc::new(Mutex::new(SocketTable::new())); + table: Arc>, +) { let policy = Arc::new(policy.clone()); let t = table.clone(); @@ -1678,7 +1801,11 @@ fn register_net_tools( handle_net_getsockname(&t, &args) }); - table + #[cfg(unix)] + { + let t = table.clone(); + tools.register("net_poll", move |args| handle_net_poll(&t, &args)); + } } /// Routes incoming fs_* tool calls to the matching `FsSandbox` by @@ -3594,7 +3721,8 @@ mod tests { fn net_getsockopt_returns_correct_type_for_dgram() { let mut reg = ToolRegistry::new(); let policy = NetworkPolicy::AllowAll; - register_net_tools(&mut reg, &policy, None); + let table = Arc::new(Mutex::new(SocketTable::new())); + register_net_tools(&mut reg, &policy, None, table); let req = br#"{"name":"net_socket","args":{"family":2,"type":2}}"#; let resp = std::str::from_utf8(®.dispatch(req)).unwrap().to_string(); @@ -3695,7 +3823,8 @@ mod tests { let mut reg = ToolRegistry::new(); let policy = NetworkPolicy::AllowAll; - register_net_tools(&mut reg, &policy, None); + let table = Arc::new(Mutex::new(SocketTable::new())); + register_net_tools(&mut reg, &policy, None, table); // Create a socket let req = br#"{"name":"net_socket","args":{"family":2,"type":2}}"#; From d0989da6b51867374754945336725d62232cfd1d Mon Sep 17 00:00:00 2001 From: danbugs Date: Thu, 18 Jun 2026 19:14:09 +0000 Subject: [PATCH 02/15] feat: add Go HTTP server example Minimal Go net/http server running on Hyperlight with host-proxied async networking via hostsock. Includes Dockerfile for cross-platform rootfs builds. Signed-off-by: danbugs --- examples/go-http/Dockerfile | 32 ++++++++++++ examples/go-http/Justfile | 49 +++++++++++++++++ examples/go-http/kraft.yaml | 90 ++++++++++++++++++++++++++++++++ examples/go-http/main.go | 19 +++++++ examples/go-http/patch_interp.py | 28 ++++++++++ 5 files changed, 218 insertions(+) create mode 100644 examples/go-http/Dockerfile create mode 100644 examples/go-http/Justfile create mode 100644 examples/go-http/kraft.yaml create mode 100644 examples/go-http/main.go create mode 100644 examples/go-http/patch_interp.py diff --git a/examples/go-http/Dockerfile b/examples/go-http/Dockerfile new file mode 100644 index 0000000..8353118 --- /dev/null +++ b/examples/go-http/Dockerfile @@ -0,0 +1,32 @@ +FROM golang:1.21.3-bookworm AS build + +WORKDIR /src + +COPY ./main.go /src/main.go + +RUN set -xe; \ + CGO_ENABLED=0 \ + GOARCH=amd64 \ + go build \ + -buildmode=pie \ + -ldflags="-linkmode=internal" \ + -o /server main.go \ + ; + +# Strip PT_INTERP from the static PIE binary: Go's internal linker adds +# /lib64/ld-linux-x86-64.so.2 as interpreter for PIE on Linux, but the +# binary is fully self-contained and does not need a dynamic linker. +# Unikraft's elfloader fails when the interpreter is listed but absent. +# Patch the program header: change PT_INTERP (type=3) -> PT_NULL (type=0). +COPY patch_interp.py /patch_interp.py +RUN python3 /patch_interp.py /server + +FROM scratch AS rootfs + +COPY --from=build /server /bin/server + +# --- CPIO rootfs builder (used by: docker build --target cpio) --- +FROM alpine:3.20 AS cpio +RUN apk add --no-cache cpio findutils +COPY --from=rootfs / /rootfs/ +RUN cd /rootfs && find . | cpio -o -H newc > /output.cpio 2>/dev/null diff --git a/examples/go-http/Justfile b/examples/go-http/Justfile new file mode 100644 index 0000000..96b3ad0 --- /dev/null +++ b/examples/go-http/Justfile @@ -0,0 +1,49 @@ +# go-http on Hyperlight +# +# just run - Run the HTTP server (--port implies --net) +# just rootfs - Build rootfs via Docker (cross-platform) +# just build - Build/pull kernel +# just clean - Remove build artifacts + +set windows-shell := ["powershell.exe", "-NoLogo", "-Command"] +export DOCKER_BUILDKIT := "0" + +kernel := ".unikraft/build/go-http-hyperlight_hyperlight-x86_64" +initrd := "initrd.cpio" +memory := "32Mi" +image := "go-http-hyperlight" + +# Run the HTTP server (curl http://localhost:8080 to test) +run: + hyperlight-unikraft {{kernel}} --initrd {{initrd}} --memory {{memory}} --port 8080 -- /bin/server + +# Build rootfs via Docker (cross-platform) +rootfs: + docker build --platform linux/amd64 --target cpio -t {{image}}-cpio . + - docker rm -f {{image}}-tmp + docker create --name {{image}}-tmp {{image}}-cpio /bin/true + docker cp {{image}}-tmp:/output.cpio ./{{initrd}} + docker rm -f {{image}}-tmp + +# Build kernel via kraft (Linux) +[unix] +build: + -kraft-hyperlight build --plat hyperlight --arch x86_64 + +# Pull pre-built kernel from GHCR (Windows — no kernel published yet) +[windows] +build: + Write-Host "No pre-built kernel for go-http yet. Build on Linux first." + +# Clean + rebuild everything +rebuild: clean build rootfs + +# Clean build artifacts +[unix] +clean: + rm -rf .unikraft {{initrd}} + +[windows] +clean: + if (Test-Path .unikraft) { Remove-Item -Recurse -Force .unikraft } + if (Test-Path {{initrd}}) { Remove-Item -Force {{initrd}} } diff --git a/examples/go-http/kraft.yaml b/examples/go-http/kraft.yaml new file mode 100644 index 0000000..d06b787 --- /dev/null +++ b/examples/go-http/kraft.yaml @@ -0,0 +1,90 @@ +specification: '0.6' +name: go-http-hyperlight + +unikraft: + source: https://github.com/unikraft/unikraft.git + version: plat-hyperlight + kconfig: + # Platform + CONFIG_PLAT_HYPERLIGHT: 'y' + CONFIG_PAGING: 'n' + CONFIG_LIBUKVMEM: 'n' + CONFIG_LIBUKINTCTLR_HYPERLIGHT: 'y' + CONFIG_HYPERLIGHT_MAX_GUEST_LOG_LEVEL: 0 + + # Suppress kernel logging for performance + CONFIG_LIBUKPRINT_KLVL_CRIT: 'y' + CONFIG_LIBUKPRINT_PRINT_TIME: 'n' + CONFIG_LIBUKPRINT_PRINT_SRCNAME: 'n' + CONFIG_LIBUKBOOT_BANNER_NONE: 'y' + + # Size optimization + CONFIG_OPTIMIZE_SIZE: 'y' + CONFIG_OPTIMIZE_PIE: 'y' + + # VFS and initrd support + CONFIG_LIBVFSCORE: 'y' + CONFIG_LIBVFSCORE_AUTOMOUNT_CI: 'y' + CONFIG_LIBVFSCORE_AUTOMOUNT_CI_CUSTOM: 'y' + CONFIG_LIBVFSCORE_AUTOMOUNT_CI0_MP: '/' + CONFIG_LIBVFSCORE_AUTOMOUNT_CI0_DRIVER: 'cpiovfs' + CONFIG_LIBVFSCORE_AUTOMOUNT_CI0_UKOPTS_IFINITRD0: 'y' + CONFIG_LIBCPIOVFS: 'y' + CONFIG_LIBUKCPIO: 'y' + + # ELF loader - Go binary location + CONFIG_APPELFLOADER: 'y' + CONFIG_APPELFLOADER_VFSEXEC: 'y' + CONFIG_APPELFLOADER_CUSTOMAPPNAME: 'n' + CONFIG_APPELFLOADER_VFSEXEC_ENVPATH: 'n' + CONFIG_APPELFLOADER_VFSEXEC_PATH: '/bin/server' + CONFIG_LIBPOSIX_ENVIRON_ENVP0: '"PATH=/bin"' + CONFIG_LIBPOSIX_ENVIRON_ENVP1: '"GOMEMLIMIT=64MiB"' + CONFIG_LIBPOSIX_ENVIRON_ENVP2: '"GOMAXPROCS=1"' + CONFIG_LIBELF: 'y' + + # Random number support + CONFIG_LIBUKRANDOM_CMDLINE_SEED: 'y' + CONFIG_LIBUKRANDOM_GETRANDOM: 'y' + + # Threading support + CONFIG_LIBUKBOOT_MAINTHREAD: 'y' + CONFIG_LIBPOSIX_PROCESS_ARCH_PRCTL: 'y' + CONFIG_LIBPOSIX_PROCESS_MULTITHREADING: 'y' + + # Signal and futex support (needed for Go runtime) + CONFIG_LIBPOSIX_PROCESS_SIGNAL: 'y' + CONFIG_LIBPOSIX_FUTEX: 'y' + + # eventfd (needed by Go's epoll-based netpoller) + CONFIG_LIBPOSIX_EVENTFD: 'y' + + # mmap needed by Go runtime (heap reservation + page summaries) + CONFIG_LIBUKMPI: 'y' + CONFIG_LIBUKMMAP: 'y' + + # devfs + /dev/hcall (required for host-proxied networking) + CONFIG_LIBDEVFS: 'y' + CONFIG_LIBDEVFS_AUTOMOUNT: 'y' + CONFIG_HYPERLIGHT_HCALL: 'y' + + # Networking: host-proxied sockets + CONFIG_LIBPOSIX_SOCKET: 'y' + CONFIG_LIBHOSTSOCK: 'y' + + # FD infrastructure (required by posix-socket) + CONFIG_LIBPOSIX_FDIO: 'y' + CONFIG_LIBPOSIX_FDTAB: 'y' + CONFIG_LIBUKFILE: 'y' + +libraries: + app-elfloader: + source: https://github.com/unikraft/app-elfloader.git + version: plat-hyperlight + libelf: + source: https://github.com/unikraft/lib-libelf.git + version: staging + +targets: + - architecture: x86_64 + platform: hyperlight diff --git a/examples/go-http/main.go b/examples/go-http/main.go new file mode 100644 index 0000000..646c6ff --- /dev/null +++ b/examples/go-http/main.go @@ -0,0 +1,19 @@ +package main + +import ( + "fmt" + "log" + "net/http" +) + +func helloHandler(w http.ResponseWriter, r *http.Request) { + fmt.Fprint(w, "Hello from Hyperlight-Unikraft!") +} + +func main() { + mux := http.NewServeMux() + mux.HandleFunc("/", helloHandler) + + fmt.Println("Server is running on http://localhost:8080") + log.Fatal(http.ListenAndServe(":8080", mux)) +} diff --git a/examples/go-http/patch_interp.py b/examples/go-http/patch_interp.py new file mode 100644 index 0000000..37e94ca --- /dev/null +++ b/examples/go-http/patch_interp.py @@ -0,0 +1,28 @@ +#!/usr/bin/env python3 +"""Strip PT_INTERP from a static PIE binary. + +Go's internal linker adds /lib64/ld-linux-x86-64.so.2 as PT_INTERP for PIE +binaries on Linux, but a CGO_ENABLED=0 binary is fully self-contained and +does not need a dynamic linker. Patch PT_INTERP -> PT_NULL so elfloaders +that reject missing interpreters will load the binary directly. +""" +import struct +import sys + +path = sys.argv[1] +with open(path, "rb") as f: + data = bytearray(f.read()) + +e_phoff, = struct.unpack_from(" PT_NULL at program header index {i}") + +with open(path, "wb") as f: + f.write(data) From 170b580ced43e0a295f34a1cb1e6a204cbc21921 Mon Sep 17 00:00:00 2001 From: danbugs Date: Thu, 18 Jun 2026 19:14:13 +0000 Subject: [PATCH 03/15] feat: add .NET Kestrel HTTP server example ASP.NET Core Kestrel web server on Hyperlight using the same async networking infrastructure as go-http. Uses CreateEmptyBuilder to avoid inotify dependency (ENOSYS on Unikraft). Signed-off-by: danbugs --- examples/dotnet-http/Dockerfile | 25 +++++ examples/dotnet-http/Justfile | 49 +++++++++ examples/dotnet-http/KestrelHyperlight.csproj | 7 ++ examples/dotnet-http/Program.cs | 13 +++ examples/dotnet-http/kraft.yaml | 101 ++++++++++++++++++ 5 files changed, 195 insertions(+) create mode 100644 examples/dotnet-http/Dockerfile create mode 100644 examples/dotnet-http/Justfile create mode 100644 examples/dotnet-http/KestrelHyperlight.csproj create mode 100644 examples/dotnet-http/Program.cs create mode 100644 examples/dotnet-http/kraft.yaml diff --git a/examples/dotnet-http/Dockerfile b/examples/dotnet-http/Dockerfile new file mode 100644 index 0000000..ccf6f2f --- /dev/null +++ b/examples/dotnet-http/Dockerfile @@ -0,0 +1,25 @@ +FROM mcr.microsoft.com/dotnet/sdk:9.0-alpine AS build +WORKDIR /app +COPY Program.cs KestrelHyperlight.csproj ./ +RUN dotnet publish -c Release -r linux-musl-x64 --self-contained \ + -p:PublishTrimmed=true \ + -p:InvariantGlobalization=true \ + -o /out + +FROM scratch AS rootfs + +# .NET app + runtime (self-contained) +COPY --from=build /out/ /app/ + +# musl dynamic linker +COPY --from=build /lib/ld-musl-x86_64.so.1 /lib/ld-musl-x86_64.so.1 + +# C++ runtime libraries (needed by .NET runtime) +COPY --from=build /usr/lib/libstdc++.so.6 /usr/lib/libstdc++.so.6 +COPY --from=build /usr/lib/libgcc_s.so.1 /usr/lib/libgcc_s.so.1 + +# --- CPIO rootfs builder (used by: docker build --target cpio) --- +FROM alpine:3.20 AS cpio +RUN apk add --no-cache cpio findutils +COPY --from=rootfs / /rootfs/ +RUN cd /rootfs && find . | cpio -o -H newc > /output.cpio 2>/dev/null diff --git a/examples/dotnet-http/Justfile b/examples/dotnet-http/Justfile new file mode 100644 index 0000000..3750555 --- /dev/null +++ b/examples/dotnet-http/Justfile @@ -0,0 +1,49 @@ +# dotnet-http (Kestrel) on Hyperlight +# +# just run - Run the HTTP server (--port implies --net) +# just rootfs - Build rootfs via Docker (cross-platform) +# just build - Build/pull kernel +# just clean - Remove build artifacts + +set windows-shell := ["powershell.exe", "-NoLogo", "-Command"] +export DOCKER_BUILDKIT := "0" + +kernel := ".unikraft/build/dotnet-http-hyperlight_hyperlight-x86_64" +initrd := "initrd.cpio" +memory := "128Mi" +image := "dotnet-http-hyperlight" + +# Run the HTTP server (curl http://localhost:8080 to test) +run: + hyperlight-unikraft {{kernel}} --initrd {{initrd}} --memory {{memory}} --port 8080 -- /app/KestrelHyperlight + +# Build rootfs via Docker (cross-platform) +rootfs: + docker build --platform linux/amd64 --target cpio -t {{image}}-cpio . + - docker rm -f {{image}}-tmp + docker create --name {{image}}-tmp {{image}}-cpio /bin/true + docker cp {{image}}-tmp:/output.cpio ./{{initrd}} + docker rm -f {{image}}-tmp + +# Build kernel via kraft (Linux) +[unix] +build: + -kraft-hyperlight build --plat hyperlight --arch x86_64 + +# Pull pre-built kernel from GHCR (Windows — no kernel published yet) +[windows] +build: + Write-Host "No pre-built kernel for dotnet-http yet. Build on Linux first." + +# Clean + rebuild everything +rebuild: clean build rootfs + +# Clean build artifacts +[unix] +clean: + rm -rf .unikraft {{initrd}} + +[windows] +clean: + if (Test-Path .unikraft) { Remove-Item -Recurse -Force .unikraft } + if (Test-Path {{initrd}}) { Remove-Item -Force {{initrd}} } diff --git a/examples/dotnet-http/KestrelHyperlight.csproj b/examples/dotnet-http/KestrelHyperlight.csproj new file mode 100644 index 0000000..c770bb0 --- /dev/null +++ b/examples/dotnet-http/KestrelHyperlight.csproj @@ -0,0 +1,7 @@ + + + net9.0 + enable + true + + diff --git a/examples/dotnet-http/Program.cs b/examples/dotnet-http/Program.cs new file mode 100644 index 0000000..d584f0a --- /dev/null +++ b/examples/dotnet-http/Program.cs @@ -0,0 +1,13 @@ +var builder = WebApplication.CreateEmptyBuilder(new WebApplicationOptions()); +builder.WebHost.UseKestrelCore(); + +var app = builder.Build(); +app.Urls.Add("http://0.0.0.0:8080"); + +app.Run(async context => +{ + await context.Response.WriteAsync("Hello from Kestrel on Hyperlight!"); +}); + +Console.WriteLine("Listening on :8080"); +await app.RunAsync(); diff --git a/examples/dotnet-http/kraft.yaml b/examples/dotnet-http/kraft.yaml new file mode 100644 index 0000000..37c6ed0 --- /dev/null +++ b/examples/dotnet-http/kraft.yaml @@ -0,0 +1,101 @@ +specification: '0.6' +name: dotnet-http-hyperlight + +unikraft: + source: https://github.com/unikraft/unikraft.git + version: plat-hyperlight + kconfig: + # Platform + CONFIG_PLAT_HYPERLIGHT: 'y' + CONFIG_PAGING: 'n' + CONFIG_LIBUKVMEM: 'n' + CONFIG_LIBUKINTCTLR_HYPERLIGHT: 'y' + CONFIG_HYPERLIGHT_MAX_GUEST_LOG_LEVEL: 0 + + # Suppress kernel logging for performance + CONFIG_LIBUKPRINT_KLVL_CRIT: 'y' + CONFIG_LIBUKPRINT_PRINT_TIME: 'n' + CONFIG_LIBUKPRINT_PRINT_SRCNAME: 'n' + CONFIG_LIBUKBOOT_BANNER_NONE: 'y' + + # Size optimization + CONFIG_OPTIMIZE_SIZE: 'y' + CONFIG_OPTIMIZE_PIE: 'y' + + # VFS and initrd support + CONFIG_LIBVFSCORE: 'y' + CONFIG_LIBVFSCORE_AUTOMOUNT_CI: 'y' + CONFIG_LIBVFSCORE_AUTOMOUNT_CI_CUSTOM: 'y' + CONFIG_LIBVFSCORE_AUTOMOUNT_CI0_MP: '/' + CONFIG_LIBVFSCORE_AUTOMOUNT_CI0_DRIVER: 'cpiovfs' + CONFIG_LIBVFSCORE_AUTOMOUNT_CI0_UKOPTS_IFINITRD0: 'y' + CONFIG_LIBCPIOVFS: 'y' + CONFIG_LIBUKCPIO: 'y' + + # ELF loader - .NET apphost binary + CONFIG_APPELFLOADER: 'y' + CONFIG_APPELFLOADER_VFSEXEC: 'y' + CONFIG_APPELFLOADER_CUSTOMAPPNAME: 'n' + CONFIG_APPELFLOADER_VFSEXEC_ENVPATH: 'n' + CONFIG_APPELFLOADER_VFSEXEC_PATH: '/app/KestrelHyperlight' + CONFIG_APPELFLOADER_VFSEXEC_EXECBIT_CHECK: 'n' + CONFIG_LIBPOSIX_ENVIRON_ENVP0: '"PATH=/app:/usr/bin:/bin"' + CONFIG_LIBPOSIX_ENVIRON_ENVP1: '"DOTNET_GCHeapHardLimit=0x8000000"' + CONFIG_LIBPOSIX_ENVIRON_ENVP2: '"DOTNET_gcServer=0"' + CONFIG_LIBPOSIX_ENVIRON_ENVP3: '"DOTNET_EnableWriteXorExecute=0"' + CONFIG_LIBPOSIX_ENVIRON_ENVP4: '"COMPlus_EnableDiagnostics=0"' + CONFIG_LIBPOSIX_ENVIRON_ENVP5: '"DOTNET_DefaultStackSize=0x10000"' + CONFIG_LIBPOSIX_ENVIRON_ENVP6: '"hostBuilder__reloadConfigOnChange=false"' + CONFIG_LIBELF: 'y' + + # Random number support + CONFIG_LIBUKRANDOM_CMDLINE_SEED: 'y' + CONFIG_LIBUKRANDOM_GETRANDOM: 'y' + + # Stack size (order 9 = 2^9 pages = 2MB, needed for CoreCLR's 1.5MB alloca) + CONFIG_STACK_SIZE_PAGE_ORDER: 9 + + # Threading support (required for .NET runtime) + CONFIG_LIBUKBOOT_MAINTHREAD: 'y' + CONFIG_LIBPOSIX_PROCESS_ARCH_PRCTL: 'y' + CONFIG_LIBPOSIX_PROCESS_MULTITHREADING: 'y' + CONFIG_LIBPOSIX_PROCESS_SIGNAL: 'y' + CONFIG_LIBPOSIX_FUTEX: 'y' + CONFIG_LIBUKSCHED: 'y' + CONFIG_LIBUKSCHEDCOOP: 'y' + CONFIG_LIBUKMPI: 'y' + CONFIG_LIBUKLOCK: 'y' + CONFIG_LIBUKLOCK_SEMAPHORE: 'y' + CONFIG_LIBUKLOCK_MUTEX: 'y' + + # mmap needed by CoreCLR memory management + CONFIG_LIBUKMMAP: 'y' + + # Event/polling support + CONFIG_LIBPOSIX_POLL: 'y' + CONFIG_LIBPOSIX_EVENTFD: 'y' + CONFIG_LIBPOSIX_FDIO: 'y' + CONFIG_LIBPOSIX_FDTAB: 'y' + CONFIG_LIBUKFILE: 'y' + + # devfs + /dev/hcall (required for host-proxied networking) + CONFIG_LIBDEVFS: 'y' + CONFIG_LIBDEVFS_AUTOMOUNT: 'y' + CONFIG_HYPERLIGHT_HCALL: 'y' + + # Networking: host-proxied sockets + CONFIG_LIBPOSIX_SOCKET: 'y' + CONFIG_LIBHOSTSOCK: 'y' + + +libraries: + app-elfloader: + source: https://github.com/unikraft/app-elfloader.git + version: plat-hyperlight + libelf: + source: https://github.com/unikraft/lib-libelf.git + version: staging + +targets: + - architecture: x86_64 + platform: hyperlight From 7f1cc6f56cd0e225d52c9354f829cce2c7fdf6e7 Mon Sep 17 00:00:00 2001 From: danbugs Date: Thu, 18 Jun 2026 20:06:26 +0000 Subject: [PATCH 04/15] ci: add go-http and dotnet-http to test matrix HTTP server examples need a different test path: start the server in the background, poll until ready, curl the endpoint, check the response body, then kill the server. Signed-off-by: danbugs --- .github/workflows/test-examples.yml | 97 +++++++++++++++++++++++++++++ 1 file changed, 97 insertions(+) diff --git a/.github/workflows/test-examples.yml b/.github/workflows/test-examples.yml index 48614f5..e60b70c 100644 --- a/.github/workflows/test-examples.yml +++ b/.github/workflows/test-examples.yml @@ -61,6 +61,8 @@ jobs: - python-agent-driver - powershell - networking-py + - go-http + - dotnet-http steps: - uses: actions/checkout@v4 @@ -223,6 +225,14 @@ jobs: - example: networking-py args: "--port 8080 -- /echo_server_test.py" expect: "SUCCESS: bind\\+listen on port 8080 allowed" + - example: go-http + args: "--port 8080 -- /bin/server" + expect: "Hello from Hyperlight-Unikraft!" + http_port: "8080" + - example: dotnet-http + args: "--port 8080 -- /app/KestrelHyperlight" + expect: "Hello from Kestrel on Hyperlight!" + http_port: "8080" steps: - uses: actions/checkout@v4 @@ -338,6 +348,41 @@ jobs: mkdir -p "$mount_dir" mount_args="--mount $mount_dir:/host" fi + # HTTP server examples: start in background, poll, curl, kill. + http_port="${{ matrix.http_port }}" + if [ -n "$http_port" ]; then + mem_args="" + if [ -n "$memory" ]; then + mem_args="-m $memory" + fi + hyperlight-unikraft -q $mem_args "$kernel" --initrd "$cpio" ${{ matrix.args }} & + server_pid=$! + ready=0 + for i in $(seq 1 30); do + if curl -s --max-time 2 "http://localhost:${http_port}" >/dev/null 2>&1; then + ready=1 + break + fi + sleep 1 + done + if [ "$ready" -ne 1 ]; then + echo "FAIL: server did not become ready within 30s" + kill "$server_pid" 2>/dev/null || true + exit 1 + fi + response=$(curl -s --max-time 5 "http://localhost:${http_port}") + kill "$server_pid" 2>/dev/null || true + wait "$server_pid" 2>/dev/null || true + echo "=== HTTP response ===" + echo "$response" + if echo "$response" | grep -qF "$expect"; then + echo "PASS: matched /$expect/" + else + echo "FAIL: did not match /$expect/" + exit 1 + fi + exit 0 + fi case "${{ matrix.driver }}" in multifn-test) cmd=(timeout 60 /home/runner/work/hyperlight-unikraft/hyperlight-unikraft/host/target/release/multifn-test "$kernel" "$cpio") @@ -403,6 +448,8 @@ jobs: - python-agent-driver - powershell - networking-py + - go-http + - dotnet-http steps: - uses: actions/checkout@v4 @@ -554,6 +601,14 @@ jobs: - example: networking-py args: "--port 8080 -- /echo_server_test.py" expect: "SUCCESS: bind\\+listen on port 8080 allowed" + - example: go-http + args: "--port 8080 -- /bin/server" + expect: "Hello from Hyperlight-Unikraft!" + http_port: "8080" + - example: dotnet-http + args: "--port 8080 -- /app/KestrelHyperlight" + expect: "Hello from Kestrel on Hyperlight!" + http_port: "8080" steps: - uses: actions/checkout@v4 @@ -622,6 +677,48 @@ jobs: $PSNativeCommandUseErrorActionPreference = $false $ErrorActionPreference = 'Continue' + # HTTP server examples: start in background, poll, curl, kill. + $httpPort = '${{ matrix.http_port }}' + if ($httpPort -ne '') { + $argList = @() + if ($runArgs -ne '') { + $argList = $runArgs.Split(' ') | Where-Object { $_ -ne '' } + } + $memArgs = @() + if ($memory -ne '') { + $memArgs = @('-m', $memory) + } + $proc = Start-Process -FilePath 'hyperlight-unikraft' ` + -ArgumentList (@('-q') + $memArgs + @($kernel, '--initrd', $cpio) + $argList) ` + -PassThru -NoNewWindow + $ready = $false + for ($i = 0; $i -lt 30; $i++) { + try { + $null = Invoke-WebRequest -Uri "http://localhost:${httpPort}" -UseBasicParsing -TimeoutSec 2 -ErrorAction Stop + $ready = $true + break + } catch { + Start-Sleep -Seconds 1 + } + } + if (-not $ready) { + Write-Host "FAIL: server did not become ready within 30s" + Stop-Process -Id $proc.Id -Force -ErrorAction SilentlyContinue + exit 1 + } + $resp = (Invoke-WebRequest -Uri "http://localhost:${httpPort}" -UseBasicParsing -TimeoutSec 5).Content + Stop-Process -Id $proc.Id -Force -ErrorAction SilentlyContinue + Write-Host "=== HTTP response ===" + Write-Host $resp + if ($resp -match [regex]::Escape($expect)) { + Write-Host "PASS: matched /$expect/" + exit 0 + } else { + Write-Host "FAIL: did not match /$expect/" + exit 1 + } + } + switch ($driver) { 'multifn-test' { $out = & multifn-test $kernel $cpio 2>&1 From d10746e3916546702e754ed48a0cdef64edf58bf Mon Sep 17 00:00:00 2001 From: danbugs Date: Thu, 18 Jun 2026 20:13:42 +0000 Subject: [PATCH 05/15] fix: resolve fmt and clippy warnings Signed-off-by: danbugs --- host/src/lib.rs | 12 +++--------- 1 file changed, 3 insertions(+), 9 deletions(-) diff --git a/host/src/lib.rs b/host/src/lib.rs index c43d2de..ab61ea9 100644 --- a/host/src/lib.rs +++ b/host/src/lib.rs @@ -1635,10 +1635,7 @@ fn handle_net_poll( .get("fds") .and_then(|v| v.as_array()) .ok_or_else(|| anyhow!("net_poll: missing 'fds' array"))?; - let timeout_ms = args - .get("timeout_ms") - .and_then(|v| v.as_i64()) - .unwrap_or(0) as libc::c_int; + let timeout_ms = args.get("timeout_ms").and_then(|v| v.as_i64()).unwrap_or(0) as libc::c_int; let tbl = table.lock().unwrap(); let mut pollfds: Vec = Vec::new(); @@ -1649,10 +1646,7 @@ fn handle_net_poll( .get("fd") .and_then(|v| v.as_u64()) .ok_or_else(|| anyhow!("net_poll: entry missing 'fd'"))?; - let events = entry - .get("events") - .and_then(|v| v.as_i64()) - .unwrap_or(0) as i16; + let events = entry.get("events").and_then(|v| v.as_i64()).unwrap_or(0) as i16; if let Ok(sock) = tbl.get_socket(fd) { pollfds.push(libc::pollfd { fd: sock.as_raw_fd(), @@ -1716,7 +1710,7 @@ fn hl_sleep_poll_sockets( return Ok(json!({})); } - let timeout_ms = ((ns / 1_000_000) as libc::c_int).max(1).min(30_000); + let timeout_ms = ((ns / 1_000_000) as libc::c_int).clamp(1, 30_000); let ret = unsafe { libc::poll( pollfds.as_mut_ptr(), From bd1e17841e2e3b6b0f96e3be4fad0b2b063e40e2 Mon Sep 17 00:00:00 2001 From: danbugs Date: Thu, 18 Jun 2026 20:24:46 +0000 Subject: [PATCH 06/15] fix: use static-pie linking for Go examples, remove patch_interp.py Build with CGO_ENABLED=1 and -extldflags "-static-pie" instead of patching PT_INTERP out of CGO_ENABLED=0 PIE binaries. Signed-off-by: danbugs --- examples/go-http/Dockerfile | 13 ++----------- examples/go-http/patch_interp.py | 28 ---------------------------- examples/go/Dockerfile | 13 ++----------- examples/go/patch_interp.py | 28 ---------------------------- 4 files changed, 4 insertions(+), 78 deletions(-) delete mode 100644 examples/go-http/patch_interp.py delete mode 100644 examples/go/patch_interp.py diff --git a/examples/go-http/Dockerfile b/examples/go-http/Dockerfile index 8353118..055e9da 100644 --- a/examples/go-http/Dockerfile +++ b/examples/go-http/Dockerfile @@ -5,22 +5,13 @@ WORKDIR /src COPY ./main.go /src/main.go RUN set -xe; \ - CGO_ENABLED=0 \ + CGO_ENABLED=1 \ GOARCH=amd64 \ go build \ - -buildmode=pie \ - -ldflags="-linkmode=internal" \ + -ldflags='-linkmode external -extldflags "-static-pie" -s -w' \ -o /server main.go \ ; -# Strip PT_INTERP from the static PIE binary: Go's internal linker adds -# /lib64/ld-linux-x86-64.so.2 as interpreter for PIE on Linux, but the -# binary is fully self-contained and does not need a dynamic linker. -# Unikraft's elfloader fails when the interpreter is listed but absent. -# Patch the program header: change PT_INTERP (type=3) -> PT_NULL (type=0). -COPY patch_interp.py /patch_interp.py -RUN python3 /patch_interp.py /server - FROM scratch AS rootfs COPY --from=build /server /bin/server diff --git a/examples/go-http/patch_interp.py b/examples/go-http/patch_interp.py deleted file mode 100644 index 37e94ca..0000000 --- a/examples/go-http/patch_interp.py +++ /dev/null @@ -1,28 +0,0 @@ -#!/usr/bin/env python3 -"""Strip PT_INTERP from a static PIE binary. - -Go's internal linker adds /lib64/ld-linux-x86-64.so.2 as PT_INTERP for PIE -binaries on Linux, but a CGO_ENABLED=0 binary is fully self-contained and -does not need a dynamic linker. Patch PT_INTERP -> PT_NULL so elfloaders -that reject missing interpreters will load the binary directly. -""" -import struct -import sys - -path = sys.argv[1] -with open(path, "rb") as f: - data = bytearray(f.read()) - -e_phoff, = struct.unpack_from(" PT_NULL at program header index {i}") - -with open(path, "wb") as f: - f.write(data) diff --git a/examples/go/Dockerfile b/examples/go/Dockerfile index 67bfcf6..996b1a0 100644 --- a/examples/go/Dockerfile +++ b/examples/go/Dockerfile @@ -5,22 +5,13 @@ WORKDIR /src COPY ./hello.go /src/hello.go RUN set -xe; \ - CGO_ENABLED=0 \ + CGO_ENABLED=1 \ GOARCH=amd64 \ go build \ - -buildmode=pie \ - -ldflags="-linkmode=internal" \ + -ldflags='-linkmode external -extldflags "-static-pie" -s -w' \ -o /hello hello.go \ ; -# Strip PT_INTERP from the static PIE binary: Go's internal linker adds -# /lib64/ld-linux-x86-64.so.2 as interpreter for PIE on Linux, but the -# binary is fully self-contained and does not need a dynamic linker. -# Unikraft's elfloader fails when the interpreter is listed but absent. -# Patch the program header: change PT_INTERP (type=3) -> PT_NULL (type=0). -COPY patch_interp.py /patch_interp.py -RUN python3 /patch_interp.py /hello - FROM scratch AS rootfs COPY --from=build /hello /bin/hello diff --git a/examples/go/patch_interp.py b/examples/go/patch_interp.py deleted file mode 100644 index 37e94ca..0000000 --- a/examples/go/patch_interp.py +++ /dev/null @@ -1,28 +0,0 @@ -#!/usr/bin/env python3 -"""Strip PT_INTERP from a static PIE binary. - -Go's internal linker adds /lib64/ld-linux-x86-64.so.2 as PT_INTERP for PIE -binaries on Linux, but a CGO_ENABLED=0 binary is fully self-contained and -does not need a dynamic linker. Patch PT_INTERP -> PT_NULL so elfloaders -that reject missing interpreters will load the binary directly. -""" -import struct -import sys - -path = sys.argv[1] -with open(path, "rb") as f: - data = bytearray(f.read()) - -e_phoff, = struct.unpack_from(" PT_NULL at program header index {i}") - -with open(path, "wb") as f: - f.write(data) From 7e9881b11c1a3b70584ae13617b066659f06e750 Mon Sep 17 00:00:00 2001 From: danbugs Date: Thu, 18 Jun 2026 20:27:03 +0000 Subject: [PATCH 07/15] Revert "fix: use static-pie linking for Go examples, remove patch_interp.py" This reverts commit bd1e17841e2e3b6b0f96e3be4fad0b2b063e40e2. Signed-off-by: danbugs --- examples/go-http/Dockerfile | 13 +++++++++++-- examples/go-http/patch_interp.py | 28 ++++++++++++++++++++++++++++ examples/go/Dockerfile | 13 +++++++++++-- examples/go/patch_interp.py | 28 ++++++++++++++++++++++++++++ 4 files changed, 78 insertions(+), 4 deletions(-) create mode 100644 examples/go-http/patch_interp.py create mode 100644 examples/go/patch_interp.py diff --git a/examples/go-http/Dockerfile b/examples/go-http/Dockerfile index 055e9da..8353118 100644 --- a/examples/go-http/Dockerfile +++ b/examples/go-http/Dockerfile @@ -5,13 +5,22 @@ WORKDIR /src COPY ./main.go /src/main.go RUN set -xe; \ - CGO_ENABLED=1 \ + CGO_ENABLED=0 \ GOARCH=amd64 \ go build \ - -ldflags='-linkmode external -extldflags "-static-pie" -s -w' \ + -buildmode=pie \ + -ldflags="-linkmode=internal" \ -o /server main.go \ ; +# Strip PT_INTERP from the static PIE binary: Go's internal linker adds +# /lib64/ld-linux-x86-64.so.2 as interpreter for PIE on Linux, but the +# binary is fully self-contained and does not need a dynamic linker. +# Unikraft's elfloader fails when the interpreter is listed but absent. +# Patch the program header: change PT_INTERP (type=3) -> PT_NULL (type=0). +COPY patch_interp.py /patch_interp.py +RUN python3 /patch_interp.py /server + FROM scratch AS rootfs COPY --from=build /server /bin/server diff --git a/examples/go-http/patch_interp.py b/examples/go-http/patch_interp.py new file mode 100644 index 0000000..37e94ca --- /dev/null +++ b/examples/go-http/patch_interp.py @@ -0,0 +1,28 @@ +#!/usr/bin/env python3 +"""Strip PT_INTERP from a static PIE binary. + +Go's internal linker adds /lib64/ld-linux-x86-64.so.2 as PT_INTERP for PIE +binaries on Linux, but a CGO_ENABLED=0 binary is fully self-contained and +does not need a dynamic linker. Patch PT_INTERP -> PT_NULL so elfloaders +that reject missing interpreters will load the binary directly. +""" +import struct +import sys + +path = sys.argv[1] +with open(path, "rb") as f: + data = bytearray(f.read()) + +e_phoff, = struct.unpack_from(" PT_NULL at program header index {i}") + +with open(path, "wb") as f: + f.write(data) diff --git a/examples/go/Dockerfile b/examples/go/Dockerfile index 996b1a0..67bfcf6 100644 --- a/examples/go/Dockerfile +++ b/examples/go/Dockerfile @@ -5,13 +5,22 @@ WORKDIR /src COPY ./hello.go /src/hello.go RUN set -xe; \ - CGO_ENABLED=1 \ + CGO_ENABLED=0 \ GOARCH=amd64 \ go build \ - -ldflags='-linkmode external -extldflags "-static-pie" -s -w' \ + -buildmode=pie \ + -ldflags="-linkmode=internal" \ -o /hello hello.go \ ; +# Strip PT_INTERP from the static PIE binary: Go's internal linker adds +# /lib64/ld-linux-x86-64.so.2 as interpreter for PIE on Linux, but the +# binary is fully self-contained and does not need a dynamic linker. +# Unikraft's elfloader fails when the interpreter is listed but absent. +# Patch the program header: change PT_INTERP (type=3) -> PT_NULL (type=0). +COPY patch_interp.py /patch_interp.py +RUN python3 /patch_interp.py /hello + FROM scratch AS rootfs COPY --from=build /hello /bin/hello diff --git a/examples/go/patch_interp.py b/examples/go/patch_interp.py new file mode 100644 index 0000000..37e94ca --- /dev/null +++ b/examples/go/patch_interp.py @@ -0,0 +1,28 @@ +#!/usr/bin/env python3 +"""Strip PT_INTERP from a static PIE binary. + +Go's internal linker adds /lib64/ld-linux-x86-64.so.2 as PT_INTERP for PIE +binaries on Linux, but a CGO_ENABLED=0 binary is fully self-contained and +does not need a dynamic linker. Patch PT_INTERP -> PT_NULL so elfloaders +that reject missing interpreters will load the binary directly. +""" +import struct +import sys + +path = sys.argv[1] +with open(path, "rb") as f: + data = bytearray(f.read()) + +e_phoff, = struct.unpack_from(" PT_NULL at program header index {i}") + +with open(path, "wb") as f: + f.write(data) From 0a8b037ca2294c028b2aede328e082c4936fe955 Mon Sep 17 00:00:00 2001 From: danbugs Date: Thu, 18 Jun 2026 20:42:38 +0000 Subject: [PATCH 08/15] fix: address Copilot review feedback on net_poll and hl_sleep MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Clamp timeout_ms before i64→c_int cast, validate events range, handle poll() errors and EINTR, report POLLNVAL for unknown fds, add argument check to patch_interp.py. Signed-off-by: danbugs --- examples/go-http/patch_interp.py | 4 ++++ examples/go/patch_interp.py | 4 ++++ host/src/lib.rs | 34 +++++++++++++++++++++++++++----- 3 files changed, 37 insertions(+), 5 deletions(-) diff --git a/examples/go-http/patch_interp.py b/examples/go-http/patch_interp.py index 37e94ca..a06c796 100644 --- a/examples/go-http/patch_interp.py +++ b/examples/go-http/patch_interp.py @@ -9,6 +9,10 @@ import struct import sys +if len(sys.argv) != 2: + print(f"Usage: {sys.argv[0]} ", file=sys.stderr) + sys.exit(1) + path = sys.argv[1] with open(path, "rb") as f: data = bytearray(f.read()) diff --git a/examples/go/patch_interp.py b/examples/go/patch_interp.py index 37e94ca..a06c796 100644 --- a/examples/go/patch_interp.py +++ b/examples/go/patch_interp.py @@ -9,6 +9,10 @@ import struct import sys +if len(sys.argv) != 2: + print(f"Usage: {sys.argv[0]} ", file=sys.stderr) + sys.exit(1) + path = sys.argv[1] with open(path, "rb") as f: data = bytearray(f.read()) diff --git a/host/src/lib.rs b/host/src/lib.rs index ab61ea9..66b9b8b 100644 --- a/host/src/lib.rs +++ b/host/src/lib.rs @@ -1635,18 +1635,27 @@ fn handle_net_poll( .get("fds") .and_then(|v| v.as_array()) .ok_or_else(|| anyhow!("net_poll: missing 'fds' array"))?; - let timeout_ms = args.get("timeout_ms").and_then(|v| v.as_i64()).unwrap_or(0) as libc::c_int; + let timeout_ms = args + .get("timeout_ms") + .and_then(|v| v.as_i64()) + .unwrap_or(0) + .clamp(0, libc::c_int::MAX as i64) as libc::c_int; let tbl = table.lock().unwrap(); let mut pollfds: Vec = Vec::new(); let mut guest_fds: Vec = Vec::new(); + let mut ready = Vec::new(); for entry in fds_val { let fd = entry .get("fd") .and_then(|v| v.as_u64()) .ok_or_else(|| anyhow!("net_poll: entry missing 'fd'"))?; - let events = entry.get("events").and_then(|v| v.as_i64()).unwrap_or(0) as i16; + let raw_events = entry.get("events").and_then(|v| v.as_i64()).unwrap_or(0); + if !(0..=i16::MAX as i64).contains(&raw_events) { + return Err(anyhow!("net_poll: events {raw_events} out of i16 range")); + } + let events = raw_events as i16; if let Ok(sock) = tbl.get_socket(fd) { pollfds.push(libc::pollfd { fd: sock.as_raw_fd(), @@ -1654,15 +1663,17 @@ fn handle_net_poll( revents: 0, }); guest_fds.push(fd); + } else { + ready.push(json!({ "fd": fd, "revents": libc::POLLNVAL as i64 })); } } drop(tbl); if pollfds.is_empty() { - return Ok(json!({"ready": []})); + return Ok(json!({"ready": ready})); } - let _ret = unsafe { + let ret = unsafe { libc::poll( pollfds.as_mut_ptr(), pollfds.len() as libc::nfds_t, @@ -1670,7 +1681,13 @@ fn handle_net_poll( ) }; - let mut ready = Vec::new(); + if ret < 0 { + let err = std::io::Error::last_os_error(); + if err.raw_os_error() != Some(libc::EINTR) { + return Err(anyhow!("net_poll: poll() failed: {err}")); + } + } + for (i, pfd) in pollfds.iter().enumerate() { if pfd.revents != 0 { ready.push(json!({ @@ -1719,6 +1736,13 @@ fn hl_sleep_poll_sockets( ) }; + if ret < 0 { + let err = std::io::Error::last_os_error(); + if err.raw_os_error() != Some(libc::EINTR) { + return Err(anyhow!("hl_sleep poll failed: {err}")); + } + } + Ok(json!({"socket_ready": ret > 0})) } From f531f19389882b9ea084574f44cd91ef728dec31 Mon Sep 17 00:00:00 2001 From: danbugs Date: Thu, 18 Jun 2026 21:07:44 +0000 Subject: [PATCH 09/15] ci: exclude go-http and dotnet-http from Windows test matrix Socket polling (hl_sleep_poll_sockets) is Unix-only, so HTTP server examples cannot serve requests on Windows. Remove them from package-images-for-windows and runtime-test-windows matrices. Signed-off-by: danbugs --- .github/workflows/test-examples.yml | 13 +++---------- 1 file changed, 3 insertions(+), 10 deletions(-) diff --git a/.github/workflows/test-examples.yml b/.github/workflows/test-examples.yml index e60b70c..947b4e6 100644 --- a/.github/workflows/test-examples.yml +++ b/.github/workflows/test-examples.yml @@ -448,8 +448,7 @@ jobs: - python-agent-driver - powershell - networking-py - - go-http - - dotnet-http + # go-http and dotnet-http excluded: no Windows runtime test for them steps: - uses: actions/checkout@v4 @@ -601,14 +600,8 @@ jobs: - example: networking-py args: "--port 8080 -- /echo_server_test.py" expect: "SUCCESS: bind\\+listen on port 8080 allowed" - - example: go-http - args: "--port 8080 -- /bin/server" - expect: "Hello from Hyperlight-Unikraft!" - http_port: "8080" - - example: dotnet-http - args: "--port 8080 -- /app/KestrelHyperlight" - expect: "Hello from Kestrel on Hyperlight!" - http_port: "8080" + # go-http and dotnet-http are excluded from Windows: they need + # hl_sleep_poll_sockets (Unix-only) to wake the guest on I/O. steps: - uses: actions/checkout@v4 From fa0d7bb1e4e3c67714a4c2ffb4cb12a622327842 Mon Sep 17 00:00:00 2001 From: danbugs Date: Thu, 18 Jun 2026 21:16:22 +0000 Subject: [PATCH 10/15] feat(host): add Windows socket polling via WSAPoll Implement hl_sleep_poll_sockets and handle_net_poll for Windows using WSAPoll from winsock2, matching the Unix libc::poll() implementations. This enables HTTP server examples (go-http, dotnet-http) to run on Windows by waking the guest when socket I/O arrives. Restores go-http and dotnet-http in Windows CI matrices. Signed-off-by: danbugs --- .github/workflows/test-examples.yml | 13 +++- host/Cargo.toml | 2 +- host/src/lib.rs | 115 +++++++++++++++++++++++++++- 3 files changed, 122 insertions(+), 8 deletions(-) diff --git a/.github/workflows/test-examples.yml b/.github/workflows/test-examples.yml index 947b4e6..e60b70c 100644 --- a/.github/workflows/test-examples.yml +++ b/.github/workflows/test-examples.yml @@ -448,7 +448,8 @@ jobs: - python-agent-driver - powershell - networking-py - # go-http and dotnet-http excluded: no Windows runtime test for them + - go-http + - dotnet-http steps: - uses: actions/checkout@v4 @@ -600,8 +601,14 @@ jobs: - example: networking-py args: "--port 8080 -- /echo_server_test.py" expect: "SUCCESS: bind\\+listen on port 8080 allowed" - # go-http and dotnet-http are excluded from Windows: they need - # hl_sleep_poll_sockets (Unix-only) to wake the guest on I/O. + - example: go-http + args: "--port 8080 -- /bin/server" + expect: "Hello from Hyperlight-Unikraft!" + http_port: "8080" + - example: dotnet-http + args: "--port 8080 -- /app/KestrelHyperlight" + expect: "Hello from Kestrel on Hyperlight!" + http_port: "8080" steps: - uses: actions/checkout@v4 diff --git a/host/Cargo.toml b/host/Cargo.toml index de55d41..c312f67 100644 --- a/host/Cargo.toml +++ b/host/Cargo.toml @@ -44,5 +44,5 @@ nix = { version = "0.29", features = ["fs"] } libc = "0.2" [target.'cfg(windows)'.dependencies] -windows-sys = { version = "0.61", features = ["Win32_System_IO", "Win32_System_Ioctl", "Win32_Storage_FileSystem"] } +windows-sys = { version = "0.61", features = ["Win32_System_IO", "Win32_System_Ioctl", "Win32_Storage_FileSystem", "Win32_Networking_WinSock"] } diff --git a/host/src/lib.rs b/host/src/lib.rs index 66b9b8b..334d136 100644 --- a/host/src/lib.rs +++ b/host/src/lib.rs @@ -1038,7 +1038,6 @@ fn register_internal_tools( tools.register("__hl_sleep", move |args| { let ns = args["ns"].as_u64().unwrap_or(0).min(MAX_SLEEP_NS); if ns > 0 { - #[cfg(unix)] if let Some(ref table) = st_for_sleep { return hl_sleep_poll_sockets(&sc, table, ns); } @@ -1699,11 +1698,80 @@ fn handle_net_poll( Ok(json!({"ready": ready})) } +#[cfg(windows)] +fn handle_net_poll( + table: &Mutex, + args: &serde_json::Value, +) -> Result { + use serde_json::json; + use std::os::windows::io::AsRawSocket; + use windows_sys::Win32::Networking::WinSock::{WSAPoll, POLLNVAL, WSAPOLLFD}; + + let fds_val = args + .get("fds") + .and_then(|v| v.as_array()) + .ok_or_else(|| anyhow!("net_poll: missing 'fds' array"))?; + let timeout_ms = args + .get("timeout_ms") + .and_then(|v| v.as_i64()) + .unwrap_or(0) + .clamp(0, i32::MAX as i64) as i32; + + let tbl = table.lock().unwrap(); + let mut pollfds: Vec = Vec::new(); + let mut guest_fds: Vec = Vec::new(); + let mut ready = Vec::new(); + + for entry in fds_val { + let fd = entry + .get("fd") + .and_then(|v| v.as_u64()) + .ok_or_else(|| anyhow!("net_poll: entry missing 'fd'"))?; + let raw_events = entry.get("events").and_then(|v| v.as_i64()).unwrap_or(0); + if !(0..=i16::MAX as i64).contains(&raw_events) { + return Err(anyhow!("net_poll: events {raw_events} out of i16 range")); + } + let events = raw_events as i16; + if let Ok(sock) = tbl.get_socket(fd) { + pollfds.push(WSAPOLLFD { + fd: sock.as_raw_socket() as usize, + events, + revents: 0, + }); + guest_fds.push(fd); + } else { + ready.push(json!({ "fd": fd, "revents": POLLNVAL as i64 })); + } + } + drop(tbl); + + if pollfds.is_empty() { + return Ok(json!({"ready": ready})); + } + + let ret = unsafe { WSAPoll(pollfds.as_mut_ptr(), pollfds.len() as u32, timeout_ms) }; + + if ret < 0 { + let err = std::io::Error::last_os_error(); + return Err(anyhow!("net_poll: WSAPoll() failed: {err}")); + } + + for (i, pfd) in pollfds.iter().enumerate() { + if pfd.revents != 0 { + ready.push(json!({ + "fd": guest_fds[i], + "revents": pfd.revents as i64, + })); + } + } + Ok(json!({"ready": ready})) +} + /// `__hl_sleep` variant: poll all sockets in the table while sleeping. /// Returns early if any socket becomes ready. #[cfg(unix)] fn hl_sleep_poll_sockets( - sc: &SleepCancel, + _sc: &SleepCancel, table: &Mutex, ns: u64, ) -> Result { @@ -1723,7 +1791,7 @@ fn hl_sleep_poll_sockets( drop(tbl); if pollfds.is_empty() { - sc.wait(Duration::from_nanos(ns)); + _sc.wait(Duration::from_nanos(ns)); return Ok(json!({})); } @@ -1746,6 +1814,46 @@ fn hl_sleep_poll_sockets( Ok(json!({"socket_ready": ret > 0})) } +#[cfg(windows)] +fn hl_sleep_poll_sockets( + _sc: &SleepCancel, + table: &Mutex, + ns: u64, +) -> Result { + use serde_json::json; + use std::os::windows::io::AsRawSocket; + use windows_sys::Win32::Networking::WinSock::{ + WSAPoll, POLLERR, POLLRDNORM, POLLWRNORM, WSAPOLLFD, + }; + + let tbl = table.lock().unwrap(); + let mut pollfds: Vec = tbl + .sockets + .values() + .map(|hs| WSAPOLLFD { + fd: hs.socket.as_raw_socket() as usize, + events: (POLLRDNORM | POLLWRNORM | POLLERR) as i16, + revents: 0, + }) + .collect(); + drop(tbl); + + if pollfds.is_empty() { + _sc.wait(Duration::from_nanos(ns)); + return Ok(json!({})); + } + + let timeout_ms = ((ns / 1_000_000) as i32).clamp(1, 30_000); + let ret = unsafe { WSAPoll(pollfds.as_mut_ptr(), pollfds.len() as u32, timeout_ms) }; + + if ret < 0 { + let err = std::io::Error::last_os_error(); + return Err(anyhow!("hl_sleep WSAPoll failed: {err}")); + } + + Ok(json!({"socket_ready": ret > 0})) +} + // --------------------------------------------------------------------------- fn register_net_tools( @@ -1819,7 +1927,6 @@ fn register_net_tools( handle_net_getsockname(&t, &args) }); - #[cfg(unix)] { let t = table.clone(); tools.register("net_poll", move |args| handle_net_poll(&t, &args)); From 6ed274efccc46d3e82c54c1de156c4a54661a00b Mon Sep 17 00:00:00 2001 From: danbugs Date: Thu, 18 Jun 2026 21:43:14 +0000 Subject: [PATCH 11/15] fix(host): translate POSIX poll flags to Windows WSAPoll flags The guest sends POSIX poll flags (POLLIN=1, POLLOUT=4, POLLERR=8) but WSAPoll uses different values (POLLRDNORM=256, POLLWRNORM=16, POLLERR=1). Without translation, POSIX POLLIN was interpreted as Windows POLLERR, preventing connection readiness detection on Windows. Signed-off-by: danbugs --- host/src/lib.rs | 48 ++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 44 insertions(+), 4 deletions(-) diff --git a/host/src/lib.rs b/host/src/lib.rs index 334d136..8d2948e 100644 --- a/host/src/lib.rs +++ b/host/src/lib.rs @@ -1705,7 +1705,47 @@ fn handle_net_poll( ) -> Result { use serde_json::json; use std::os::windows::io::AsRawSocket; - use windows_sys::Win32::Networking::WinSock::{WSAPoll, POLLNVAL, WSAPOLLFD}; + use windows_sys::Win32::Networking::WinSock::{ + WSAPoll, POLLERR as W_POLLERR, POLLHUP as W_POLLHUP, POLLNVAL as W_POLLNVAL, POLLRDNORM, + POLLWRNORM, WSAPOLLFD, + }; + + const POSIX_POLLIN: i16 = 0x0001; + const POSIX_POLLOUT: i16 = 0x0004; + const POSIX_POLLERR: i16 = 0x0008; + const POSIX_POLLHUP: i16 = 0x0010; + const POSIX_POLLNVAL: i16 = 0x0020; + + fn posix_to_win(posix: i16) -> i16 { + let mut win: i16 = 0; + if posix & POSIX_POLLIN != 0 { + win |= POLLRDNORM; + } + if posix & POSIX_POLLOUT != 0 { + win |= POLLWRNORM; + } + win + } + + fn win_to_posix(win: i16) -> i16 { + let mut posix: i16 = 0; + if win & POLLRDNORM != 0 { + posix |= POSIX_POLLIN; + } + if win & POLLWRNORM != 0 { + posix |= POSIX_POLLOUT; + } + if win & W_POLLERR != 0 { + posix |= POSIX_POLLERR; + } + if win & W_POLLHUP != 0 { + posix |= POSIX_POLLHUP; + } + if win & W_POLLNVAL != 0 { + posix |= POSIX_POLLNVAL; + } + posix + } let fds_val = args .get("fds") @@ -1731,7 +1771,7 @@ fn handle_net_poll( if !(0..=i16::MAX as i64).contains(&raw_events) { return Err(anyhow!("net_poll: events {raw_events} out of i16 range")); } - let events = raw_events as i16; + let events = posix_to_win(raw_events as i16); if let Ok(sock) = tbl.get_socket(fd) { pollfds.push(WSAPOLLFD { fd: sock.as_raw_socket() as usize, @@ -1740,7 +1780,7 @@ fn handle_net_poll( }); guest_fds.push(fd); } else { - ready.push(json!({ "fd": fd, "revents": POLLNVAL as i64 })); + ready.push(json!({ "fd": fd, "revents": POSIX_POLLNVAL as i64 })); } } drop(tbl); @@ -1760,7 +1800,7 @@ fn handle_net_poll( if pfd.revents != 0 { ready.push(json!({ "fd": guest_fds[i], - "revents": pfd.revents as i64, + "revents": win_to_posix(pfd.revents) as i64, })); } } From bca116c79bfbf2541dda6a28a74a93f0e688ed6f Mon Sep 17 00:00:00 2001 From: danbugs Date: Thu, 18 Jun 2026 22:10:08 +0000 Subject: [PATCH 12/15] fix(host): remove POLLERR from WSAPoll events (output-only on Windows) Signed-off-by: danbugs --- host/src/lib.rs | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/host/src/lib.rs b/host/src/lib.rs index 8d2948e..bd1229b 100644 --- a/host/src/lib.rs +++ b/host/src/lib.rs @@ -1862,9 +1862,7 @@ fn hl_sleep_poll_sockets( ) -> Result { use serde_json::json; use std::os::windows::io::AsRawSocket; - use windows_sys::Win32::Networking::WinSock::{ - WSAPoll, POLLERR, POLLRDNORM, POLLWRNORM, WSAPOLLFD, - }; + use windows_sys::Win32::Networking::WinSock::{WSAPoll, POLLRDNORM, POLLWRNORM, WSAPOLLFD}; let tbl = table.lock().unwrap(); let mut pollfds: Vec = tbl @@ -1872,7 +1870,8 @@ fn hl_sleep_poll_sockets( .values() .map(|hs| WSAPOLLFD { fd: hs.socket.as_raw_socket() as usize, - events: (POLLRDNORM | POLLWRNORM | POLLERR) as i16, + // POLLERR is output-only on Windows; setting it in events causes WSAEINVAL + events: (POLLRDNORM | POLLWRNORM) as i16, revents: 0, }) .collect(); From 4d935977484f7de106178190efbe300e33aa5c4d Mon Sep 17 00:00:00 2001 From: danbugs Date: Thu, 18 Jun 2026 22:31:21 +0000 Subject: [PATCH 13/15] ci: add stderr capture for Windows HTTP test debugging Signed-off-by: danbugs --- .github/workflows/test-examples.yml | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/.github/workflows/test-examples.yml b/.github/workflows/test-examples.yml index e60b70c..4f064ee 100644 --- a/.github/workflows/test-examples.yml +++ b/.github/workflows/test-examples.yml @@ -644,6 +644,8 @@ jobs: - name: Run and check output shell: pwsh + env: + HL_DISPATCH_DEBUG: "1" run: | $kernel = (Resolve-Path "image/kernel").Path $cpio = (Resolve-Path "image/initrd.cpio").Path @@ -688,9 +690,10 @@ jobs: if ($memory -ne '') { $memArgs = @('-m', $memory) } + $stderrLog = Join-Path $env:RUNNER_TEMP 'hl-stderr.log' $proc = Start-Process -FilePath 'hyperlight-unikraft' ` -ArgumentList (@('-q') + $memArgs + @($kernel, '--initrd', $cpio) + $argList) ` - -PassThru -NoNewWindow + -PassThru -NoNewWindow -RedirectStandardError $stderrLog $ready = $false for ($i = 0; $i -lt 30; $i++) { try { @@ -703,6 +706,8 @@ jobs: } if (-not $ready) { Write-Host "FAIL: server did not become ready within 30s" + Write-Host "=== stderr (last 50 lines) ===" + Get-Content $stderrLog -Tail 50 -ErrorAction SilentlyContinue Stop-Process -Id $proc.Id -Force -ErrorAction SilentlyContinue exit 1 } From e1a740c158536f19efb024346721ba66c54c0ea0 Mon Sep 17 00:00:00 2001 From: danbugs Date: Thu, 18 Jun 2026 23:01:59 +0000 Subject: [PATCH 14/15] fix(ci): increase HTTP test timeouts and use 127.0.0.1 The CI HTTP server tests (go-http, dotnet-http) were failing because Invoke-WebRequest -TimeoutSec 2 sent RST before the server could process the connection through the hostsock sleep-rescan cycle. Use 127.0.0.1 to avoid IPv6 resolution, increase per-request timeout to 10s, and add an initial delay for the unikernel to boot. Signed-off-by: danbugs --- .github/workflows/test-examples.yml | 21 +++++++++++---------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/.github/workflows/test-examples.yml b/.github/workflows/test-examples.yml index 4f064ee..5e6b761 100644 --- a/.github/workflows/test-examples.yml +++ b/.github/workflows/test-examples.yml @@ -357,20 +357,21 @@ jobs: fi hyperlight-unikraft -q $mem_args "$kernel" --initrd "$cpio" ${{ matrix.args }} & server_pid=$! + sleep 3 ready=0 for i in $(seq 1 30); do - if curl -s --max-time 2 "http://localhost:${http_port}" >/dev/null 2>&1; then + if curl -s --max-time 10 "http://127.0.0.1:${http_port}" >/dev/null 2>&1; then ready=1 break fi sleep 1 done if [ "$ready" -ne 1 ]; then - echo "FAIL: server did not become ready within 30s" + echo "FAIL: server did not become ready within timeout" kill "$server_pid" 2>/dev/null || true exit 1 fi - response=$(curl -s --max-time 5 "http://localhost:${http_port}") + response=$(curl -s --max-time 10 "http://127.0.0.1:${http_port}") kill "$server_pid" 2>/dev/null || true wait "$server_pid" 2>/dev/null || true echo "=== HTTP response ===" @@ -644,8 +645,6 @@ jobs: - name: Run and check output shell: pwsh - env: - HL_DISPATCH_DEBUG: "1" run: | $kernel = (Resolve-Path "image/kernel").Path $cpio = (Resolve-Path "image/initrd.cpio").Path @@ -694,10 +693,12 @@ jobs: $proc = Start-Process -FilePath 'hyperlight-unikraft' ` -ArgumentList (@('-q') + $memArgs + @($kernel, '--initrd', $cpio) + $argList) ` -PassThru -NoNewWindow -RedirectStandardError $stderrLog + # Give the server time to boot the unikernel and start listening + Start-Sleep -Seconds 5 $ready = $false for ($i = 0; $i -lt 30; $i++) { try { - $null = Invoke-WebRequest -Uri "http://localhost:${httpPort}" -UseBasicParsing -TimeoutSec 2 -ErrorAction Stop + $null = Invoke-WebRequest -Uri "http://127.0.0.1:${httpPort}" -UseBasicParsing -TimeoutSec 10 -ErrorAction Stop $ready = $true break } catch { @@ -705,13 +706,13 @@ jobs: } } if (-not $ready) { - Write-Host "FAIL: server did not become ready within 30s" - Write-Host "=== stderr (last 50 lines) ===" - Get-Content $stderrLog -Tail 50 -ErrorAction SilentlyContinue + Write-Host "FAIL: server did not become ready within timeout" + Write-Host "=== stderr (last 200 lines) ===" + Get-Content $stderrLog -Tail 200 -ErrorAction SilentlyContinue Stop-Process -Id $proc.Id -Force -ErrorAction SilentlyContinue exit 1 } - $resp = (Invoke-WebRequest -Uri "http://localhost:${httpPort}" -UseBasicParsing -TimeoutSec 5).Content + $resp = (Invoke-WebRequest -Uri "http://127.0.0.1:${httpPort}" -UseBasicParsing -TimeoutSec 10).Content Stop-Process -Id $proc.Id -Force -ErrorAction SilentlyContinue Write-Host "=== HTTP response ===" Write-Host $resp From f8d20d30d7e8863e9715e054235c321ba119492c Mon Sep 17 00:00:00 2001 From: danbugs Date: Thu, 18 Jun 2026 23:25:06 +0000 Subject: [PATCH 15/15] fix(ci): decode byte array response from Invoke-WebRequest Signed-off-by: danbugs --- .github/workflows/test-examples.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/test-examples.yml b/.github/workflows/test-examples.yml index 5e6b761..b5a743a 100644 --- a/.github/workflows/test-examples.yml +++ b/.github/workflows/test-examples.yml @@ -712,7 +712,8 @@ jobs: Stop-Process -Id $proc.Id -Force -ErrorAction SilentlyContinue exit 1 } - $resp = (Invoke-WebRequest -Uri "http://127.0.0.1:${httpPort}" -UseBasicParsing -TimeoutSec 10).Content + $raw = (Invoke-WebRequest -Uri "http://127.0.0.1:${httpPort}" -UseBasicParsing -TimeoutSec 10).Content + if ($raw -is [byte[]]) { $resp = [System.Text.Encoding]::UTF8.GetString($raw) } else { $resp = $raw } Stop-Process -Id $proc.Id -Force -ErrorAction SilentlyContinue Write-Host "=== HTTP response ===" Write-Host $resp