diff --git a/pythonlib/camoufox/server.py b/pythonlib/camoufox/server.py index c59a56b..669a676 100644 --- a/pythonlib/camoufox/server.py +++ b/pythonlib/camoufox/server.py @@ -8,6 +8,7 @@ from playwright._impl._driver import compute_driver_executable from camoufox.pkgman import LOCAL_DATA from camoufox.utils import launch_options +from camoufox.virtdisplay import VirtualDisplay LAUNCH_SCRIPT: Path = LOCAL_DATA / "launchServer.js" @@ -60,7 +61,20 @@ def launch_server(**kwargs) -> NoReturn: ) kwargs.pop(unsupported, None) - config = launch_options(**kwargs) + virtual_display = None + if kwargs.get('headless') == 'virtual': + virtual_display = VirtualDisplay(debug=kwargs.get('debug')) + kwargs['virtual_display'] = virtual_display.get() + kwargs['headless'] = False + try: + _serve(launch_options(**kwargs)) + finally: + if virtual_display: + virtual_display.kill() + + +def _serve(config: Dict[str, Any]) -> NoReturn: + """Run launchServer.js with `config` until the Node process exits.""" nodejs = get_nodejs() data = orjson.dumps(to_camel_case_dict(config)) diff --git a/pythonlib/camoufox/utils.py b/pythonlib/camoufox/utils.py index e792414..317bb0b 100644 --- a/pythonlib/camoufox/utils.py +++ b/pythonlib/camoufox/utils.py @@ -892,8 +892,8 @@ def launch_options( To prevent leaks, only use this for special cases. headless (Optional[bool]): Whether to run the browser in headless mode. Defaults to False. - Note: If you are running linux, passing headless='virtual' to Camoufox & AsyncCamoufox - will use Xvfb. + Note: If you are running linux, passing headless='virtual' to Camoufox, AsyncCamoufox + or launch_server will use Xvfb. main_world_eval (Optional[bool]): Whether to enable running scripts in the main world. To use this, prepend "mw:" to the script: page.evaluate("mw:" + script). @@ -921,7 +921,7 @@ def launch_options( debug (Optional[bool]): Prints the config being sent to Camoufox. virtual_display (Optional[str]): - Virtual display number. Ex: ':99'. This is handled by Camoufox & AsyncCamoufox. + Virtual display number. Ex: ':99'. This is handled by Camoufox, AsyncCamoufox and launch_server. pin_cpu_cores (Optional[bool]): Pin the browser to navigator.hardwareConcurrency cores (Linux/Windows) so the fingerprint's own core count can be kept: diff --git a/pythonlib/tests/test_server.py b/pythonlib/tests/test_server.py index 507ae17..6e0fb84 100644 --- a/pythonlib/tests/test_server.py +++ b/pythonlib/tests/test_server.py @@ -275,3 +275,79 @@ def test_launch_server_surfaces_child_exit_instead_of_pipe_error(monkeypatch, tm server.launch_server() assert "3" in str(excinfo.value), str(excinfo.value) + + +class _FakeVirtualDisplay: + instances = [] + + def __init__(self, debug=None): + self.debug = debug + self.killed = False + _FakeVirtualDisplay.instances.append(self) + + def get(self): + return ":99" + + def kill(self): + self.killed = True + + +class _ExitedProcess: + def __init__(self): + self.stdin = open(os.devnull, "w") + self.returncode = 0 + + def poll(self): + return self.returncode + + def wait(self, timeout=None): + return self.returncode + + +@pytest.fixture +def fake_virtual_display(monkeypatch): + _FakeVirtualDisplay.instances = [] + monkeypatch.setattr(server, "VirtualDisplay", _FakeVirtualDisplay, raising=False) + monkeypatch.setattr(server, "get_nodejs", lambda: "/node") + return _FakeVirtualDisplay.instances + + +def test_launch_server_runs_virtual_headless_on_a_virtual_display( + monkeypatch, fake_virtual_display +): + # headless='virtual' is a Camoufox() option, not a Playwright one: the + # server must start Xvfb, launch headful on it, and kill it when the + # server process exits. + launched = {} + + def fake_launch_options(**kwargs): + launched.update(kwargs) + return {} + + monkeypatch.setattr(server, "launch_options", fake_launch_options) + monkeypatch.setattr( + server.subprocess, "Popen", lambda *args, **kwargs: _ExitedProcess() + ) + + with pytest.raises(RuntimeError): + server.launch_server(headless="virtual") + + assert len(fake_virtual_display) == 1 + assert launched["headless"] is False + assert launched["virtual_display"] == ":99" + assert fake_virtual_display[0].killed + + +def test_launch_server_kills_virtual_display_when_launch_fails( + monkeypatch, fake_virtual_display +): + def failing_launch_options(**kwargs): + raise ValueError("invalid options") + + monkeypatch.setattr(server, "launch_options", failing_launch_options) + + with pytest.raises(ValueError, match="invalid options"): + server.launch_server(headless="virtual") + + assert len(fake_virtual_display) == 1 + assert fake_virtual_display[0].killed