fix(pythonlib): handle headless='virtual' in launch_server

launch_server() is documented to take the same arguments as Camoufox(),
but passed headless='virtual' straight to launch_options(), so the server
launched with no Xvfb display. Start a VirtualDisplay the way Camoufox()
does, launch headful on it, and kill it when the server process exits or
the launch fails.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Jake Writer
2026-09-25 15:22:28 -06:00
co-authored by Claude Opus 5.5
parent d3d9d73c2d
commit fd99a0d85f
3 changed files with 94 additions and 4 deletions
+15 -1
View File
@@ -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))
+3 -3
View File
@@ -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:
+76
View File
@@ -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