Skip to content

fix: symlink libpython for MuJoCo mjpython on macOS with uv#1503

Merged
spomichter merged 6 commits intodevfrom
fix/mujoco-libpython-symlink
Mar 10, 2026
Merged

fix: symlink libpython for MuJoCo mjpython on macOS with uv#1503
spomichter merged 6 commits intodevfrom
fix/mujoco-libpython-symlink

Conversation

@SUMMERxYANG
Copy link
Contributor

Problem

MuJoCo's mjpython fails with Library not loaded: libpython3.12.dylib when Python is installed via uv. The lib exists in uv's managed directory but mjpython looks in .venv/lib/.

Solution

New LibPythonConfiguratorMacOS that auto-creates a symlink in the venv lib dir. Follows existing configurator pattern.

Breaking Changes

None

Contributor License Agreement

  • I have read and approved the CLA.

SUMMERxYANG and others added 2 commits March 9, 2026 21:45
When Python is installed via uv, mjpython fails because it expects
libpython at .venv/lib/ but uv places it in its own managed directory.
Add a LibPythonConfiguratorMacOS system configurator that auto-creates
the symlink during system setup before launching the MuJoCo subprocess.
@greptile-apps
Copy link
Contributor

greptile-apps bot commented Mar 10, 2026

Greptile Summary

This PR introduces LibPythonConfiguratorMacOS, a new SystemConfigurator subclass that resolves the Library not loaded: libpython3.12.dylib error when running MuJoCo's mjpython with a uv-managed Python environment. It follows the established configurator pattern (non-critical, check/explain/fix) and is wired into the existing lcm_configurators() macOS branch alongside MaxFileConfiguratorMacOS.

Key changes:

  • libpython.py: New configurator that resolves sys.executable to find the uv-managed dylib location and creates a symlink in venv_lib if missing. OSError is caught in fix() and the is_symlink() guard prevents a FileExistsError crash for already-symlinked paths.
  • __init__.py: LibPythonConfiguratorMacOS added to the macOS configurator list and exported in __all__.
  • test_lcmservice.py: macOS test updated to assert the new configurator is present at index 3.

One logic issue remains: the check() condition not target.exists() and not target.is_symlink() silently excludes broken symlinks from _missing, causing check() to return True (pass) even when a stale/dangling symlink exists. This means if a uv Python installation is moved or updated after the symlink was first created, the broken symlink will persist undetected and mjpython will continue to fail.

Confidence Score: 3/5

  • Safe to merge for the common case, but a logic gap causes broken symlinks to be silently ignored, leaving mjpython broken without any diagnostic or repair.
  • The configurator is non-critical and gracefully handles OSError, so it won't crash the application. However, the broken-symlink exclusion in check() introduces a silent false-positive: the exact scenario the configurator is meant to fix (stale symlink after a uv Python update) goes undetected and unrepaired. This is a correctness issue worth addressing before merge.
  • dimos/protocol/service/system_configurator/libpython.py — specifically the check() condition on line 52.

Important Files Changed

Filename Overview
dimos/protocol/service/system_configurator/libpython.py New configurator that creates libpython symlinks for MuJoCo mjpython on macOS. OSError and broken-symlink crash guards are addressed, but a logic issue remains: broken symlinks are excluded from _missing, so check() returns True (false positive) when a stale symlink exists, leaving mjpython broken without any fix being applied.
dimos/protocol/service/system_configurator/init.py Adds LibPythonConfiguratorMacOS to the macOS branch of lcm_configurators() and to all. Follows the same pattern as MaxFileConfiguratorMacOS; acknowledged as a short-term placement.
dimos/protocol/service/test_lcmservice.py Test updated to expect 4 macOS configurators and asserts the new LibPythonConfiguratorMacOS is at index 3. Change is correct and consistent with the init.py update.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A[configure_system called] --> B{platform == Darwin?}
    B -->|No| C[check returns True, skip]
    B -->|Yes| D[Resolve real_lib from sys.executable]
    D --> E[Glob libpython dylibs in real_lib]
    E --> F{For each dylib: target in venv_lib?}
    F -->|exists True| G[Skip, already present]
    F -->|exists False AND is_symlink True| H[Warning: skipped broken symlink, check returns True, mjpython still fails]
    F -->|exists False AND is_symlink False| I[Append to _missing]
    I --> J[check returns False]
    J --> K[explanation lists required symlinks]
    K --> L{User approves?}
    L -->|No| M[Exit, non-critical]
    L -->|Yes| N[fix called]
    N --> O[mkdir -p venv_lib]
    O --> P{is_symlink at target?}
    P -->|Yes| Q[unlink stale symlink]
    P -->|No| R[symlink_to real_path]
    Q --> R
    R --> S{OSError?}
    S -->|Yes| T[logger.warning, continue]
    S -->|No| U[logger.warning Created symlink]
Loading

Last reviewed commit: dd50ff7

@spomichter
Copy link
Contributor

@greptile

Comment on lines +50 to +53
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))
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.

target.exists() follows symlinks and returns False for dangling ones,
so the extra is_symlink() guard was silently skipping broken symlinks.
The fix() method already handles unlinking stale symlinks before
recreating them.
@spomichter spomichter merged commit 68632fb into dev Mar 10, 2026
12 checks passed
@spomichter spomichter deleted the fix/mujoco-libpython-symlink branch March 10, 2026 13:22
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants