diff --git a/CHANGELOG.md b/CHANGELOG.md index 05d2c912be..0535dcf4ee 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,7 @@ - Bedrock: Assistant text and reasoning are now preserved alongside tool calls in the same turn instead of being dropped. (#4457) - Bugfix: With checkpoints stored on S3, a resumed sample can now itself be resumed — previously a crash after resume lost the checkpoint chain and the next retry restarted the sample from scratch. - Bugfix: Crash recovery now reconstructs `sample.messages` from the first model role's conversation when a solver runs multiple agents concurrently, instead of returning whichever agent's model call happened to fire last. (#4414) +- Web Browser: headful macOS scale-factor detection falls back to 1 instead of crashing when `pyobjc-framework-AppKit` is missing or no display is attached. - Bugfix: Errored samples that were scored (e.g. via `score_on_error`) now contribute their scores to metrics and `scored_samples`, matching what the sample log shows. (#4412) - Bugfix: Several raised errors (unexpected Anthropic input block, unsupported image data type, missing sample-buffer database) now interpolate the offending value into the message instead of showing the literal placeholder text. - Bugfix: Native compaction now truncates an oversized tool output before summarizing instead of crashing or silently summarizing an error when it would exceed the model's context window. (#3600) diff --git a/src/inspect_tool_support/src/inspect_tool_support/_remote_tools/_web_browser/scale_factor.py b/src/inspect_tool_support/src/inspect_tool_support/_remote_tools/_web_browser/scale_factor.py index 339c5647c4..a499a01ee5 100644 --- a/src/inspect_tool_support/src/inspect_tool_support/_remote_tools/_web_browser/scale_factor.py +++ b/src/inspect_tool_support/src/inspect_tool_support/_remote_tools/_web_browser/scale_factor.py @@ -1,6 +1,19 @@ +import logging import subprocess import sys +logger = logging.getLogger(__name__) + + +def warn_once(logger: logging.Logger, message: str) -> None: + """Emit ``message`` as a warning, but only the first time it is seen this process.""" + if message not in _warned: + logger.warning(message) + _warned.add(message) + + +_warned: set[str] = set() + # Playwright launches Chromium with --force-device-scale-factor=1 by default, which # ensures consistent rendering and measurement behavior using CSS pixels instead of # native device pixels, regardless of the actual display's DPI. On HiDPI displays, @@ -19,12 +32,29 @@ # allows the code to accurately calculate node visibility. -def get_screen_scale_factor() -> float: - if sys.platform == "darwin": +def _darwin_scale_factor() -> float: + try: # `pip install pyobjc-framework-AppKit` is required to run headfully on macOS from AppKit import NSScreen # type: ignore # noqa: PLC0415 - return NSScreen.mainScreen().backingScaleFactor() + screen = NSScreen.mainScreen() + except ModuleNotFoundError: + warn_once( + logger, + "pyobjc-framework-AppKit is not installed; falling back to a screen " + "scale factor of 1. Install it (`pip install pyobjc-framework-AppKit`) " + "to enable HiDPI scale-factor detection when running headfully on macOS.", + ) + return 1.0 + except Exception: + return 1.0 + # mainScreen() is None when no display is attached (headless / over SSH) + return float(screen.backingScaleFactor()) if screen is not None else 1.0 + + +def get_screen_scale_factor() -> float: + if sys.platform == "darwin": + return _darwin_scale_factor() elif sys.platform == "win32": try: # Using GetDpiForSystem from Windows API diff --git a/src/inspect_tool_support/src/inspect_tool_support/_remote_tools/_web_browser/test_scale_factor.py b/src/inspect_tool_support/src/inspect_tool_support/_remote_tools/_web_browser/test_scale_factor.py new file mode 100644 index 0000000000..fbcb17a214 --- /dev/null +++ b/src/inspect_tool_support/src/inspect_tool_support/_remote_tools/_web_browser/test_scale_factor.py @@ -0,0 +1,37 @@ +import sys +import types + +from scale_factor import get_screen_scale_factor + + +def _install_fake_appkit(monkeypatch, screen: object) -> None: + """Put a fake ``AppKit`` on ``sys.modules`` whose ``mainScreen()`` returns ``screen``.""" + module = types.ModuleType("AppKit") + module.NSScreen = types.SimpleNamespace(mainScreen=lambda: screen) # type: ignore[attr-defined] + monkeypatch.setitem(sys.modules, "AppKit", module) + + +def test_darwin_returns_backing_scale_factor(monkeypatch): + monkeypatch.setattr(sys, "platform", "darwin") + _install_fake_appkit( + monkeypatch, types.SimpleNamespace(backingScaleFactor=lambda: 2.0) + ) + assert get_screen_scale_factor() == 2.0 + + +def test_darwin_headless_screen_falls_back_to_one(monkeypatch): + # NSScreen.mainScreen() is None when no display is attached (headless / over SSH); + # dereferencing it used to raise AttributeError instead of defaulting to 1.0. + monkeypatch.setattr(sys, "platform", "darwin") + _install_fake_appkit(monkeypatch, None) + assert get_screen_scale_factor() == 1.0 + + +def test_darwin_missing_appkit_falls_back_to_one(monkeypatch): + # pyobjc-framework-AppKit is an optional dependency; a missing import used to crash + # the probe instead of degrading like the Windows and Linux branches do. + monkeypatch.setattr(sys, "platform", "darwin") + monkeypatch.setitem( + sys.modules, "AppKit", None + ) # `from AppKit import ...` -> ImportError + assert get_screen_scale_factor() == 1.0