fix(auto-save-guard): refuse only on content divergence, not mtime (#172)
* feat: guard SWIG auto-save against external file changes
After every board-mutating SWIG command, kicad_interface._auto_save_board()
unconditionally calls pcbnew.SaveBoard() with the in-memory board. When the
on-disk .kicad_pcb has been modified externally between our LoadBoard and
SaveBoard (KiCad GUI's own save, git checkout, another process), the
in-memory state silently overwrites those external changes - losing data
the user can't see was at risk.
This change records the file's mtime_ns + sha256 at LoadBoard and verifies
the signature matches before each auto-save:
* If the signature has diverged, refuse the save and attach a structured
warning to the command result so callers know their mutation is
in-memory only and they need to reload before retrying.
* If it matches, copy the existing file to .mcp-backups/<name>.<ts>
(rotating, keeps last 20) before overwriting.
* Update the recorded signature after our own writes so subsequent
saves are not falsely flagged.
Backwards compatible:
* No tool schemas changed.
* Successful saves return as before, with an extra `autoSave` field
when the wrapper observed something noteworthy.
* Refused saves return success: true (the in-memory mutation did
succeed) plus warnings: [...] and autoSave.diskChangedExternally,
so callers can detect the situation programmatically.
Adds tests/test_auto_save_guard.py (10 tests, all passing) covering:
signature math, refusal on external change, backup creation + content,
backup rotation, first-save semantics (no recorded signature proceeds
normally), and skip cases (no board / no path).
Motivation: the aircam-pdb fork-user lost ~480 traces and the full
footprint layout to a silent overwrite incident on 2026-05-03; recovery
was only possible because VS Code's local-history extension happened to
have a snapshot from a few minutes earlier. This guard makes that class
of incident loud and locally recoverable.
* fix(auto-save-guard): refuse only on content divergence, not mtime
The guard added in 9ba0010 records `(mtime_ns, sha256)` as the file's
disk signature and refuses auto-save when the recorded tuple no longer
matches the current one. Comparing the full tuple meant any mtime delta
fired the refusal — including a bare `touch` of the file, an atime-style
backup tool, or any MCP read path that opened the .kicad_pcb between
load and save. Users were trapped: every write needed an explicit
save_project call to bypass the false positive (documented as a
workaround in fork users' notes).
Compare on sha256 only; mtime is incidental. The actual data-loss
scenario the guard is meant to catch — an external write that genuinely
changed the file — produces a different content hash, which is what the
guard now keys off. After a touch-only mtime advance with content
unchanged, refresh the recorded signature so we don't re-hash on every
subsequent call.
Drops the mtime-equality fast path on _disk_signature: a filesystem with
coarse mtime resolution (FAT32, some network mounts) could accept two
writes inside one mtime tick; trusting mtime as a hash cache key would
re-introduce a class of silent overwrites the guard exists to prevent.
The hash itself is cheap (sha256 over a typical .kicad_pcb completes in
tens of milliseconds).
Adds 4 regression tests in test_auto_save_guard.py:
- touch-only mtime advance proceeds and refreshes the signature
- content change at the same mtime is still refused (hash divergence
must drive the decision, not tuple equality)
- the user-facing warning calls out "contents", not "mtime"
- _disk_signature returns the same hash when content is unchanged
even after the file's mtime advances
This commit is contained in:
@@ -624,7 +624,15 @@ class KiCADInterface:
|
||||
|
||||
@staticmethod
|
||||
def _disk_signature(path: str) -> Optional[Tuple[int, str]]:
|
||||
"""Return (mtime_ns, sha256_hex) for the file, or None if missing/unreadable."""
|
||||
"""Return (mtime_ns, sha256_hex) for the file, or None if missing/unreadable.
|
||||
|
||||
The sha256 is always recomputed from disk: the conflict guard in
|
||||
``_auto_save_board`` compares hashes (content), not mtime, so we
|
||||
cannot use mtime as a cache key without re-introducing the bug
|
||||
where two writes inside one mtime tick on a coarse-resolution
|
||||
filesystem (FAT32, network mounts, etc.) would mask a real
|
||||
content change.
|
||||
"""
|
||||
try:
|
||||
st = os.stat(path)
|
||||
h = hashlib.sha256()
|
||||
@@ -702,19 +710,27 @@ class KiCADInterface:
|
||||
expected = self._board_disk_signature
|
||||
current = self._disk_signature(board_path)
|
||||
|
||||
# If we have a recorded signature and disk has diverged, refuse to save.
|
||||
# (If expected is None we treat this as "first save" and proceed —
|
||||
# otherwise users with pre-existing setups would never be able to save.)
|
||||
if expected is not None and current is not None and expected != current:
|
||||
# Only refuse if the file's CONTENT (sha256) has actually diverged
|
||||
# from what we recorded. mtime alone is not a conflict signal —
|
||||
# `touch`, atime-driven backups, or even some MCP read paths can
|
||||
# advance mtime without changing content, and refusing on that
|
||||
# basis traps users in a state where every write needs an explicit
|
||||
# save_project workaround.
|
||||
#
|
||||
# If expected is None, treat this as "first save" and proceed —
|
||||
# otherwise pre-existing setups (open_project ran before this guard
|
||||
# was introduced) would never be able to save.
|
||||
if expected is not None and current is not None and expected[1] != current[1]:
|
||||
warning = (
|
||||
"Auto-save refused: the on-disk PCB file changed externally "
|
||||
"since this MCP session loaded it. To avoid clobbering those "
|
||||
"changes, the in-memory mutation has NOT been written to disk. "
|
||||
"Reload via open_project to refresh, then re-apply the change."
|
||||
"Auto-save refused: the on-disk PCB file's contents changed "
|
||||
"externally since this MCP session loaded it. To avoid "
|
||||
"clobbering those changes, the in-memory mutation has NOT "
|
||||
"been written to disk. Reload via open_project to refresh, "
|
||||
"then re-apply the change."
|
||||
)
|
||||
logger.warning(f"{warning} ({board_path})")
|
||||
logger.warning(f" expected mtime_ns={expected[0]} sha256={expected[1][:12]}…")
|
||||
logger.warning(f" current mtime_ns={current[0]} sha256={current[1][:12]}…")
|
||||
logger.warning(f" expected sha256={expected[1][:12]}… mtime_ns={expected[0]}")
|
||||
logger.warning(f" current sha256={current[1][:12]}… mtime_ns={current[0]}")
|
||||
return {
|
||||
"saved": False,
|
||||
"warning": warning,
|
||||
@@ -725,6 +741,11 @@ class KiCADInterface:
|
||||
"memChangesUnsaved": True,
|
||||
}
|
||||
|
||||
# Content matches but mtime advanced (e.g. external `touch`): refresh
|
||||
# the recorded mtime so we don't re-hash on every subsequent call.
|
||||
if expected is not None and current is not None and expected != current:
|
||||
self._board_disk_signature = current
|
||||
|
||||
# Make a rotating backup of the existing file (best-effort).
|
||||
backup_path: Optional[str] = None
|
||||
if current is not None:
|
||||
|
||||
Reference in New Issue
Block a user