From 2c0ea1d0c6c8ab22bef7cc6ec626b9e94dacb6d4 Mon Sep 17 00:00:00 2001 From: jxxghp Date: Tue, 27 Oct 2026 13:03:48 +0800 Subject: [PATCH] =?UTF-8?q?fix(display):=20=E9=81=BF=E5=85=8D=E5=9B=BA?= =?UTF-8?q?=E5=AE=9A=E7=BC=96=E5=8F=B7=E5=AF=BC=E8=87=B4=20Xvfb=20?= =?UTF-8?q?=E5=90=AF=E5=8A=A8=E5=86=B2=E7=AA=81=20(#7217)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 移除将 DISPLAY 追加到 Xvfb 命令的参数,交由显示后端分配空闲编号。补充真实命令构造、显示环境恢复和重复启停的回归测试。 Refs #7212 --- app/adapters/system/display/resource.py | 9 ++- tests/test_display_resource.py | 91 ++++++++++++++++++++++++- 2 files changed, 97 insertions(+), 3 deletions(-) diff --git a/app/adapters/system/display/resource.py b/app/adapters/system/display/resource.py index eafd5b2e8..dbb75dcb1 100644 --- a/app/adapters/system/display/resource.py +++ b/app/adapters/system/display/resource.py @@ -4,7 +4,6 @@ from __future__ import annotations -import os from typing import Any, Optional from app.adapters.system.host import SystemUtils @@ -17,18 +16,25 @@ class VirtualDisplayResource: """ def __init__(self) -> None: + """ + 初始化显示句柄,实际进程在首次启动时创建。 + """ self._display: Optional[Any] = None @property def display(self) -> Optional[Any]: """ 返回当前拥有的显示对象;未启动或已停止时为 None。 + + :return: 当前资源拥有的显示对象 """ return self._display def start(self) -> None: """ 仅在容器环境启动虚拟显示,重复启动保持幂等。 + + 由显示后端分配空闲编号并管理 DISPLAY,避免与已有显示进程冲突。 """ if self._display is not None or not SystemUtils.is_docker(): return @@ -37,7 +43,6 @@ class VirtualDisplayResource: display = Display( visible=False, size=(1024, 768), - extra_args=[os.environ["DISPLAY"]], ) self._display = display display.start() diff --git a/tests/test_display_resource.py b/tests/test_display_resource.py index 3421bc402..846f5d850 100644 --- a/tests/test_display_resource.py +++ b/tests/test_display_resource.py @@ -4,8 +4,13 @@ from __future__ import annotations +import os import sys from types import ModuleType +from typing import Any, Optional +from unittest.mock import MagicMock + +import pytest from app.adapters.system.display import DisplayHelper from app.adapters.system.display.resource import VirtualDisplayResource @@ -15,6 +20,8 @@ from app.foundation.singleton import Singleton def test_virtual_display_skips_host_process_outside_docker(monkeypatch) -> None: """ 非容器环境启动资源时不得创建虚拟显示进程。 + + :param monkeypatch: 隔离容器环境判定的测试夹具 """ monkeypatch.setattr( "app.adapters.system.display.resource.SystemUtils.is_docker", @@ -31,6 +38,8 @@ def test_virtual_display_skips_host_process_outside_docker(monkeypatch) -> None: def test_virtual_display_starts_and_stops_owned_process(monkeypatch) -> None: """ 容器环境只停止当前资源实际拥有的显示进程。 + + :param monkeypatch: 隔离显示后端与容器环境的测试夹具 """ events: list[object] = [] @@ -40,6 +49,11 @@ def test_virtual_display_starts_and_stops_owned_process(monkeypatch) -> None: """ def __init__(self, **kwargs) -> None: + """ + 记录资源交给显示后端的启动参数。 + + :param kwargs: 显示后端的构造选项 + """ events.append(("create", kwargs)) def start(self) -> None: @@ -74,7 +88,6 @@ def test_virtual_display_starts_and_stops_owned_process(monkeypatch) -> None: { "visible": False, "size": (1024, 768), - "extra_args": [":99"], }, ), "start", @@ -83,9 +96,85 @@ def test_virtual_display_starts_and_stops_owned_process(monkeypatch) -> None: assert resource.display is None +@pytest.mark.parametrize("original_display", [":987", None]) +def test_virtual_display_allocates_number_and_restores_environment( + monkeypatch: pytest.MonkeyPatch, + original_display: Optional[str], +) -> None: + """ + 真实显示后端自动分配编号,重复启停后恢复原有或缺省的 DISPLAY。 + + 只替换 Xvfb 进程边界,保留命令构造、displayfd 管道和环境变量管理。 + + :param monkeypatch: 隔离进程启动、容器判定和环境变量的测试夹具 + :param original_display: 启动前的显示地址;None 表示未配置 DISPLAY + """ + commands: list[list[str]] = [] + processes: list[MagicMock] = [] + display_numbers = iter((1001, 1002)) + + def spawn_display( + command: list[str], *, pass_fds: list[int], **_kwargs: Any, + ) -> MagicMock: + """ + 模拟 Xvfb 通过 displayfd 回传分配编号并保留可停止的进程句柄。 + + :param command: 真实显示后端拼接的启动命令 + :param pass_fds: 交给 Xvfb 回传显示编号的管道描述符 + :param _kwargs: 子进程启动时保留的输出及 shell 选项 + :return: 供显示后端查询和停止的进程替身 + """ + commands.append(command) + process = MagicMock() + process.poll.return_value = None + process.communicate.return_value = (b"", b"") + processes.append(process) + os.write(pass_fds[0], f"{next(display_numbers)}\n".encode("ascii")) + return process + + monkeypatch.setattr( + "app.adapters.system.display.resource.SystemUtils.is_docker", lambda: True, + ) + monkeypatch.setattr( + "pyvirtualdisplay.abstractdisplay.get_helptext", lambda _program: "-displayfd", + ) + monkeypatch.setattr("pyvirtualdisplay.abstractdisplay.subprocess.Popen", spawn_display) + monkeypatch.setenv("PYVIRTUALDISPLAY_DISPLAYFD", "1") + if original_display is None: + monkeypatch.delenv("DISPLAY", raising=False) + else: + monkeypatch.setenv("DISPLAY", original_display) + resource = VirtualDisplayResource() + + try: + for display_number in (1001, 1002): + resource.start() + display = resource.display + resource.start() + + assert resource.display is display + assert os.environ["DISPLAY"] == f":{display_number}" + assert "-displayfd" in commands[-1] + assert not any(argument.startswith(":") for argument in commands[-1]) + + resource.stop() + resource.stop() + assert resource.display is None + assert os.environ.get("DISPLAY") == original_display + finally: + resource.stop() + + assert len(commands) == 2 + for process in processes: + process.kill.assert_called_once_with() + process.wait.assert_called_once_with() + + def test_display_helper_keeps_legacy_constructor_and_stop_contract(monkeypatch) -> None: """ 旧构造入口显式激活 host.display,stop 只停止已配置 Runtime。 + + :param monkeypatch: 隔离 Runtime 资源调用的测试夹具 """ events: list[tuple[str, str]] = [] singleton_key = (DisplayHelper, (), frozenset())