diff --git a/images/README.md b/images/README.md index 0a2462ac..8b2a7566 100644 --- a/images/README.md +++ b/images/README.md @@ -6,6 +6,44 @@ - `cd kernel-images/images/chromium-headful` - Build and run the docker image with `./build-docker.sh && ENABLE_WEBRTC=true ./run-docker.sh` - Open http://localhost:8080/ in your browser + +### Experimental native Wayland Chromium + +Set `KERNEL_WAYLAND_PURE=true` when running the headful image to launch +Chromium with the native Wayland Ozone backend on a headless Weston output. +This mode does not start Xorg, Mutter, Neko, or the X11 capture/input path; +use CDP for browser interaction and screenshots while benchmarking it. + +```sh +KERNEL_WAYLAND_PURE=true ./run-docker.sh +``` + +`KERNEL_WAYLAND_NESTED=true` remains available for comparing native Wayland +Chromium while retaining the existing X11 capture and input path. + +Set `ENABLE_WEBRTC=true ENABLE_WAYLAND_WEBRTC=true` in pure mode to run Neko +against the Wayland capture and `/dev/uinput` input backends. This requires +the Neko build with Wayland support and a wlroots compositor exposing +screencopy and output-management. + +#### Browser-only benchmark + +Ten fresh-container trials per mode, using the same Chromium flags and a +1920x1080 configuration. The screenshot metric is CDP +`Page.captureScreenshot`; it does not measure product capture or live view. + +| metric | X11 | pure Wayland | +| --- | ---: | ---: | +| wrapper readiness (mean) | 2.84s | 2.19s | +| Chromium startup (mean) | 602ms | 565ms | +| CDP evaluation p50 (mean) | 1.06ms | 0.96ms | +| CDP screenshot p50 (mean) | 257ms | 116ms | +| container memory (mean) | 709MiB | 414MiB | + +The memory reduction primarily comes from not starting Xorg and Mutter. Pure +mode remains experimental and does not provide the X11 screenshot, input, +recording, or live-view paths. + - Now new endpoint should be available for tests example curl command: ```sh curl -X POST localhost:444/computer/cursor \ diff --git a/images/chromium-headful/Dockerfile b/images/chromium-headful/Dockerfile index a521ba36..768c7787 100644 --- a/images/chromium-headful/Dockerfile +++ b/images/chromium-headful/Dockerfile @@ -158,6 +158,27 @@ RUN --mount=type=cache,target=/tmp/cache/ffmpeg,sharing=locked,id=$CACHEIDPREFIX rm -rf /tmp/ffmpeg* EOT +FROM docker.io/ubuntu:22.04 AS wayland-recorder-builder + +RUN apt-get update && apt-get install --no-install-recommends -y \ + build-essential git meson ninja-build pkg-config ca-certificates \ + libwayland-dev wayland-protocols libgbm-dev libdrm-dev \ + libavutil-dev libavcodec-dev libavdevice-dev libavfilter-dev \ + libavformat-dev libswresample-dev libswscale-dev \ + && rm -rf /var/lib/apt/lists/* +RUN git clone --depth 1 --branch v0.6.0 https://github.com/ammen99/wf-recorder.git /tmp/wf-recorder \ + && meson setup /tmp/wf-recorder/build /tmp/wf-recorder \ + -Dpulse=disabled -Dpipewire=disabled \ + -Ddefault_codec=rawvideo \ + && meson compile -C /tmp/wf-recorder/build \ + && meson install -C /tmp/wf-recorder/build \ + && rm -rf /tmp/wf-recorder +RUN git clone --depth 1 --branch v0.5.0 https://gitlab.freedesktop.org/emersion/wlr-randr.git /tmp/wlr-randr \ + && meson setup /tmp/wlr-randr/build /tmp/wlr-randr \ + && meson compile -C /tmp/wlr-randr/build \ + && meson install -C /tmp/wlr-randr/build \ + && rm -rf /tmp/wlr-randr + FROM ghcr.io/kernel/neko/base:3.0.8-v1.6.0 AS neko FROM node:22-bullseye-slim AS node-22 FROM docker.io/ubuntu:22.04 @@ -186,6 +207,7 @@ RUN --mount=type=cache,target=/var/cache/apt,sharing=locked,id=$CACHEIDPREFIX-ap imagemagick \ sudo \ mutter \ + weston \ curl \ # Network tools net-tools \ @@ -197,9 +219,22 @@ RUN --mount=type=cache,target=/var/cache/apt,sharing=locked,id=$CACHEIDPREFIX-ap xdg-utils \ libvulkan1 \ fontconfig \ - unzip && \ + unzip \ + libavcodec58 \ + libavdevice58 \ + libavfilter7 \ + libavformat58 \ + libavutil56 \ + libswresample3 \ + libswscale5 \ + libgbm1 \ + libwayland-client0 \ + libdrm2 && \ apt-get clean && rm -rf /var/lib/apt/lists/* +COPY --from=wayland-recorder-builder /usr/local/bin/wf-recorder /usr/local/bin/wf-recorder +COPY --from=wayland-recorder-builder /usr/local/bin/wlr-randr /usr/local/bin/wlr-randr + # Install fonts to match a realistic Ubuntu 22.04 desktop fingerprint. # A minimal container has only 3 fonts, which is a strong fingerprinting signal # used by reCAPTCHA Enterprise and other bot detection vendors. diff --git a/images/chromium-headful/run-docker.sh b/images/chromium-headful/run-docker.sh index 9e5af0ae..e1d6751e 100755 --- a/images/chromium-headful/run-docker.sh +++ b/images/chromium-headful/run-docker.sh @@ -68,6 +68,13 @@ RUN_ARGS=( --mount type=bind,src="$FLAGS_FILE",dst=/chromium/flags ) +if [[ "${KERNEL_WAYLAND_NESTED:-}" == "true" ]]; then + RUN_ARGS+=( -e KERNEL_WAYLAND_NESTED=true ) +fi +if [[ "${KERNEL_WAYLAND_PURE:-}" == "true" ]]; then + RUN_ARGS+=( -e KERNEL_WAYLAND_PURE=true ) +fi + if [[ -n "${PLAYWRIGHT_ENGINE:-}" ]]; then RUN_ARGS+=( -e PLAYWRIGHT_ENGINE="$PLAYWRIGHT_ENGINE" ) fi @@ -88,6 +95,9 @@ if [[ "${ENABLE_WEBRTC:-}" == "true" ]]; then echo "Running container with WebRTC" RUN_ARGS+=( -p 8080:8080 ) RUN_ARGS+=( -e ENABLE_WEBRTC=true ) + if [[ "${ENABLE_WAYLAND_WEBRTC:-}" == "true" ]]; then + RUN_ARGS+=( -e ENABLE_WAYLAND_WEBRTC=true ) + fi if [[ -n "${NEKO_ICESERVERS:-}" ]]; then RUN_ARGS+=( -e NEKO_ICESERVERS="$NEKO_ICESERVERS" ) else diff --git a/images/chromium-headful/supervisor/services/neko-wayland.conf b/images/chromium-headful/supervisor/services/neko-wayland.conf new file mode 100644 index 00000000..afa26c07 --- /dev/null +++ b/images/chromium-headful/supervisor/services/neko-wayland.conf @@ -0,0 +1,8 @@ +[program:neko-wayland] +command=/usr/bin/neko serve --server.static /var/www --server.bind 0.0.0.0:8080 +autostart=false +autorestart=true +startsecs=0 +environment=PULSE_SERVER="unix:/tmp/pulse/native",NEKO_DESKTOP_WAYLAND="true",NEKO_CAPTURE_VIDEO_WAYLAND="true",NEKO_CAPTURE_VIDEO_WAYLAND_RECORDER="wf-recorder",NEKO_DESKTOP_WAYLAND_RESIZE_COMMAND="wlr-randr",NEKO_DESKTOP_WAYLAND_OUTPUT="HEADLESS-1" +stdout_logfile=/var/log/supervisord/neko-wayland +redirect_stderr=true diff --git a/images/chromium-headful/supervisor/services/weston-pure.conf b/images/chromium-headful/supervisor/services/weston-pure.conf new file mode 100644 index 00000000..41b3d23e --- /dev/null +++ b/images/chromium-headful/supervisor/services/weston-pure.conf @@ -0,0 +1,9 @@ +[program:weston-pure] +command=/bin/bash -lc 'exec /usr/bin/weston --backend=headless-backend.so --socket=wayland-1 --width="${WIDTH:-1920}" --height="${HEIGHT:-1080}" --use-pixman --idle-time=0 --shell=kiosk-shell.so' +autostart=false +autorestart=true +startsecs=0 +user=kernel +environment=HOME="/home/kernel",XDG_RUNTIME_DIR="/tmp/runtime-kernel",WAYLAND_DISPLAY="wayland-1" +stdout_logfile=/var/log/supervisord/weston-pure +redirect_stderr=true diff --git a/images/chromium-headful/supervisor/services/weston.conf b/images/chromium-headful/supervisor/services/weston.conf new file mode 100644 index 00000000..c7823606 --- /dev/null +++ b/images/chromium-headful/supervisor/services/weston.conf @@ -0,0 +1,9 @@ +[program:weston] +command=/bin/bash -lc 'exec /usr/bin/weston --backend=x11-backend.so --socket=wayland-1 --width="${WIDTH:-1920}" --height="${HEIGHT:-1080}" --use-pixman --idle-time=0' +autostart=false +autorestart=true +startsecs=0 +user=kernel +environment=DISPLAY=":1",HOME="/home/kernel",XDG_RUNTIME_DIR="/tmp/runtime-kernel",WAYLAND_DISPLAY="wayland-1" +stdout_logfile=/var/log/supervisord/weston +redirect_stderr=true diff --git a/server/cmd/chromium-launcher/main.go b/server/cmd/chromium-launcher/main.go index 7bce8506..407bf44b 100644 --- a/server/cmd/chromium-launcher/main.go +++ b/server/cmd/chromium-launcher/main.go @@ -20,6 +20,9 @@ import ( // connect to the same socket and play into the same sink the daemon sets up. // Keep them in sync with start-pulseaudio.sh. const ( + waylandRuntimeDir = "/tmp/runtime-kernel" + waylandDisplay = "wayland-1" + // pulseServer is the PulseAudio socket the recorder and chromium share. pulseServer = "unix:/tmp/pulse/native" // pulseSink is the null sink chromium plays into; the recorder captures @@ -56,21 +59,28 @@ func main() { // Wait for devtools port to be available (handles SIGKILL socket cleanup delay) waitForPort(internalPort, 5*time.Second) - // Wait for the X server. The wrapper starts chromium in parallel with - // xorg/xvfb, so the display socket may not be ready yet — without this - // gate chromium would fail on connect and supervisord would restart us. - if d := x11.WaitForDisplay(":1", 20*time.Second); d >= 20*time.Second { - fmt.Fprintf(os.Stderr, "warning: X display :1 not responsive after %s\n", d) - } - - // Headful: wait for mutter to register before exec'ing chromium. If - // chromium maps its window with no WM present, the CSD hint it sends has - // no listener; mutter starts later, reparents the existing window, and - // applies default SSD — i.e., the titlebar with the close X. Headless - // has no WM, so skip. - if !*headless { - if d := x11.WaitForMutter(20 * time.Second); d >= 20*time.Second { - fmt.Fprintf(os.Stderr, "warning: mutter not registered after %s\n", d) + nestedWayland := !*headless && strings.EqualFold(strings.TrimSpace(os.Getenv("KERNEL_WAYLAND_NESTED")), "true") + pureWayland := !*headless && strings.EqualFold(strings.TrimSpace(os.Getenv("KERNEL_WAYLAND_PURE")), "true") + waylandEnabled := nestedWayland || pureWayland + if waylandEnabled { + waitForWayland(20 * time.Second) + } else { + // Wait for the X server. The wrapper starts chromium in parallel with + // xorg/xvfb, so the display socket may not be ready yet — without this + // gate chromium would fail on connect and supervisord would restart us. + if d := x11.WaitForDisplay(":1", 20*time.Second); d >= 20*time.Second { + fmt.Fprintf(os.Stderr, "warning: X display :1 not responsive after %s\n", d) + } + + // Headful: wait for mutter to register before exec'ing chromium. If + // chromium maps its window with no WM present, the CSD hint it sends has + // no listener; mutter starts later, reparents the existing window, and + // applies default SSD — i.e., the titlebar with the close X. Headless + // has no WM, so skip. + if !*headless { + if d := x11.WaitForMutter(20 * time.Second); d >= 20*time.Second { + fmt.Fprintf(os.Stderr, "warning: mutter not registered after %s\n", d) + } } } @@ -82,6 +92,9 @@ func main() { } final := chromiumflags.MergeFlagsWithRuntimeTokens(baseFlags, runtimeTokens) final = withDefaultPrivateNetworkBypass(final) + if waylandEnabled { + final = withWaylandPlatform(final) + } // Diagnostics for parity with previous scripts fmt.Printf("BASE_FLAGS: %s\n", baseFlags) @@ -107,12 +120,20 @@ func main() { // recorder's sink; the root path below relies on this inherited env, while the // non-root path re-asserts them in its runuser env allowlist. env := os.Environ() + if !pureWayland { + env = append(env, "DISPLAY=:1") + } env = append(env, - "DISPLAY=:1", "DBUS_SESSION_BUS_ADDRESS=unix:path=/run/dbus/system_bus_socket", "PULSE_SERVER="+pulseServer, "PULSE_SINK="+pulseSink, ) + if waylandEnabled { + env = append(env, + "XDG_RUNTIME_DIR="+waylandRuntimeDir, + "WAYLAND_DISPLAY="+waylandDisplay, + ) + } if runAsRoot { // Replace current process with Chromium @@ -144,15 +165,23 @@ func main() { // GetDefaultOutputDeviceID), which is the sink the recorder captures. inner := []string{ "env", - "DISPLAY=:1", "DBUS_SESSION_BUS_ADDRESS=unix:path=/run/dbus/system_bus_socket", "PULSE_SERVER=" + pulseServer, "PULSE_SINK=" + pulseSink, "XDG_CONFIG_HOME=/home/kernel/.config", "XDG_CACHE_HOME=/home/kernel/.cache", "HOME=/home/kernel", - *chromiumPath, } + if !pureWayland { + inner = append(inner, "DISPLAY=:1") + } + if waylandEnabled { + inner = append(inner, + "XDG_RUNTIME_DIR="+waylandRuntimeDir, + "WAYLAND_DISPLAY="+waylandDisplay, + ) + } + inner = append(inner, *chromiumPath) inner = append(inner, chromiumArgs...) argv := append([]string{filepath.Base(runuserPath), "-u", "kernel", "--"}, inner...) if err := syscall.Exec(runuserPath, argv, env); err != nil { @@ -170,6 +199,37 @@ func withDefaultPrivateNetworkBypass(flags []string) []string { return append(flags, defaultPrivateNetworkBypassFlag) } +func withWaylandPlatform(flags []string) []string { + result := append([]string(nil), flags...) + for i, flag := range result { + if strings.HasPrefix(flag, "--ozone-platform=") { + result[i] = "--ozone-platform=wayland" + return result + } + if flag == "--ozone-platform" && i+1 < len(result) { + result[i+1] = "wayland" + return result + } + } + return append(result, "--ozone-platform=wayland") +} + +func waitForWayland(timeout time.Duration) { + runtimeDir := os.Getenv("XDG_RUNTIME_DIR") + if runtimeDir == "" { + runtimeDir = waylandRuntimeDir + } + socket := filepath.Join(runtimeDir, waylandDisplay) + deadline := time.Now().Add(timeout) + for time.Now().Before(deadline) { + if fi, err := os.Stat(socket); err == nil && fi.Mode()&os.ModeSocket != 0 { + return + } + time.Sleep(20 * time.Millisecond) + } + fmt.Fprintf(os.Stderr, "warning: Wayland display %s not ready after %s\n", socket, timeout) +} + // execLookPath helps satisfy syscall.Exec's requirement to pass an absolute path. func execLookPath(file string) (string, error) { if strings.ContainsRune(file, os.PathSeparator) { diff --git a/server/cmd/chromium-launcher/main_test.go b/server/cmd/chromium-launcher/main_test.go index 6e6d11b0..60859409 100644 --- a/server/cmd/chromium-launcher/main_test.go +++ b/server/cmd/chromium-launcher/main_test.go @@ -67,6 +67,39 @@ func TestDefaultPrivateNetworkBypassPreservesRuntimePrecedence(t *testing.T) { } } +func TestWithWaylandPlatform(t *testing.T) { + tests := []struct { + name string + flags []string + want []string + }{ + { + name: "appends platform", + flags: []string{"--kiosk"}, + want: []string{"--kiosk", "--ozone-platform=wayland"}, + }, + { + name: "replaces equals form", + flags: []string{"--ozone-platform=x11"}, + want: []string{"--ozone-platform=wayland"}, + }, + { + name: "replaces separate form", + flags: []string{"--ozone-platform", "x11"}, + want: []string{"--ozone-platform", "wayland"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := withWaylandPlatform(tt.flags) + if !reflect.DeepEqual(got, tt.want) { + t.Fatalf("withWaylandPlatform() mismatch:\n got: %#v\nwant: %#v", got, tt.want) + } + }) + } +} + func TestExecLookPath(t *testing.T) { dir := t.TempDir() bin := filepath.Join(dir, "mybin") diff --git a/server/cmd/wrapper/main.go b/server/cmd/wrapper/main.go index 37b70e3a..fafbb6fd 100644 --- a/server/cmd/wrapper/main.go +++ b/server/cmd/wrapper/main.go @@ -60,6 +60,9 @@ func profileName(p profile) string { func main() { t0 := time.Now() prof := detectProfile() + waylandNested := prof == profileHeadful && nestedWaylandEnabled() + waylandPure := prof == profileHeadful && pureWaylandEnabled() + waylandEnabled := waylandNested || waylandPure stzManaged := scaleToZeroManaged() logf("starting wrapper (profile=%s stz=%s)", profileName(prof), stzMode(stzManaged)) forkIdentityWait, err := forkIdentityWaitEnabled() @@ -119,7 +122,12 @@ func main() { startLogAggregator() // Default env that downstream services expect. - _ = os.Setenv("DISPLAY", defaultDisplay) + if !waylandPure { + _ = os.Setenv("DISPLAY", defaultDisplay) + } + if waylandEnabled { + configureWaylandEnv() + } if os.Getenv("INTERNAL_PORT") == "" { _ = os.Setenv("INTERNAL_PORT", defaultIntPort) } @@ -131,9 +139,12 @@ func main() { // which would otherwise spam autolaunch errors). _ = os.Setenv("DBUS_SESSION_BUS_ADDRESS", "unix:path="+dbusSocket) - // Stale X locks from prior runs. + // Stale display state from prior runs. _ = os.Remove("/tmp/.X1-lock") _ = os.Remove("/tmp/.X11-unix/X1") + if waylandEnabled { + _ = os.Remove(waylandRuntimeDir + "/" + waylandDisplay) + } // supervisord — start in nodaemon mode so we own its lifecycle. // Without -n it forks and the parent exits with code 0, which would @@ -161,22 +172,20 @@ func main() { // any per-instance identity envs — it just needs the envoy CA cert // (baked into the image at build time, see shared/envoy/bake-certs.sh) // so it trusts the forward proxy on first start with no runtime cert - // work to wait on. chromium-launcher internally waits for the X server - // and (headful) for mutter before exec'ing chromium, so we start it in - // parallel with the X server to overlap chromium-launcher's preamble - // with display startup. chromedriver listens on 9225 immediately and - // only attaches to chromium on session creation, so it can come up - // alongside everything. mutter has no internal X-wait, so it's started - // as soon as the X server is confirmed up — chromium-launcher gates on - // it so chrome can negotiate CSD with the WM before mapping its window - // (without it, mutter reparents the existing window with default SSD - // and a titlebar appears). neko reads the active display mode at start, - // so it's deferred until after the dbus wait on the WebRTC path. + // work to wait on. chromium-launcher waits for the configured display + // before exec'ing chromium. In pure Wayland mode, Weston owns a headless + // output and no X server is started. In nested mode, Xorg remains the outer + // display so the existing capture and input stack can still be used. + // chromedriver listens on 9225 immediately and only attaches to Chromium + // on session creation, so it can come up alongside the display services. xServer := "xorg" if prof == profileHeadless { xServer = "xvfb" } webrtc := prof == profileHeadful && os.Getenv("ENABLE_WEBRTC") == "true" + if waylandPure { + webrtc = webrtc && os.Getenv("ENABLE_WAYLAND_WEBRTC") == "true" + } // Pre-touch chromium's supervisord log so kernel-images-api's `tail -f` // doesn't bail out and enter its 250ms retry backoff when started in @@ -184,10 +193,25 @@ func main() { _ = os.WriteFile(filepath.Join(supervisordLogD, "chromium"), nil, 0o644) browserStart := time.Now() - startAll(xServer, "dbus", "chromedriver", "pulseaudio") - waitForX(defaultDisplay, 20*time.Second) + services := []string{"dbus", "chromedriver", "pulseaudio"} + if !waylandPure { + services = append([]string{xServer}, services...) + } + startAll(services...) + if !waylandPure { + waitForX(defaultDisplay, 20*time.Second) + } if prof == profileHeadful { - startAll("mutter") + switch { + case waylandPure: + startAll("weston-pure") + waitForWayland(20 * time.Second) + case waylandNested: + startAll("weston") + waitForWayland(20 * time.Second) + default: + startAll("mutter") + } } waitForSocket(pulseSocket, 10*time.Second) startAll("chromium") @@ -208,7 +232,11 @@ func main() { } waitForSocket(dbusSocket, 10*time.Second) if prof == profileHeadful && webrtc { - startAll("neko") + if waylandPure { + startAll("neko-wayland") + } else { + startAll("neko") + } } if forkIdentityWait { waitForHTTPProbe("public cdp", "http://127.0.0.1:"+os.Getenv("CHROME_PORT")+"/json/version", 30*time.Second) diff --git a/server/cmd/wrapper/system.go b/server/cmd/wrapper/system.go index d030f96f..a13efbb0 100644 --- a/server/cmd/wrapper/system.go +++ b/server/cmd/wrapper/system.go @@ -34,12 +34,15 @@ func stzMode(managed bool) string { func prepareUserDirs(asRoot bool) { if asRoot { - for _, d := range []string{"/tmp", "/var/log", supervisordLogD, "/home/kernel", "/home/kernel/user-data"} { + for _, d := range []string{"/tmp", "/var/log", supervisordLogD, waylandRuntimeDir, "/home/kernel", "/home/kernel/user-data"} { _ = os.MkdirAll(d, 0o755) } + _ = exec.Command("chown", "kernel:kernel", waylandRuntimeDir).Run() + _ = os.Chmod(waylandRuntimeDir, 0o700) return } dirs := []string{ + waylandRuntimeDir, "/home/kernel/user-data", "/home/kernel/.config/chromium", "/home/kernel/.pki/nssdb", @@ -51,6 +54,8 @@ func prepareUserDirs(asRoot bool) { for _, d := range dirs { _ = os.MkdirAll(d, 0o755) } + _ = exec.Command("chown", "kernel:kernel", waylandRuntimeDir).Run() + _ = os.Chmod(waylandRuntimeDir, 0o700) _ = exec.Command("chown", "-R", "kernel:kernel", "/home/kernel", "/home/kernel/user-data", "/home/kernel/.config", "/home/kernel/.pki", "/home/kernel/.cache").Run() diff --git a/server/cmd/wrapper/wayland.go b/server/cmd/wrapper/wayland.go new file mode 100644 index 00000000..892bf622 --- /dev/null +++ b/server/cmd/wrapper/wayland.go @@ -0,0 +1,29 @@ +package main + +import ( + "os" + "strings" + "time" +) + +const ( + waylandRuntimeDir = "/tmp/runtime-kernel" + waylandDisplay = "wayland-1" +) + +func nestedWaylandEnabled() bool { + return strings.EqualFold(strings.TrimSpace(os.Getenv("KERNEL_WAYLAND_NESTED")), "true") +} + +func pureWaylandEnabled() bool { + return strings.EqualFold(strings.TrimSpace(os.Getenv("KERNEL_WAYLAND_PURE")), "true") +} + +func configureWaylandEnv() { + _ = os.Setenv("XDG_RUNTIME_DIR", waylandRuntimeDir) + _ = os.Setenv("WAYLAND_DISPLAY", waylandDisplay) +} + +func waitForWayland(timeout time.Duration) { + waitForSocket(waylandRuntimeDir+"/"+waylandDisplay, timeout) +}