Skip to content
Merged
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
13 changes: 13 additions & 0 deletions GNUmakefile
Original file line number Diff line number Diff line change
Expand Up @@ -379,6 +379,7 @@ test-chipset:
python3 tests/keybindings_test.py
python3 tests/scheduler_test.py
python3 tests/examples_teaching_test.py
python3 tests/run_gui_test.py
python3 tests/layout_test.py
python3 tests/ci_gate_test.py

Expand Down Expand Up @@ -436,6 +437,18 @@ cleanall: clean
# ── Docker image (rebuild when the Dockerfile is newer than the stamp) ───────
# Previously this stamp depended on a FORCE target, which marked every
# ISO/ELF out of date on every `make`. Depend on the Dockerfile instead.
#
# The stamp only proves the image was built at some point in the past; it
# doesn't prove the image still exists in the local Docker daemon (e.g. after
# `docker system prune` or a fresh machine/context). Detect that case at
# parse time and drop the stale stamp so the image gets rebuilt.
ifneq ($(wildcard .docker-image),)
ifneq ($(shell command -v docker 2>/dev/null),)
ifeq ($(shell docker image inspect $(DOCKER_IMG) >/dev/null 2>&1 && echo yes),)
$(shell rm -f .docker-image)
endif
endif
endif

.docker-image: tools/Dockerfile
docker build --platform $(DOCKER_PLATFORM) --load -t $(DOCKER_IMG) -f tools/Dockerfile .
Expand Down
6 changes: 5 additions & 1 deletion kernel/gui/compositor.py
Original file line number Diff line number Diff line change
Expand Up @@ -1067,7 +1067,11 @@ async def stop(self) -> None:
_bridge_input.stop_forwarder()
from kernel.bridge import bridge as _bridge
if self._bridge_present:
_bridge.call("display.close", {})
# The host window represents the whole run-gui session. Ask
# the bridge process to exit so its host-side supervisor can
# also stop QEMU; merely closing the SDL display leaves both
# processes alive after the user closes the window.
_bridge.call("shutdown", {})
except Exception:
pass
self._bridge_present = False
Expand Down
54 changes: 54 additions & 0 deletions tests/run_gui_test.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
#!/usr/bin/env python3
"""Process-supervision regression tests for tools/run_gui.py."""

import importlib.util
import pathlib
import subprocess
import sys
import time
import unittest


ROOT = pathlib.Path(__file__).resolve().parents[1]
SPEC = importlib.util.spec_from_file_location(
"run_gui", ROOT / "tools" / "run_gui.py")
run_gui = importlib.util.module_from_spec(SPEC)
assert SPEC.loader is not None
SPEC.loader.exec_module(run_gui)


def _sleeper() -> subprocess.Popen:
return subprocess.Popen([sys.executable, "-c", "import time; time.sleep(30)"])


class RunGuiSupervisionTests(unittest.TestCase):
def test_guest_shutdown_exits_bridge_for_supervisor(self):
compositor_source = (
ROOT / "kernel" / "gui" / "compositor.py").read_text()

self.assertIn('_bridge.call("shutdown", {})', compositor_source)

def test_bridge_exit_stops_qemu_and_returns_bridge_status(self):
bridge = subprocess.Popen([sys.executable, "-c", "pass"])
started = time.monotonic()

rc = run_gui._launch_qemu(
[sys.executable, "-c", "import time; time.sleep(30)"], bridge)

self.assertEqual(rc, 0)
self.assertLess(time.monotonic() - started, 3)

def test_qemu_exit_stops_bridge_and_returns_qemu_status(self):
bridge = _sleeper()
try:
rc = run_gui._launch_qemu(
[sys.executable, "-c", "raise SystemExit(7)"], bridge)

self.assertEqual(rc, 7)
self.assertIsNotNone(bridge.poll())
finally:
run_gui._stop_proc(bridge)


if __name__ == "__main__":
unittest.main(verbosity=2)
64 changes: 51 additions & 13 deletions tools/run_gui.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@
PYTHONOS_BRIDGE_TRANSPORT=chardev.
"""

from __future__ import annotations

import os
import platform
import socket
Expand Down Expand Up @@ -348,8 +350,39 @@ def _spawn_bridge_connect(host: str, port: int,
], env=env, stdout=stream, stderr=subprocess.STDOUT)


def _launch_qemu(cmd: list) -> int:
return _wait_proc(subprocess.Popen(cmd))
def _launch_qemu(cmd: list,
bridge_proc: subprocess.Popen | None = None) -> int:
"""Run QEMU until it or the managed desktop bridge exits.

The bridge owns the visible GUI window. Treating only QEMU as the
foreground process leaves the wrapper stuck after that window is closed,
because QEMU deliberately runs with ``-no-shutdown``. A managed bridge
and QEMU therefore form one supervised session: when either exits, stop
the other and return the process that initiated shutdown's status.
"""
qemu_proc = subprocess.Popen(cmd)
if bridge_proc is None:
return _wait_proc(qemu_proc)

try:
while True:
qemu_rc = qemu_proc.poll()
if qemu_rc is not None:
_stop_proc(bridge_proc, timeout=2)
return qemu_rc

bridge_rc = bridge_proc.poll()
if bridge_rc is not None:
print("[run-gui] desktop window closed; stopping QEMU",
file=sys.stderr)
_stop_proc(qemu_proc)
return bridge_rc

time.sleep(0.1)
except KeyboardInterrupt:
_stop_proc(qemu_proc)
_stop_proc(bridge_proc, timeout=2)
return 130


def _debug_session(image: str, arch: str, repl_port: int) -> dict | None:
Expand Down Expand Up @@ -413,15 +446,22 @@ def _add_debug_qemu_args(cmd: list, session: dict) -> None:
cmd.append("-S")


def _stop_proc(proc: subprocess.Popen, timeout: float = 4) -> None:
if proc.poll() is not None:
return
proc.terminate()
try:
proc.wait(timeout=timeout)
except subprocess.TimeoutExpired:
proc.kill()
proc.wait()


def _wait_proc(proc: subprocess.Popen) -> int:
try:
proc.wait()
except KeyboardInterrupt:
proc.terminate()
try:
proc.wait(timeout=4)
except subprocess.TimeoutExpired:
proc.kill()
_stop_proc(proc)
return 130
return proc.returncode or 0

Expand Down Expand Up @@ -509,19 +549,17 @@ def main() -> int:
guest_bridge_port)
if debug_session:
_add_debug_qemu_args(cmd, debug_session)
return _launch_qemu(cmd)
return _launch_qemu(cmd, bridge_proc)
cmd = _qemu_cmd_x86_64(image, port, display, audiodev,
bridge_endpoint, gui_app,
bridge_transport, listen_host,
guest_bridge_port)
if debug_session:
_add_debug_qemu_args(cmd, debug_session)
return _launch_qemu(cmd)
return _launch_qemu(cmd, bridge_proc)
finally:
if bridge_proc is not None and bridge_proc.poll() is None:
bridge_proc.terminate()
try: bridge_proc.wait(timeout=2)
except subprocess.TimeoutExpired: bridge_proc.kill()
if bridge_proc is not None:
_stop_proc(bridge_proc, timeout=2)


if __name__ == "__main__":
Expand Down
Loading