Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions dimos/protocol/service/system_configurator/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
MulticastConfiguratorLinux,
MulticastConfiguratorMacOS,
)
from dimos.protocol.service.system_configurator.libpython import LibPythonConfiguratorMacOS


# TODO: This is a configurator API issue and inserted here temporarily
Expand All @@ -56,6 +57,7 @@ def lcm_configurators() -> list[SystemConfigurator]:
MulticastConfiguratorMacOS(loopback_interface="lo0"),
BufferConfiguratorMacOS(),
MaxFileConfiguratorMacOS(), # TODO: this is not LCM related and shouldn't be here at all
LibPythonConfiguratorMacOS(),
]
return []

Expand All @@ -65,6 +67,7 @@ def lcm_configurators() -> list[SystemConfigurator]:
"BufferConfiguratorLinux",
"BufferConfiguratorMacOS",
"ClockSyncConfigurator",
"LibPythonConfiguratorMacOS",
"MaxFileConfiguratorMacOS",
"MulticastConfiguratorLinux",
"MulticastConfiguratorMacOS",
Expand Down
76 changes: 76 additions & 0 deletions dimos/protocol/service/system_configurator/libpython.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
# Copyright 2026 Dimensional Inc.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

"""Ensure libpython is available in the venv for MuJoCo's mjpython on macOS.
When Python is installed via uv, mjpython fails because it expects
libpython at .venv/lib/ but uv places it in its own managed directory.
This configurator creates a symlink so mjpython can find the library.
"""

from __future__ import annotations

import logging
from pathlib import Path
import platform
import sys

from dimos.protocol.service.system_configurator.base import SystemConfigurator

logger = logging.getLogger(__name__)


class LibPythonConfiguratorMacOS(SystemConfigurator):
"""Create a libpython symlink in the venv lib dir if missing (macOS only)."""

critical = False

def __init__(self) -> None:
self._missing: list[tuple[Path, Path]] = [] # (symlink_target, real_dylib)

def check(self) -> bool:
if platform.system() != "Darwin":
return True

self._missing.clear()
venv_lib = Path(sys.prefix) / "lib"
real_lib = Path(sys.executable).resolve().parent.parent / "lib"

for dylib in real_lib.glob("libpython*.dylib"):
target = venv_lib / dylib.name
if not target.exists():
self._missing.append((target, dylib))
Comment on lines +50 to +53
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Broken symlinks silently pass check(), leaving mjpython broken

When a broken symlink exists at target (e.g., after uv updates/moves the managed Python installation), target.exists() returns False but target.is_symlink() returns True. The current condition excludes it from _missing, so check() returns True (appears OK) — but mjpython will still fail because the symlink is dangling.

The guard in fix() (if symlink_path.is_symlink(): symlink_path.unlink()) is effectively dead code under the normal check()fix() flow, since broken symlinks are never added to _missing.

To also detect and repair broken symlinks, change the condition to:

Suggested change
for dylib in real_lib.glob("libpython*.dylib"):
target = venv_lib / dylib.name
if not target.exists() and not target.is_symlink():
self._missing.append((target, dylib))
for dylib in real_lib.glob("libpython*.dylib"):
target = venv_lib / dylib.name
if not target.exists():
self._missing.append((target, dylib))

This restores the original intent: exists() returns False for both absent paths and broken symlinks, so both cases are queued for repair. The existing if symlink_path.is_symlink(): symlink_path.unlink() guard in fix() already handles the unlink-before-recreate scenario correctly.


return not self._missing

def explanation(self) -> str | None:
if not self._missing:
return None
lines = []
for symlink_path, real_path in self._missing:
lines.append(f"- Symlink {symlink_path} -> {real_path} (for mjpython)")
return "\n".join(lines)

def fix(self) -> None:
for symlink_path, real_path in self._missing:
try:
symlink_path.parent.mkdir(parents=True, exist_ok=True)
if symlink_path.is_symlink():
symlink_path.unlink()
symlink_path.symlink_to(real_path)
logger.warning("Created symlink %s -> %s for mjpython", symlink_path, real_path)
except OSError as error:
logger.warning(
"Failed to create symlink %s -> %s: %s", symlink_path, real_path, error
)
4 changes: 3 additions & 1 deletion dimos/protocol/service/test_lcmservice.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
from dimos.protocol.service.system_configurator import (
BufferConfiguratorLinux,
BufferConfiguratorMacOS,
LibPythonConfiguratorMacOS,
MaxFileConfiguratorMacOS,
MulticastConfiguratorLinux,
MulticastConfiguratorMacOS,
Expand Down Expand Up @@ -56,10 +57,11 @@ def test_creates_macos_checks_on_darwin(self) -> None:
autoconf()
mock_configure.assert_called_once()
checks = mock_configure.call_args[0][0]
assert len(checks) == 3
assert len(checks) == 4
assert isinstance(checks[0], MulticastConfiguratorMacOS)
assert isinstance(checks[1], BufferConfiguratorMacOS)
assert isinstance(checks[2], MaxFileConfiguratorMacOS)
assert isinstance(checks[3], LibPythonConfiguratorMacOS)
assert checks[0].loopback_interface == "lo0"

def test_passes_check_only_flag(self) -> None:
Expand Down
Loading