fix(display): 避免固定编号导致 Xvfb 启动冲突 (#7217)

移除将 DISPLAY 追加到 Xvfb 命令的参数,交由显示后端分配空闲编号。补充真实命令构造、显示环境恢复和重复启停的回归测试。

Refs #7212
This commit is contained in:
jxxghp
2026-10-27 13:03:48 +08:00
committed by GitHub
parent bde74da51d
commit 2c0ea1d0c6
2 changed files with 97 additions and 3 deletions

View File

@@ -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()

View File

@@ -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())