controller: leave a usable terminal when stopping the desktop
Some checks are pending
CodeQL / Analyze (push) Waiting to run
Controller Tests / controller-tests (push) Waiting to run
Jest Tests / jest-tests (push) Waiting to run

Stopping the display manager left the Pi's monitor black with only a blinking
cursor. The console VT is already in the foreground with a getty and a live
shell on it (verified: /dev/vcs1 holds the login banner and prompt) — the
display manager just hands the display back without the console being
repainted, so nothing is visible.

Activate the console VT, then bounce off another VT to force a modeset and
redraw, and print a short message telling the user the Controller stopped the
desktop and how to start it again.

Best-effort throughout: skipped when no getty is on the console VT, a chvt or
console-write failure never fails the stop, and being stranded on the repaint
VT is reported rather than claimed as success. Verified on 10.0.0.8 hardware.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Roland 2026-07-29 15:54:18 -06:00
parent ab5f416b3f
commit a0fa03e6c5
No known key found for this signature in database
GPG key ID: 1BA620CE6C9A4680
6 changed files with 226 additions and 11 deletions

View file

@ -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'
/>
<Toggle

View file

@ -233,6 +233,8 @@ class DesktopActionResponse(BaseModel):
"""Response model for desktop start/stop actions."""
success: bool = Field(description="Whether the action succeeded")
switched_to_console: Optional[bool] = Field(
default=None, description="Whether stopping switched the display to a console terminal")
error: Optional[str] = Field(default=None, description="Error message if failed")

View file

@ -813,6 +813,97 @@ async def _systemctl_desktop(action: str) -> 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 = {

View file

@ -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;

View file

@ -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."""

View file

@ -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,