Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ test:
@$(ROOT)/macos/Tests/run-qemu-ssh-contract.test.sh
@$(ROOT)/macos/Tests/qemu-memory-contract.test.sh
@$(ROOT)/macos/Tests/qemu-power-actions.test.sh
@$(ROOT)/macos/Tests/qemu-monitor-ready.test.sh
@$(ROOT)/macos/Tests/qemu-persistent-storage.test.sh
@PYTHONDONTWRITEBYTECODE=1 python3 "$(ROOT)/macos/Tests/resize-vm-disk.test.py"

Expand Down
23 changes: 23 additions & 0 deletions macos/Tests/qemu-memory-contract.test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -44,8 +44,10 @@ mkdir -p \
/bin/cp "$macos_dir/run-qemu-gpu.sh" "$resources/scripts/run-qemu-gpu.sh"
/bin/cp "$macos_dir/qemu-port-forwarding.sh" "$resources/scripts/qemu-port-forwarding.sh"
/bin/cp "$macos_dir/qemu-networking.sh" "$resources/scripts/qemu-networking.sh"
/bin/cp "$macos_dir/qemu-monitor-ready.sh" "$resources/scripts/qemu-monitor-ready.sh"
chmod 755 "$resources/scripts/run-qemu-gpu.sh"
chmod 644 "$resources/scripts/qemu-port-forwarding.sh"
chmod 644 "$resources/scripts/qemu-monitor-ready.sh"

cat >"$contents/MacOS/omarchy-vm-helper" <<'SH'
#!/bin/bash
Expand Down Expand Up @@ -104,10 +106,16 @@ import os
from pathlib import Path
import socket
import sys
import threading
import time

arguments = sys.argv[1:]
Path(os.environ["FAKE_QEMU_LOG"]).write_text("\n".join(arguments) + "\n")
qmp_paths = {
arguments[index + 1][5:].split(",", 1)[0]
for index, argument in enumerate(arguments[:-1])
if argument == "-qmp" and arguments[index + 1].startswith("unix:")
}
socket_paths = []
for argument in arguments:
if argument.startswith("unix:"):
Expand All @@ -128,6 +136,21 @@ if os.environ.get("FAKE_QEMU_SKIP_SOCKETS") != "1":
server.bind(path)
server.listen(1)
servers.append(server)
# Like QEMU, answer the QMP monitor with a greeting; the launcher
# waits for that before declaring the VM ready.
if path in qmp_paths:
def greet(server=server):
while True:
try:
client, _ = server.accept()
except OSError:
return
try:
client.sendall(b'{"QMP": {"version": {}, "capabilities": []}}\r\n')
except OSError:
pass
client.close()
threading.Thread(target=greet, daemon=True).start()

time.sleep(float(os.environ.get("FAKE_QEMU_LIFETIME", "0.20")))
for server in servers:
Expand Down
110 changes: 110 additions & 0 deletions macos/Tests/qemu-monitor-ready.test.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
#!/bin/bash

# The launcher's "Ready. QMP:" line is a contract: the helper connects to that
# socket and tears the VM down if the monitor does not answer. QEMU creates the
# socket file early in its initialisation but only accepts connections once its
# main loop runs, with a listen backlog of one, so a client that connects too
# early and gives up leaves every later connect refused until the loop starts.
# Ready must therefore mean "the monitor answered", not "the file exists".

set -euo pipefail

test_dir=$(cd "$(dirname "$0")" && pwd -P)
macos_dir=$(cd "$test_dir/.." && pwd -P)
library="$macos_dir/qemu-monitor-ready.sh"
launcher="$macos_dir/run-qemu-gpu.sh"

fail() {
printf 'qemu-monitor-ready.test: %s\n' "$*" >&2
exit 1
}

[[ -f $library ]] || fail "missing library: $library"
# shellcheck source=../qemu-monitor-ready.sh
source "$library"
declare -F qemu_wait_for_qmp_monitor >/dev/null || \
fail 'library must define qemu_wait_for_qmp_monitor'

scratch=$(mktemp -d "${TMPDIR:-/tmp}/qemu-monitor-ready.XXXXXX")
cleanup() {
[[ -z ${server_pid:-} ]] || kill "$server_pid" 2>/dev/null || true
[[ -z ${sleeper_pid:-} ]] || kill "$sleeper_pid" 2>/dev/null || true
rm -rf "$scratch"
}
trap cleanup EXIT

# A stand-in for QEMU: binds and listens (backlog 1) immediately, like the
# chardev does during init, but only starts accepting after ACCEPT_DELAY
# seconds, like the main loop. Each accepted client gets a QMP greeting.
fake_qemu() {
python3 - "$1" "$2" <<'PY' &
import socket, sys, time
path, delay = sys.argv[1], float(sys.argv[2])
server = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
server.bind(path)
server.listen(1)
time.sleep(delay)
while True:
client, _ = server.accept()
# A probe that gave up during the delay is still queued here; like QEMU,
# shrug off the peer having gone away.
try:
client.sendall(b'{"QMP": {"version": {}, "capabilities": []}}\r\n')
except OSError:
pass
client.close()
PY
server_pid=$!
}

# Something with a live pid to stand in for the QEMU process.
sleep 300 &
sleeper_pid=$!

# 1. Slow init: the monitor answers only after 1.5 s. Ready must wait for it.
socket_path="$scratch/slow.sock"
fake_qemu "$socket_path" 1.5
until [[ -S $socket_path ]]; do sleep 0.02; done
start=$(python3 -c 'import time; print(time.monotonic())')
qemu_wait_for_qmp_monitor "$socket_path" "$sleeper_pid" || \
fail 'a monitor that answers after a slow init must count as ready'
elapsed=$(python3 -c "import time; print(time.monotonic() - $start)")
python3 -c "import sys; sys.exit(0 if $elapsed >= 1.4 else 1)" || \
fail "ready was declared after ${elapsed}s, before the monitor could answer"
kill "$server_pid"; wait "$server_pid" 2>/dev/null || true; unset server_pid

# 2. Fast init: an immediately answering monitor must not be held up.
socket_path="$scratch/fast.sock"
fake_qemu "$socket_path" 0
until [[ -S $socket_path ]]; do sleep 0.02; done
start=$(python3 -c 'import time; print(time.monotonic())')
qemu_wait_for_qmp_monitor "$socket_path" "$sleeper_pid" || \
fail 'an answering monitor must count as ready'
elapsed=$(python3 -c "import time; print(time.monotonic() - $start)")
python3 -c "import sys; sys.exit(0 if $elapsed < 1.0 else 1)" || \
fail "an answering monitor took ${elapsed}s to be declared ready"
kill "$server_pid"; wait "$server_pid" 2>/dev/null || true; unset server_pid

# 3. QEMU gone: a socket file with no process behind it must fail promptly
# rather than wait out the whole deadline.
socket_path="$scratch/dead.sock"
python3 -c 'import socket, sys; s = socket.socket(socket.AF_UNIX); s.bind(sys.argv[1])' "$socket_path"
kill "$sleeper_pid"; wait "$sleeper_pid" 2>/dev/null || true
dead_pid=$sleeper_pid; unset sleeper_pid
start=$(python3 -c 'import time; print(time.monotonic())')
if qemu_wait_for_qmp_monitor "$socket_path" "$dead_pid" 2>/dev/null; then
fail 'a monitor whose QEMU has exited must not count as ready'
fi
elapsed=$(python3 -c "import time; print(time.monotonic() - $start)")
python3 -c "import sys; sys.exit(0 if $elapsed < 3.0 else 1)" || \
fail "a dead QEMU took ${elapsed}s to be reported"

# 4. The launcher must consult the monitor before announcing Ready.
wait_line=$(grep -n 'qemu_wait_for_qmp_monitor "$qmp_socket" "$qemu_pid"' "$launcher" | cut -d: -f1 | head -1)
ready_line=$(grep -n '^echo "\[qemu-gpu\] Ready. QMP: \$qmp_socket" >&2$' "$launcher" | cut -d: -f1 | head -1)
[[ -n $wait_line && -n $ready_line ]] || \
fail 'launcher must wait for the QMP monitor and print the Ready line'
(( wait_line < ready_line )) || \
fail 'launcher must wait for the QMP monitor before printing Ready'

echo 'qemu-monitor-ready.test: PASS'
23 changes: 23 additions & 0 deletions macos/Tests/run-qemu-ssh-contract.test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -49,8 +49,10 @@ mkdir -p \
/bin/cp "$macos_dir/run-qemu-gpu.sh" "$resources/scripts/run-qemu-gpu.sh"
/bin/cp "$macos_dir/qemu-port-forwarding.sh" "$resources/scripts/qemu-port-forwarding.sh"
/bin/cp "$macos_dir/qemu-networking.sh" "$resources/scripts/qemu-networking.sh"
/bin/cp "$macos_dir/qemu-monitor-ready.sh" "$resources/scripts/qemu-monitor-ready.sh"
chmod 755 "$resources/scripts/run-qemu-gpu.sh"
chmod 644 "$resources/scripts/qemu-port-forwarding.sh"
chmod 644 "$resources/scripts/qemu-monitor-ready.sh"

cat >"$contents/MacOS/omarchy-vm-helper" <<'SH'
#!/bin/bash
Expand Down Expand Up @@ -114,6 +116,7 @@ import os
from pathlib import Path
import socket
import sys
import threading
import time

arguments = sys.argv[1:]
Expand All @@ -137,6 +140,11 @@ if is_recovery:
'console=tty0 console=hvc0 loglevel=3"}}\n'
)
export_path.joinpath("complete").write_text("try-omarchy-boot-export-v1\n")
qmp_paths = {
arguments[index + 1][5:].split(",", 1)[0]
for index, argument in enumerate(arguments[:-1])
if argument == "-qmp" and arguments[index + 1].startswith("unix:")
}
socket_paths = []
for argument in arguments:
if argument.startswith("unix:"):
Expand All @@ -157,6 +165,21 @@ if os.environ.get("FAKE_QEMU_SKIP_SOCKETS") != "1":
server.bind(path)
server.listen(1)
servers.append(server)
# Like QEMU, answer the QMP monitor with a greeting; the launcher
# waits for that before declaring the VM ready.
if path in qmp_paths:
def greet(server=server):
while True:
try:
client, _ = server.accept()
except OSError:
return
try:
client.sendall(b'{"QMP": {"version": {}, "capabilities": []}}\r\n')
except OSError:
pass
client.close()
threading.Thread(target=greet, daemon=True).start()

time.sleep(float(os.environ.get("FAKE_QEMU_LIFETIME", "0.20")))
for server in servers:
Expand Down
2 changes: 2 additions & 0 deletions macos/build-app.sh
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,8 @@ install -m 0644 "$macos_dir/qemu-persistent-storage.sh" \
install -m 0644 "$macos_dir/qemu-port-forwarding.sh" \
"$contents/Resources/scripts/qemu-port-forwarding.sh"
install -m 0644 "$macos_dir/qemu-networking.sh" "$contents/Resources/scripts/qemu-networking.sh"
install -m 0644 "$macos_dir/qemu-monitor-ready.sh" \
"$contents/Resources/scripts/qemu-monitor-ready.sh"
for guest_resource in \
LICENSE.omarchy \
SHA256SUMS \
Expand Down
56 changes: 56 additions & 0 deletions macos/qemu-monitor-ready.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
# Wait until QEMU's QMP monitor actually answers.
#
# QEMU creates its chardev socket files early in initialisation, but the
# monitor only accepts connections once the main loop runs, and the socket
# listens with a backlog of one. A client that connects during that gap and
# gives up leaves its connection queued until QEMU accepts it, so every later
# connect is refused until the main loop starts. Announcing the VM as ready on
# the socket file alone therefore hands the helper a socket it may not be able
# to use for seconds; the helper treats that as a broken monitor and tears the
# VM down. Ready has to mean the monitor answered.
#
# Sourced by run-qemu-gpu.sh; expects `fail` to be defined by the caller.

# qemu_wait_for_qmp_monitor SOCKET QEMU_PID
#
# Returns 0 once a connection to SOCKET receives a QMP greeting. Each probe
# gives the monitor 250 ms to answer and then disconnects; a refused connect
# or a silent monitor is retried after 100 ms. Both outcomes are expected
# while QEMU is still initialising: a probe that gave up may itself occupy the
# backlog, and the main loop drains it as its first act, so the next probe
# gets through. Fails if QEMU exits, or after 60 s so a monitor that never
# answers still fails startup instead of hanging it.
qemu_wait_for_qmp_monitor() {
local socket_path=$1
local qemu_pid=$2
local deadline=$((SECONDS + 60))

while (( SECONDS < deadline )); do
if python3 - "$socket_path" <<'PY' 2>/dev/null
import socket
import sys

client = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
client.settimeout(0.25)
try:
client.connect(sys.argv[1])
greeting = client.recv(4096)
except (OSError, socket.timeout):
raise SystemExit(1)
finally:
client.close()
raise SystemExit(0 if greeting.lstrip().startswith(b'{"QMP"') else 1)
PY
then
return 0
fi
kill -0 "$qemu_pid" 2>/dev/null || {
echo 'qemu-monitor-ready: QEMU exited before its QMP monitor answered' >&2
return 1
}
sleep 0.1
done

echo 'qemu-monitor-ready: the QMP monitor did not answer within 60 seconds' >&2
return 1
}
10 changes: 10 additions & 0 deletions macos/run-qemu-gpu.sh
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ qemu_bin="$resources_dir/runtime/bin/Try Omarchy"
native_bridge="$contents_dir/MacOS/omarchy-vm-helper"
storage_library="$script_dir/qemu-persistent-storage.sh"
port_forwarding_library="$script_dir/qemu-port-forwarding.sh"
monitor_ready_library="$script_dir/qemu-monitor-ready.sh"

[[ $(uname -m) == arm64 ]] || fail "requires an ARM64 Mac"
[[ $(uname -s) == Darwin ]] || fail "requires macOS"
Expand Down Expand Up @@ -931,6 +932,9 @@ fi
[[ -f $port_forwarding_library && ! -L $port_forwarding_library ]] || {
fail "port-forwarding library is missing or unsafe: $port_forwarding_library"
}
[[ -f $monitor_ready_library && ! -L $monitor_ready_library ]] || {
fail "monitor-readiness library is missing or unsafe: $monitor_ready_library"
}

# These libraries are sealed resources in normal app launches. The complete
# app bundle was verified above before either file can execute. Inspect-only is
Expand All @@ -939,6 +943,8 @@ fi
source "$storage_library"
# shellcheck source=qemu-port-forwarding.sh
source "$port_forwarding_library"
# shellcheck source=qemu-monitor-ready.sh
source "$monitor_ready_library"
source "$script_dir/qemu-networking.sh"
qemu_network_validate
if [[ $QEMU_NETWORK_MODE == bridged ]]; then
Expand Down Expand Up @@ -1644,6 +1650,10 @@ done
[[ -S $authentication_bridge_socket ]] || fail "QEMU did not create its private authentication bridge socket"
[[ -S $camera_bridge_socket ]] || fail "QEMU did not create its private camera bridge socket"
[[ -S $clipboard_bridge_socket ]] || fail "QEMU did not create its private clipboard bridge socket"
# The socket file appears before QEMU's main loop accepts connections, and the
# helper tears the VM down if the monitor behind this line does not answer.
qemu_wait_for_qmp_monitor "$qmp_socket" "$qemu_pid" || \
fail "QEMU's QMP monitor did not become ready"
echo "[qemu-gpu] Ready. QMP: $qmp_socket" >&2

# FD 9 deliberately remains open only in QEMU. Letting the sibling audio
Expand Down