Repository navigation
Description
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
dyoshikawa commentedon Oct 17, 2026
Triage note (2026-10-17): confirmed that rulesync reads and later rewrites
~/.config/muse/settings.jsonwithout 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:.settings.json.lockor say whether it is anflock(2)lock or a create-exclusive file.flock, Node has no built-in way to take it, so rulesync would need a native dependency or anflocksubprocess (which does not work on Windows).O_EXCLlockfile 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 generateshould not run while Muse is writing its settings.superbiche commentedon Oct 19, 2026
Verified on Linux with Muse Code 1.4.7 (
1.4.7-R6101.1): this settings writer uses an advisoryflock, 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 --jsonwhile Python heldfcntl.flock(..., LOCK_EX)on that file. Muse waited. Its/proc/<pid>/fd/4pointed to the same.settings.json.lock, and/proc/locksshowed both the holder and the waiting Muse process on the same inode:Before releasing the lock, the holder atomically changed the known
contributor_mode_enabledsetting fromfalsetotrue. After release, Muse exited 0, added its skill activation entry and retainedcontributor_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.
dyoshikawa commentedon Oct 21, 2026
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.fromRulesyncMcpandFeatureProcessor.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.dyoshikawa commentedon Oct 29, 2026
Status (2026-10-29): #3688 documents the limitation in the Meta Muse Code MCP note — Rulesync does not take
.settings.json.lock, sorulesync generate --globalformusecodeshould only run while no Muse Code process (session ormusesubcommand) is running. Thanks again for confirming theflockon 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.