Skip to content

musecode MCP writes settings.json without taking Muse’s .settings.json.lock #3498

Open

Description

@superbiche

Rulesync 36.0.2, reproduced from commit 18bb1e51608c482948a0db6aa7861345a7f87295. The relevant source files are unchanged on current upstream 67c5ff341c325305d9759bf749f2708f5fc395c4.

Muse self-writes ~/.config/muse/settings.json using the sibling advisory lock .settings.json.lock. Rulesync preserves unrelated settings in sequential use, but does not acquire that lock when generating global Muse MCP settings. A concurrent writer can therefore commit a stale snapshot and lose either writer’s update.

Reproduction

On Linux with Python 3 and Rulesync 36.0.2 available, save the following as repro.py and run python3 repro.py. It uses a disposable home and a simulated cooperating harness writer; it does not start a live Muse session or use the real home.

import fcntl
import json
import os
from pathlib import Path
import subprocess
import sys
import tempfile

# Optional arguments select a local Rulesync executable instead of PATH's rulesync.
command = sys.argv[1:] or ["rulesync"]
Path("tmp/tests/home").mkdir(parents=True, exist_ok=True)
with tempfile.TemporaryDirectory(prefix="muse-rulesync-", dir="tmp/tests/home") as tmp:
    home = Path(tmp).resolve()
    settings = home / ".config" / "muse" / "settings.json"
    settings.parent.mkdir(parents=True)
    settings.write_text(json.dumps({"schema_version": 1, "contributor_mode_enabled": False}))
    source = home / ".rulesync"
    source.mkdir()
    (source / "mcp.json").write_text(json.dumps({"mcpServers": {"example": {"command": "echo"}}}))
    config = home / "rulesync.jsonc"
    config.write_text("{}")
    env = dict(os.environ, HOME=str(home), HOME_DIR=str(home), XDG_CONFIG_HOME=str(home / ".config"))
    with settings.with_name(".settings.json.lock").open("a+") as lock:
        fcntl.flock(lock, fcntl.LOCK_EX)
        snapshot = json.loads(settings.read_text())
        result = subprocess.run(command + [
            "generate", "--global", "-t", "musecode", "-f", "mcp",
            "--input-roots", str(source), "--config", str(config),
        ], env=env, capture_output=True, text=True, timeout=20)
        assert result.returncode == 0, (result.stdout, result.stderr)
        assert "example" in json.loads(settings.read_text())["mcp_servers"]
        print("Rulesync wrote MCP and exited 0 while the settings lock was held")
        snapshot["contributor_mode_enabled"] = True
        replacement = settings.with_name("settings.json.tmp")
        replacement.write_text(json.dumps(snapshot))
        os.replace(replacement, settings)
        assert "mcp_servers" not in json.loads(settings.read_text())
        print("The simulated harness commit then lost the generated MCP block")

Observed: rulesync generate --global -t musecode -f mcp exits 0 and writes the MCP block while the other process still holds the settings lock. Its subsequent atomic commit removes that block. The reverse lost-update ordering is also possible from the source: Rulesync renders an earlier settings snapshot, then writes it after a harness update. The reproduction above demonstrates the first ordering only.

Expected behavior and PR offer

Could the shared-config write path acquire the harness-compatible advisory lock before reading the settings document, then hold it across parsing, applying the owned-key patch and committing the file? Muse-only coordination would already close this gap if a general mechanism is too broad. If the lock cannot be obtained, generation should wait with a bounded timeout or fail clearly without writing. Locking only the final write, or using an atomic replace alone, still permits stale snapshots.

The existing key-preserving JSON re-serialization is appropriate; this is a transaction-coordination problem. MusecodeMcp.fromRulesyncMcp reads and renders the document, while FeatureProcessor.writeAiFiles later persists the rendered content. The pure shared-config patch helper cannot protect that separated I/O by itself.

Happy to send a PR for the lock-scoped read/patch/write transaction with subprocess regression coverage for both interleavings, unrelated-key preservation, and lock release on failure. This concerns the existing MCP writer and is separate from the hooks/permissions support tracked in #2790.


@superbiche · user · drafted with GPT 6.1 Sol.

Activity

  1. dyoshikawa commented on Oct 17, 2026

    @dyoshikawa
    Owner

    Triage note (2026-10-17): confirmed that rulesync reads and later rewrites ~/.config/muse/settings.json without taking any lock. We are leaving the issue open for now, because the fix depends on how Muse's lock works, and that is not documented anywhere:

    • Muse Code's configuration and extending docs and its changelog mention a settings file lock, but never name .settings.json.lock or say whether it is an flock(2) lock or a create-exclusive file.
    • If it is flock, Node has no built-in way to take it, so rulesync would need a native dependency or an flock subprocess (which does not work on Windows).
    • If it is a lockfile that Muse leaves on disk, an O_EXCL lockfile on rulesync's side would conflict with it.

    If you know which mechanism Muse uses, please add it here. Otherwise the fallback is to document that rulesync generate should not run while Muse is writing its settings.

  2. superbiche commented on Oct 19, 2026

    @superbiche
    Author

    Verified on Linux with Muse Code 1.4.7 (1.4.7-R6101.1): this settings writer uses an advisory flock, on the sibling .settings.json.lock.

    I tested the real Muse executable against a disposable home containing a minimal user skill, then ran muse skills disable lock-probe --scope user --json while Python held fcntl.flock(..., LOCK_EX) on that file. Muse waited. Its /proc/<pid>/fd/4 pointed to the same .settings.json.lock, and /proc/locks showed both the holder and the waiting Muse process on the same inode:

    1: FLOCK  ADVISORY  WRITE 3835125 00:24:321790675 0 EOF
    1: -> FLOCK  ADVISORY  WRITE 3835126 00:24:321790675 0 EOF
    

    Before releasing the lock, the holder atomically changed the known contributor_mode_enabled setting from false to true. After release, Muse exited 0, added its skill activation entry and retained contributor_mode_enabled: true. No live home, credentials or model session were used.

    That establishes the Linux lock mechanism for this version, rather than just simulating a presumed protocol. Windows and other Muse versions remain unverified; this does not settle the portable Node implementation. I also re-ran the original held-lock Rulesync reproduction against 37.1.0: it still writes while the lock is held.


    @superbiche · user · drafted with GPT 6.1 Sol, reviewed before posting; the voice and decisions are mine.

  3. dyoshikawa commented on Oct 21, 2026

    @dyoshikawa
    Owner

    Status (2026-10-21, autonomous run): thanks for confirming the lock on a real Muse 1.4.7 binary. That settles which file Muse locks on Linux, so the gap is real. It stays open for a maintainer decision on the implementation, not on whether to fix it. Node.js has no built-in flock, so this needs either a native dependency or a different locking approach. The read and the write also happen in separate stages (MusecodeMcp.fromRulesyncMcp and FeatureProcessor.writeAiFiles), so the lock has to span both. An autonomous run does not add dependencies or restructure the write path. The maintainer will decide on the approach and on the PR you offered.

  4. dyoshikawa commented on Oct 29, 2026

    @dyoshikawa
    Owner

    Status (2026-10-29): #3688 documents the limitation in the Meta Muse Code MCP note — Rulesync does not take .settings.json.lock, so rulesync generate --global for musecode should only run while no Muse Code process (session or muse subcommand) is running. Thanks again for confirming the flock on Muse Code 1.4.7.

    Keeping this open for a real fix. A PR for the lock-scoped read/patch/write transaction is welcome, with one constraint: no native dependency, since that would also affect the Bun-compiled release binaries. If the lock cannot be taken portably, failing clearly with a message is better than writing without it.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions