From f3db8189c7ea08e11ad5c1e8d3305e54ff340ade Mon Sep 17 00:00:00 2001 From: Jordan Hubbard Date: Fri, 11 Sep 2026 14:18:44 -0700 Subject: [PATCH 1/2] Fix build failure from stale .docker-image stamp The .docker-image stamp only tracked tools/Dockerfile's mtime, not whether the pythonos-builder image still existed in the local Docker daemon. After the image was pruned/removed, the stale stamp made make skip rebuilding it and go straight to `docker run`, which failed with "pull access denied" since the image was gone. Detect the missing-image case at parse time and drop the stale stamp so it gets rebuilt. Guarded with a `docker` availability check since the same GNUmakefile is re-parsed inside the build container (no docker-in-docker there), where the check would otherwise misfire and delete the stamp mid-build. Co-Authored-By: Claude Sonnet 5 --- GNUmakefile | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/GNUmakefile b/GNUmakefile index 26d4736f..fe3de9e5 100644 --- a/GNUmakefile +++ b/GNUmakefile @@ -436,6 +436,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 . From bf111639fdedd88c7f0f7d1761e9e51fa897d859 Mon Sep 17 00:00:00 2001 From: Jordan Hubbard Date: Thu, 17 Sep 2026 00:34:41 -0700 Subject: [PATCH 2/2] Stop run-gui when desktop window closes --- GNUmakefile | 1 + kernel/gui/compositor.py | 6 +++- tests/run_gui_test.py | 54 +++++++++++++++++++++++++++++++++ tools/run_gui.py | 64 ++++++++++++++++++++++++++++++++-------- 4 files changed, 111 insertions(+), 14 deletions(-) create mode 100644 tests/run_gui_test.py diff --git a/GNUmakefile b/GNUmakefile index fe3de9e5..052ce7d3 100644 --- a/GNUmakefile +++ b/GNUmakefile @@ -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 diff --git a/kernel/gui/compositor.py b/kernel/gui/compositor.py index c1946271..3349d93f 100644 --- a/kernel/gui/compositor.py +++ b/kernel/gui/compositor.py @@ -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 diff --git a/tests/run_gui_test.py b/tests/run_gui_test.py new file mode 100644 index 00000000..d9012819 --- /dev/null +++ b/tests/run_gui_test.py @@ -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) diff --git a/tools/run_gui.py b/tools/run_gui.py index fcafdd58..380cfa3f 100644 --- a/tools/run_gui.py +++ b/tools/run_gui.py @@ -12,6 +12,8 @@ PYTHONOS_BRIDGE_TRANSPORT=chardev. """ +from __future__ import annotations + import os import platform import socket @@ -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: @@ -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 @@ -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__":