mirror of
https://github.com/lrnselfreliance/wrolpi.git
synced 2026-10-25 18:40:06 -04:00
controller: leave a usable terminal when stopping the desktop
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:
parent
ab5f416b3f
commit
a0fa03e6c5
6 changed files with 226 additions and 11 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue