diff --git a/app/src/components/Common.js b/app/src/components/Common.js index d40e2242..51df2055 100644 --- a/app/src/components/Common.js +++ b/app/src/components/Common.js @@ -1048,8 +1048,8 @@ export function DesktopToggle() { onClose={() => setConfirmOpen(false)} onConfirm={handleConfirmStop} header='Stop the desktop' - content={"Anyone using this WROLPi's own screen will lose their session." - + ' The desktop will return on the next reboot. Are you sure?'} + content={"Anyone using this WROLPi's own screen will lose their session and the display will" + + ' drop to a terminal. The desktop will return on the next reboot. Are you sure?'} confirmButton='Stop' /> dict: return {"success": False, "error": str(e)} +# Stopping the display manager leaves the monitor black with only a blinking +# cursor, even though the console VT is already in the foreground with a getty +# and a shell on it: the display manager hands the display back without the +# console being repainted. Bouncing through another VT forces a modeset and +# redraw, and a short message guarantees there is something legible on screen. +CONSOLE_VT = "1" +CONSOLE_GETTY_UNIT = "getty@tty1.service" +CONSOLE_DEVICE = "/dev/tty1" +REPAINT_VT = "2" + +CONSOLE_MESSAGE = ( + "\n*** The WROLPi Controller stopped the desktop. ***\n" + "*** Press Enter for a prompt. Start the desktop again with: ***\n" + "*** sudo systemctl start display-manager ***\n\n" +) + + +async def _console_getty_active() -> bool: + """Whether a getty is running on the console VT (i.e. switching there gives a terminal).""" + try: + proc = await asyncio.create_subprocess_exec( + "systemctl", "is-active", CONSOLE_GETTY_UNIT, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + await asyncio.wait_for(proc.communicate(), timeout=5) + return proc.returncode == 0 + except (asyncio.TimeoutError, FileNotFoundError, OSError): + return False + + +async def _chvt(vt: str) -> bool: + """Activate a virtual terminal. Returns True on success.""" + try: + proc = await asyncio.create_subprocess_exec( + "/usr/bin/chvt", vt, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=10) + if proc.returncode == 0: + return True + logger.warning("Failed to switch to VT %s: %s", vt, stderr.decode().strip()) + return False + except asyncio.TimeoutError: + logger.warning("Timeout switching to VT %s", vt) + return False + except FileNotFoundError: + logger.warning("chvt not found; cannot switch VTs") + return False + except OSError as e: + logger.warning("Failed to switch to VT %s: %s", vt, e) + return False + + +def _write_console_message(): + """Print a short message on the console so the screen is not left blank.""" + try: + with open(CONSOLE_DEVICE, "w") as console: + console.write(CONSOLE_MESSAGE) + except OSError as e: + logger.warning("Could not write to %s: %s", CONSOLE_DEVICE, e) + + +async def _switch_to_console_vt() -> bool: + """ + Leave the physical display showing a usable terminal. + + Best-effort: never fails the caller. Returns True when the console was + activated and repainted. + """ + if not await _console_getty_active(): + # Without a getty there is no terminal to show, so leave the display alone. + logger.warning("No getty on %s; leaving the display as-is", CONSOLE_GETTY_UNIT) + return False + + if not await _chvt(CONSOLE_VT): + return False + + # The console VT is usually already in the foreground, but stale after the + # display manager released the display; bounce off another VT to repaint it. + if await _chvt(REPAINT_VT) and not await _chvt(CONSOLE_VT): + logger.error("Left the display on VT %s after failing to return to VT %s", + REPAINT_VT, CONSOLE_VT) + return False + + _write_console_message() + logger.info("Console VT %s activated and repainted", CONSOLE_VT) + return True + + async def start_desktop() -> dict: """ Start the graphical desktop (display manager) until stopped or reboot. @@ -828,15 +919,19 @@ async def start_desktop() -> dict: async def stop_desktop() -> dict: """ - Stop the graphical desktop (display manager) until started or reboot. + Stop the graphical desktop (display manager) until started or reboot, then + drop the physical display to a console terminal. Fail open: this deliberately does not `systemctl disable` the unit, so the desktop returns on the next boot. Returns: - dict with success status + dict with success status and whether the display reached the console """ - return await _systemctl_desktop("stop") + result = await _systemctl_desktop("stop") + if result.get("success"): + result["switched_to_console"] = await _switch_to_console_vt() + return result GOVERNOR_MAP = { diff --git a/controller/templates/index.html b/controller/templates/index.html index 0a9b56f8..b6c466e6 100644 --- a/controller/templates/index.html +++ b/controller/templates/index.html @@ -1577,8 +1577,8 @@ async function toggleDesktop() { const btn = document.getElementById('desktop-btn'); if (desktopRunning && !confirm( - 'Stop the desktop? Anyone using this WROLPi\'s own screen will lose their session.' - + ' The desktop will return on the next reboot.')) { + 'Stop the desktop? Anyone using this WROLPi\'s own screen will lose their session' + + ' and the display will drop to a terminal. The desktop will return on the next reboot.')) { return; } btn.disabled = true; diff --git a/controller/test/test_admin.py b/controller/test/test_admin.py index f0a1b9b8..f783f890 100644 --- a/controller/test/test_admin.py +++ b/controller/test/test_admin.py @@ -262,13 +262,15 @@ class TestDesktopStartStop: with mock.patch("asyncio.create_subprocess_exec", return_value=mock_proc) as mock_exec: result = await func() assert result["success"] is True - # Fail open: only start/stop the unit, never enable/disable or - # change the default target. - mock_exec.assert_called_once_with( + assert mock_exec.call_args_list[0] == mock.call( "systemctl", systemctl_action, "display-manager.service", stdout=mock.ANY, stderr=mock.ANY, ) + # Fail open: never enable/disable the unit or change the default target. + forbidden = {"enable", "disable", "mask", "unmask", "set-default", "isolate"} + for call in mock_exec.call_args_list: + assert not forbidden.intersection(call.args), call.args @pytest.mark.asyncio @pytest.mark.parametrize("func", [start_desktop, stop_desktop]) @@ -294,6 +296,121 @@ class TestDesktopStartStop: assert result["success"] is False +class TestStopDesktopDropsToConsole: + """Stopping the desktop must leave the physical display on a usable terminal. + + The display manager owns its own VT (7 on Raspberry Pi OS); stopping it leaves + that VT blank with only a cursor, so the display is switched to the console VT. + """ + + @staticmethod + def _proc(returncode=0, stdout=b"", stderr=b""): + proc = mock.AsyncMock() + proc.communicate.return_value = (stdout, stderr) + proc.returncode = returncode + return proc + + @pytest.mark.asyncio + async def test_activates_and_repaints_console(self): + """Should check for a getty, activate the console VT, then bounce to repaint it. + + The console VT is normally already in the foreground when the display manager + stops, but the monitor is left black until something forces a redraw. + """ + procs = [self._proc(), self._proc(0, b"active\n"), self._proc(), self._proc(), self._proc()] + + with mock.patch("controller.lib.admin.is_docker_mode", return_value=False): + with mock.patch("asyncio.create_subprocess_exec", side_effect=procs) as mock_exec: + with mock.patch("builtins.open", mock.mock_open()) as mock_console: + result = await stop_desktop() + + assert result["success"] is True + assert result["switched_to_console"] is True + commands = [call.args for call in mock_exec.call_args_list] + assert commands[0] == ("systemctl", "stop", "display-manager.service") + assert commands[1] == ("systemctl", "is-active", "getty@tty1.service") + # Land on the console, bounce away, and come back so it is repainted. + assert commands[2:] == [("/usr/bin/chvt", "1"), ("/usr/bin/chvt", "2"), ("/usr/bin/chvt", "1")] + mock_console.assert_called_once_with("/dev/tty1", "w") + written = mock_console().write.call_args.args[0] + assert "stopped the desktop" in written + assert "systemctl start display-manager" in written + + @pytest.mark.asyncio + async def test_console_write_failure_is_tolerated(self): + """A console that cannot be written to must not fail the stop.""" + procs = [self._proc(), self._proc(0, b"active\n"), self._proc(), self._proc(), self._proc()] + + with mock.patch("controller.lib.admin.is_docker_mode", return_value=False): + with mock.patch("asyncio.create_subprocess_exec", side_effect=procs): + with mock.patch("builtins.open", side_effect=OSError("No such device")): + result = await stop_desktop() + + assert result["success"] is True + assert result["switched_to_console"] is True + + @pytest.mark.asyncio + async def test_reports_failure_when_stranded_on_repaint_vt(self): + """If the bounce cannot return to the console, say so rather than claim success.""" + procs = [self._proc(), self._proc(0, b"active\n"), self._proc(), self._proc(), self._proc(1)] + + with mock.patch("controller.lib.admin.is_docker_mode", return_value=False): + with mock.patch("asyncio.create_subprocess_exec", side_effect=procs): + result = await stop_desktop() + + assert result["success"] is True + assert result["switched_to_console"] is False + + @pytest.mark.asyncio + async def test_does_not_switch_when_no_getty(self): + """Without a getty on the console VT, switching would be just as blank — skip it.""" + procs = [self._proc(), self._proc(3, b"inactive\n")] + + with mock.patch("controller.lib.admin.is_docker_mode", return_value=False): + with mock.patch("asyncio.create_subprocess_exec", side_effect=procs) as mock_exec: + result = await stop_desktop() + + assert result["success"] is True + assert result["switched_to_console"] is False + assert len(mock_exec.call_args_list) == 2 # no chvt + + @pytest.mark.asyncio + async def test_chvt_failure_does_not_fail_the_stop(self): + """A missing or failing chvt is reported, but the desktop is still stopped.""" + procs = [self._proc(), self._proc(0, b"active\n"), FileNotFoundError()] + + with mock.patch("controller.lib.admin.is_docker_mode", return_value=False): + with mock.patch("asyncio.create_subprocess_exec", side_effect=procs): + result = await stop_desktop() + + assert result["success"] is True + assert result["switched_to_console"] is False + + @pytest.mark.asyncio + async def test_no_switch_when_stop_failed(self): + """A failed stop leaves the desktop up, so the display must not be switched.""" + procs = [self._proc(5, b"", b"Unit not loaded.")] + + with mock.patch("controller.lib.admin.is_docker_mode", return_value=False): + with mock.patch("asyncio.create_subprocess_exec", side_effect=procs) as mock_exec: + result = await stop_desktop() + + assert result["success"] is False + assert "switched_to_console" not in result + assert len(mock_exec.call_args_list) == 1 + + @pytest.mark.asyncio + async def test_start_never_switches_vt(self): + """Starting the desktop must not touch the VT; the display manager claims one itself.""" + with mock.patch("controller.lib.admin.is_docker_mode", return_value=False): + with mock.patch("asyncio.create_subprocess_exec", return_value=self._proc()) as mock_exec: + result = await start_desktop() + + assert result["success"] is True + assert "switched_to_console" not in result + assert len(mock_exec.call_args_list) == 1 + + class TestGetHotspotStatus: """Tests for get_hotspot_status function.""" diff --git a/controller/test/test_admin_api.py b/controller/test/test_admin_api.py index 256f7005..840c30ca 100644 --- a/controller/test/test_admin_api.py +++ b/controller/test/test_admin_api.py @@ -169,7 +169,8 @@ class TestDesktopEndpoints: response = test_client.post(endpoint) assert response.status_code == 200 assert response.json()["success"] is True - mock_exec.assert_called_once_with( + # Stopping also switches the display to a console VT, so assert the first call. + assert mock_exec.call_args_list[0] == mock.call( "systemctl", systemctl_action, "display-manager.service", stdout=mock.ANY, stderr=mock.ANY,