#!/usr/bin/env bash set -Eeuo pipefail usage() { cat <<'EOF' Usage: ci/qemu_linux_tests.sh [options] [-- V arguments] Sync the current checkout to an existing Debian ARM64 QEMU guest, rebuild V, and run the Linux test suite. With no V arguments, the script runs `test-all`. Options: --no-sync Use the checkout already present in the guest. --provision Install the packages used by the Linux compiler tests. -h, --help Show this help. Environment overrides: V_QEMU_VM_DIR VM state directory (default: ~/.local/share/v3-qemu-debian13) V_QEMU_SSH_PORT Forwarded SSH port (default: 2222) V_QEMU_GUEST SSH target (default: v@127.0.0.1) V_QEMU_GUEST_REPO Guest checkout (default: /home/v/v3); absolute paths using only letters, digits, '.', '_', '-', and '/' are accepted V_QEMU_CPUS Virtual CPUs (default: 4) V_QEMU_MEMORY_MB Guest memory in MiB (default: 16384) V_QEMU_JOBS V test jobs (default: 1) V_QEMU_CC Guest compiler exposed as `cc` to nested V3 processes (default: clang) V_QEMU_VFLAGS Flags inherited by V subprocesses (default: -cc clang -no-memory-limit) V_QEMU_NO_FALLBACK Set V_MACOS_V3_NO_FALLBACK (default: 1) V_QEMU_TMPDIR Guest disk-backed V temporary root (default: /var/tmp/v-qemu-tests) V_QEMU_STOP_AFTER Power off the guest after the run when set to 1 V_QEMU_FIRMWARE AArch64 UEFI firmware path V_QEMU_RSYNC Host rsync executable (only needed when syncing) Examples: ci/qemu_linux_tests.sh ci/qemu_linux_tests.sh -- -cc clang test vlib/v/ V_QEMU_CC=gcc V_QEMU_VFLAGS='-cc gcc -no-memory-limit' \ ci/qemu_linux_tests.sh -- -cc gcc test vlib/v/ V_QEMU_NO_FALLBACK=0 ci/qemu_linux_tests.sh -- test-all EOF } provision=0 sync_checkout=1 while (($# > 0)); do case "$1" in --no-sync) sync_checkout=0 shift ;; --provision) provision=1 shift ;; -h | --help) usage exit 0 ;; --) shift break ;; *) break ;; esac done repo_root=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd) vm_dir=${V_QEMU_VM_DIR:-"${HOME}/.local/share/v3-qemu-debian13"} ssh_port=${V_QEMU_SSH_PORT:-2222} guest=${V_QEMU_GUEST:-v@127.0.0.1} guest_repo=${V_QEMU_GUEST_REPO:-/home/v/v3} cpus=${V_QEMU_CPUS:-4} memory_mb=${V_QEMU_MEMORY_MB:-16384} # A V3 compiler-tree test can exceed 6 GiB RSS on ARM64. One unbounded worker is # reliable in the default 16 GiB guest; callers with larger guests can opt into # more parallelism explicitly. jobs=${V_QEMU_JOBS:-1} nested_cc=${V_QEMU_CC:-clang} vflags=${V_QEMU_VFLAGS:--cc clang -no-memory-limit} no_fallback=${V_QEMU_NO_FALLBACK:-1} guest_tmp_root=${V_QEMU_TMPDIR:-/var/tmp/v-qemu-tests} stop_after=${V_QEMU_STOP_AFTER:-0} if [[ ! "$guest_repo" =~ ^/[A-Za-z0-9._/-]+$ ]]; then echo "V_QEMU_GUEST_REPO must be an absolute path containing only letters, digits, '.', '_', '-', and '/': ${guest_repo}" >&2 exit 1 fi case "/${guest_repo#/}/" in *"/../"*) echo "V_QEMU_GUEST_REPO cannot contain '..' path segments: ${guest_repo}" >&2 exit 1 ;; esac if [[ "$guest_tmp_root" != /* ]]; then echo "V_QEMU_TMPDIR must be an absolute guest path: ${guest_tmp_root}" >&2 exit 1 fi qemu_bin=${V_QEMU_BIN:-$(command -v qemu-system-aarch64 || true)} if [[ -z "$qemu_bin" ]]; then echo 'qemu-system-aarch64 is required (on macOS: brew install qemu).' >&2 exit 1 fi rsync_bin= if ((sync_checkout)); then rsync_candidate=${V_QEMU_RSYNC:-rsync} rsync_bin=$(command -v "$rsync_candidate" || true) if [[ -z "$rsync_bin" ]]; then echo 'rsync is required unless --no-sync is used.' >&2 exit 1 fi fi key_file="${vm_dir}/id_ed25519" known_hosts_file="${vm_dir}/known_hosts" pid_file="${vm_dir}/qemu.pid" disk_file="${vm_dir}/disk.qcow2" vars_file="${vm_dir}/edk2-vars.fd" seed_file="${vm_dir}/seed.iso" serial_log="${vm_dir}/serial.log" qemu_log="${vm_dir}/qemu.log" for required_file in "$key_file" "$known_hosts_file" "$disk_file" "$vars_file" "$seed_file"; do if [[ ! -f "$required_file" ]]; then echo "Missing VM asset: ${required_file}" >&2 echo 'Create the Debian cloud guest first or set V_QEMU_VM_DIR.' >&2 exit 1 fi done firmware=${V_QEMU_FIRMWARE:-} if [[ -z "$firmware" ]]; then qemu_prefix=$(CDPATH= cd -- "$(dirname -- "$qemu_bin")/.." && pwd) firmware="${qemu_prefix}/share/qemu/edk2-aarch64-code.fd" fi if [[ ! -f "$firmware" ]]; then echo "Missing AArch64 UEFI firmware: ${firmware}" >&2 echo 'Set V_QEMU_FIRMWARE to the edk2 AArch64 code image.' >&2 exit 1 fi ssh_options=( -i "$key_file" -p "$ssh_port" -o "UserKnownHostsFile=\"${known_hosts_file}\"" -o StrictHostKeyChecking=yes -o ConnectTimeout=5 ) if [[ ! "$ssh_port" =~ ^[0-9]+$ ]]; then echo "Invalid SSH port: ${ssh_port}" >&2 exit 1 fi if [[ "$key_file" == *"'"* || "$known_hosts_file" == *"'"* || "$known_hosts_file" == *'"'* ]]; then echo 'The QEMU SSH key and known-hosts paths cannot contain quote characters.' >&2 exit 1 fi # openrsync understands quoted arguments but treats Bash `%q` backslashes # literally. Preserve spaces with single-quoted arguments, and retain inner # quotes around the known-hosts value for OpenSSH's own option parser. printf -v rsync_ssh_command \ "ssh -i '%s' -p '%s' '-oUserKnownHostsFile=\"%s\"' -o StrictHostKeyChecking=yes" \ "$key_file" "$ssh_port" "$known_hosts_file" vm_is_running() { [[ -s "$pid_file" ]] && kill -0 "$(<"$pid_file")" 2>/dev/null } start_vm() { if vm_is_running; then return fi if [[ -f "$pid_file" ]]; then rm -f -- "$pid_file" fi local accelerator=tcg local cpu=max if [[ $(uname -s) == Darwin ]]; then accelerator=hvf cpu=host fi "$qemu_bin" \ -machine "virt,accel=${accelerator},highmem=on" \ -cpu "$cpu" \ -smp "$cpus" \ -m "$memory_mb" \ -drive "if=pflash,format=raw,readonly=on,file=${firmware}" \ -drive "if=pflash,format=raw,file=${vars_file}" \ -drive "if=virtio,file=${disk_file},format=qcow2,discard=unmap,detect-zeroes=unmap" \ -drive "if=virtio,file=${seed_file},format=raw,readonly=on" \ -device virtio-net-pci,netdev=net0 \ -netdev "user,id=net0,hostfwd=tcp:127.0.0.1:${ssh_port}-:22" \ -display none \ -serial "file:${serial_log}" \ -monitor none \ -daemonize \ -pidfile "$pid_file" \ -D "$qemu_log" } wait_for_ssh() { for _ in {1..120}; do if ssh "${ssh_options[@]}" "$guest" true 2>/dev/null; then return fi sleep 1 done echo "Guest SSH did not become ready on port ${ssh_port}." >&2 exit 1 } power_off() { if [[ "$stop_after" == 1 ]] && vm_is_running; then ssh "${ssh_options[@]}" "$guest" 'sudo poweroff' >/dev/null 2>&1 || true fi } trap power_off EXIT start_vm wait_for_ssh if ((provision)); then # A fresh Debian guest does not necessarily have the remote half of rsync yet. # Install it before attempting to transfer the checkout. ssh "${ssh_options[@]}" "$guest" "sudo apt-get update && sudo DEBIAN_FRONTEND=noninteractive apt-get install -y build-essential clang git lld rsync pkg-config libssl-dev sqlite3 libsqlite3-dev default-libmysqlclient-dev libpq-dev postgresql valgrind libfreetype6-dev libxi-dev libxcursor-dev libgl-dev libxrandr-dev libasound2-dev libegl-dev libwayland-dev libxkbcommon-dev libwayland-egl1 libxkbcommon-x11-dev wayland-protocols libx11-dev libgl1-mesa-dri xauth xvfb" fi host_head=$(git -C "$repo_root" rev-parse HEAD) printf -v guest_repo_q '%q' "$guest_repo" guest_head=$(ssh "${ssh_options[@]}" "$guest" "git -C ${guest_repo_q} rev-parse HEAD") if [[ "$host_head" != "$guest_head" ]]; then echo "Host and guest baselines differ (${host_head} != ${guest_head})." >&2 echo "Update ${guest_repo} in the guest before syncing local changes." >&2 exit 1 fi if ((sync_checkout)); then # Remove every untracked path copied by the previous sync before restoring the # current set. This keeps a locally deleted untracked source from lingering in # guest module discovery without cleaning unrelated guest build artifacts. ssh "${ssh_options[@]}" "$guest" "bash -s -- ${guest_repo_q}" <<'EOF' set -Eeuo pipefail guest_repo=$1 git_dir=$(git -C "$guest_repo" rev-parse --absolute-git-dir) manifest="${git_dir}/qemu-linux-tests-untracked" if [[ -f "$manifest" ]]; then cd "$guest_repo" while IFS= read -r -d '' path; do case "$path" in '' | /* | .. | ../* | */.. | */../*) echo "Invalid path in QEMU sync manifest: ${path}" >&2 exit 1 ;; esac rm -f -- "$path" done < "$manifest" fi EOF # Remove tracked paths deleted or renamed in the local worktree. The NUL-delimited # protocol keeps arbitrary Git filenames safe, and every removal is scoped beneath # the already-validated guest checkout. git -C "$repo_root" diff --no-renames --name-only --diff-filter=D -z HEAD -- \ | ssh "${ssh_options[@]}" "$guest" "cd ${guest_repo_q} && xargs -0 -r rm -f --" # Staged deletions are absent from `git ls-files --cached`, while unstaged # deletions are still listed. Send only paths that currently exist locally. git -C "$repo_root" ls-files -z --cached --others --exclude-standard \ | while IFS= read -r -d '' path; do if [[ -e "${repo_root}/${path}" || -L "${repo_root}/${path}" ]]; then printf '%s\0' "$path" fi done \ | "$rsync_bin" -az \ --from0 \ --files-from=- \ --exclude '.detect_tcc*' \ -e "$rsync_ssh_command" \ "${repo_root}/" "${guest}:${guest_repo}/" # Record only untracked paths that the rsync exclusion above allows through. git -C "$repo_root" ls-files -z --others --exclude-standard \ | while IFS= read -r -d '' path; do case "$path" in .detect_tcc* | */.detect_tcc*) continue ;; esac printf '%s\0' "$path" done \ | ssh "${ssh_options[@]}" "$guest" \ "git_dir=\$(git -C ${guest_repo_q} rev-parse --absolute-git-dir) && cat > \"\${git_dir}/qemu-linux-tests-untracked\"" fi if (($# == 0)); then set -- test-all fi printf -v jobs_q '%q' "$jobs" printf -v nested_cc_q '%q' "$nested_cc" printf -v vflags_q '%q' "$vflags" printf -v no_fallback_q '%q' "$no_fallback" printf -v guest_tmp_root_q '%q' "$guest_tmp_root" printf -v guest_tmp_template_q '%q' "${guest_tmp_root%/}/run.XXXXXXXX" printf -v test_command '%q ' ./vnew "$@" remote_command="cd ${guest_repo_q}" remote_command+=" && mkdir -p ${guest_tmp_root_q}" remote_command+=" && qemu_tmp=\$(mktemp -d ${guest_tmp_template_q})" remote_command+=" && trap 'rm -rf -- \"\$qemu_tmp\"' EXIT" remote_command+=" && export PATH=${guest_repo_q}:\$PATH" remote_command+=" TMPDIR=/tmp VTMP=\$qemu_tmp" remote_command+=" V_C_ERROR_BUG_REPORT_DISABLED=1" remote_command+=" && if [ ! -x thirdparty/tcc/tcc.exe ] || [ ! -f thirdparty/tcc/lib/libgc.a ]; then make; fi" remote_command+=" && ./v -o ./vnew cmd/v" if ((provision)); then remote_command+=" && ./vnew retry -- ./vnew install markdown" remote_command+=" && if [ ! -f thirdparty/sqlite/sqlite3.c ]; then ./vnew run vlib/db/sqlite/install_thirdparty_sqlite.vsh; fi" fi remote_command+=" && ./vnew wipe-cache" remote_command+=" && mkdir -p \"\$qemu_tmp/bin\"" remote_command+=" && ln -sf ${guest_repo_q}/vnew \"\$qemu_tmp/bin/v\"" remote_command+=" && nested_cc_path=\$(command -v ${nested_cc_q})" remote_command+=" && ln -sf \"\$nested_cc_path\" \"\$qemu_tmp/bin/cc\"" remote_command+=" && export PATH=\"\$qemu_tmp/bin:\$PATH\"" remote_command+=" && VFLAGS=${vflags_q} VJOBS=${jobs_q}" remote_command+=" V_MACOS_V3_NO_FALLBACK=${no_fallback_q} ${test_command}" ssh "${ssh_options[@]}" "$guest" "$remote_command"