diff --git a/dm_control/_render/glfw_renderer.py b/dm_control/_render/glfw_renderer.py index 301e40a0..e0afe63b 100644 --- a/dm_control/_render/glfw_renderer.py +++ b/dm_control/_render/glfw_renderer.py @@ -15,6 +15,8 @@ """An OpenGL renderer backed by GLFW.""" +import sys + from dm_control._render import base from dm_control._render import executor @@ -24,10 +26,29 @@ import glfw # pylint: disable=g-import-not-at-top except (ImportError, IOError, OSError) as exc: raise ImportError from exc -try: - glfw.init() -except glfw.GLFWError as exc: - raise ImportError from exc + + +def _init_glfw(): + """Calls `glfw.init()`, re-raising failures as `ImportError`. + + `glfwInit` is a no-op if GLFW has already been initialized, so this is safe + to call multiple times. + """ + try: + glfw.init() + except glfw.GLFWError as exc: + raise ImportError from exc + + +# On macOS, initializing GLFW at import time disrupts the shared NSApplication +# state used by other GUI toolkits, most notably matplotlib's default 'MacOSX' +# backend (https://github.com/google-deepmind/dm_control/issues/377), so we +# defer initialization until a context is actually created. On other platforms +# we initialize at import time so that the automatic backend selection in +# `dm_control/_render/__init__.py` can still fall back to EGL/OSMesa when GLFW +# cannot create a context (e.g. on headless Linux machines). +if sys.platform != 'darwin': + _init_glfw() class GLFWContext(base.ContextBase): @@ -37,6 +58,7 @@ def __init__(self, max_width, max_height): # GLFWContext always uses `PassthroughRenderExecutor` rather than offloading # rendering calls to a separate thread because GLFW can only be safely used # from the main thread. + _init_glfw() super().__init__(max_width, max_height, executor.PassthroughRenderExecutor) def _platform_init(self, max_width, max_height): diff --git a/dm_control/_render/glfw_renderer_test.py b/dm_control/_render/glfw_renderer_test.py index b599690b..05fad88b 100644 --- a/dm_control/_render/glfw_renderer_test.py +++ b/dm_control/_render/glfw_renderer_test.py @@ -15,6 +15,11 @@ """Tests for GLFWContext.""" +import os +import re +import subprocess +import sys +import textwrap import unittest from absl.testing import absltest from dm_control import _render @@ -64,5 +69,74 @@ def test_repeatedly_create_and_destroy_context(self): renderer = _render.Renderer(MAX_WIDTH, MAX_HEIGHT) wrapper.MjrContext(wrapper.MjModel.from_xml_string(''), renderer) + +_COUNT_INIT_CALLS_SCRIPT = textwrap.dedent(""" + import sys + import types + sys.platform = {platform} + stub = types.ModuleType('glfw') + stub.GLFWError = type('GLFWError', (Exception,), {{}}) + stub.VISIBLE = 1 + stub.DOUBLEBUFFER = 2 + stub._init_calls = [] + stub.init = lambda: stub._init_calls.append(1) + stub.window_hint = lambda *args, **kwargs: None + stub.create_window = lambda **kwargs: object() + stub.make_context_current = lambda window: None + stub.get_current_context = lambda: None + stub.destroy_window = lambda window: None + sys.modules['glfw'] = stub + import dm_control._render.glfw_renderer as glfw_renderer + print('INIT_AT_IMPORT', len(stub._init_calls)) + glfw_renderer.GLFWContext(max_width=64, max_height=64) + print('INIT_AFTER_CONTEXT', len(stub._init_calls)) +""") + + +class GlfwInitAtImportTest(absltest.TestCase): + """Tests when `glfw.init()` runs relative to importing `glfw_renderer`. + + Initializing GLFW at import time interferes with other GUI toolkits that + share the process's NSApplication on macOS, most notably matplotlib's + default 'MacOSX' backend (https://github.com/google-deepmind/dm_control/ + issues/377), so on macOS the initialization must be deferred until a + `GLFWContext` is actually created. On other platforms it must still happen + at import time so that the automatic backend selection in + `dm_control/_render/__init__.py` can fall back to EGL/OSMesa when GLFW + cannot create a context (e.g. on headless Linux machines). + """ + + def _run_with_stubbed_glfw(self, forced_platform): + script = _COUNT_INIT_CALLS_SCRIPT.format(platform=repr(forced_platform)) + env = dict(os.environ, MUJOCO_GL='glfw') + completed_process = subprocess.run( + args=[sys.executable, '-c', script], + env=env, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + # The stub module injected here only defines the attributes that + # `glfw_renderer` accesses at import time and at initialization time. + timeout=120, + check=True, + text=True) + init_at_import = re.search(r'^INIT_AT_IMPORT (\d+)$', + completed_process.stdout, re.M) + init_after_context = re.search(r'^INIT_AFTER_CONTEXT (\d+)$', + completed_process.stdout, re.M) + assert init_at_import and init_after_context, ( + completed_process.stdout + completed_process.stderr) + return int(init_at_import.group(1)), int(init_after_context.group(1)) + + def test_defers_initialization_on_macos(self): + init_at_import, init_after_context = self._run_with_stubbed_glfw('darwin') + self.assertEqual(init_at_import, 0) + self.assertEqual(init_after_context, 1) + + def test_initializes_at_import_on_other_platforms(self): + init_at_import, init_after_context = self._run_with_stubbed_glfw('linux') + self.assertEqual(init_at_import, 1) + self.assertEqual(init_after_context, 2) + + if __name__ == '__main__': absltest.main()