fix(task-21): registry identity is normcase(abspath) everywhere — a case variant reloads instead of registering twice, resolves instead of being refused, and drawing_close(save) checks the file it writes

aeab950 claimed one registry entry per file but only the two save paths
compared with normcase. _register_document keyed by the plain
os.path.abspath string, so on Windows drawing_open of an already-open
file spelled with another case registered a second entry (no `reloaded`,
open_documents: 2); from there every document_activate/close of either
entry was refused as ambiguous, and drawing_close(save=True) — which
bypasses the resolver — wrote the second entry's bytes to the shared
file while the surviving row reported saved: True. The resolver's
by_path route had the same plain-string comparison, so over the wire a
lowercase drive letter was refused 'no open document named ...' while
ComBackend._com_find_document lowercases and accepts it.

One helper, _file_identity(path) = normcase(abspath(path)), now decides
every registry comparison: _register_document consults _path_holder so
another spelling of an open file reloads it; the _doc_path setter re-keys
only when the file changes, so a case-variant save keeps the key a caller
holds; _resolve_document_key's by_path compares identities;
_close_document_sync refuses at the write when another entry holds the
file; _clear_quarantine's same-path warning uses the same rule. Keys stay
spelled as the caller spelled them — the identity is for comparing, not
for showing.

Five tests pin it (four skip on a case-sensitive filesystem, where two
spellings of one file cannot exist), including the wire route through
fastmcp.Client that the review measured.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
This commit is contained in:
Umut CAN
2026-09-16 21:51:16 +03:00
co-authored by Claude Opus 5
parent aeab950d2d
commit 2805697d16
2 changed files with 162 additions and 24 deletions
+53 -24
View File
@@ -1103,26 +1103,48 @@ class EzdxfBackend(AutoCADBackend):
)
state = self._state
state.path = value
if new_key != self._active_key:
# Re-key only when the *file* changes: another spelling of the file
# the entry already holds (a case variant on Windows) keeps the key a
# caller may be holding.
if not self._same_file(new_key, self._active_key):
del self._docs[self._active_key]
state.key = new_key
self._docs[new_key] = state
self._active_key = new_key
def _path_holder(self, abs_path: str, *, exclude: str | None = None) -> str | None:
"""Key of the entry (other than ``exclude``) that holds ``abs_path``, or None.
@staticmethod
def _file_identity(path: str) -> str:
"""The string two spellings of one file share: ``normcase(abspath(path))``.
Compared with ``os.path.normcase`` so that on Windows two spellings of
one file count as the same file; on POSIX that is plain equality.
Every registry comparison goes through this. On Windows it folds case
and separators, so ``C:\\Work\\Gear.dxf`` and ``c:/work/gear.dxf`` are one
file; on POSIX ``normcase`` is the identity and only ``..`` / ``.`` /
duplicate separators are folded. Keys themselves stay spelled as the
caller spelled them - the identity is for comparing, not for showing.
"""
wanted = os.path.normcase(abs_path)
try:
return os.path.normcase(os.path.abspath(path))
except (OSError, ValueError): # a path this OS cannot normalise
return os.path.normcase(path)
@classmethod
def _same_file(cls, a: str, b: str) -> bool:
return cls._file_identity(a) == cls._file_identity(b)
def _path_holder(self, path: str, *, exclude: str | None = None) -> str | None:
"""Key of the entry (other than ``exclude``) that holds ``path``, or None.
Compared by ``_file_identity`` so that on Windows two spellings of one
file count as the same file; on POSIX that is plain equality.
"""
wanted = self._file_identity(path)
for key, state in self._docs.items():
if key == exclude:
continue
candidates = [key]
if state.path:
candidates.append(os.path.abspath(state.path))
if any(os.path.normcase(c) == wanted for c in candidates):
candidates.append(state.path)
if any(self._file_identity(c) == wanted for c in candidates):
return key
return None
@@ -1132,7 +1154,7 @@ class EzdxfBackend(AutoCADBackend):
Runs before ``doc.saveas`` so a refusal leaves the disk, the registry
and the active document exactly as they were.
"""
holder = self._path_holder(os.path.abspath(path), exclude=self._active_key)
holder = self._path_holder(path, exclude=self._active_key)
if holder is None:
return
label = self._docs[holder].label
@@ -1273,7 +1295,10 @@ class EzdxfBackend(AutoCADBackend):
Returns ``(key, reloaded)``. A path that is already open is *reloaded*:
its old entry (and its snapshots) is dropped and the fresh read takes
its place — the pre-registry behaviour of ``drawing_open``, kept so a
save-then-reopen verification still reads the disk.
save-then-reopen verification still reads the disk. "Already open" is
decided by ``_path_holder`` (file identity), not by the key string:
the same file spelled with another case used to register a second
entry, after which every lookup of either was refused as ambiguous.
"""
if path is None:
self._untitled_counter += 1
@@ -1281,10 +1306,12 @@ class EzdxfBackend(AutoCADBackend):
reloaded = False
else:
key = os.path.abspath(path)
previous = self._docs.pop(key, None)
reloaded = previous is not None
if previous is not None:
previous.discard_snapshots()
previous_key = self._path_holder(key)
reloaded = previous_key is not None
if previous_key is not None:
self._docs.pop(previous_key).discard_snapshots()
if self._active_key == previous_key:
self._active_key = None
self._docs[key] = _DocState(key=key, doc=doc, path=path)
self._activate_key(key)
return key, reloaded
@@ -1317,14 +1344,14 @@ class EzdxfBackend(AutoCADBackend):
# say so rather than hand back whichever entry the key names.
self._refuse_shared_path(wanted, self._docs[wanted])
return wanted
try:
as_path = os.path.abspath(wanted)
except (OSError, ValueError):
as_path = wanted
# Paths compare by file identity (``normcase`` on Windows), as the
# live engine's ``_com_find_document`` lowercases before comparing: a
# lowercase drive letter or a case variant names the same document.
as_path = self._file_identity(wanted)
by_path = [
key
for key, state in self._docs.items()
if state.path and os.path.abspath(state.path) == as_path
if state.path and self._file_identity(state.path) == as_path
]
if len(by_path) == 1:
self._refuse_shared_path(wanted, self._docs[by_path[0]])
@@ -1355,7 +1382,7 @@ class EzdxfBackend(AutoCADBackend):
if not state.path:
return
abs_path = os.path.abspath(state.path)
other = self._path_holder(abs_path, exclude=state.key)
other = self._path_holder(state.path, exclude=state.key)
if other is not None:
raise ValueError(
f"document {wanted!r} is ambiguous: entries {sorted([state.key, other])} "
@@ -1407,6 +1434,11 @@ class EzdxfBackend(AutoCADBackend):
f"document_close: {label!r} has never been saved, so save=True has "
"nowhere to write; drawing_save_as(path) first, or pass discard=True"
)
# ``drawing_close`` reaches here without the resolver, so the
# one-entry-per-file check is made at the write itself: a
# save onto a file another entry holds would leave that
# entry claiming ``saved: True`` over someone else's bytes.
self._refuse_shared_path(label, state)
state.doc.saveas(state.path)
state.dirty = False
saved = True
@@ -1554,10 +1586,7 @@ class EzdxfBackend(AutoCADBackend):
record.call,
)
if new_path and record.document_path:
try:
same = os.path.abspath(new_path) == os.path.abspath(record.document_path)
except (OSError, ValueError): # a path this OS cannot normalise
same = new_path == record.document_path
same = self._same_file(new_path, record.document_path)
if same:
log.warning(
"%s is reopening %s, the same path the abandoned %r call was working "
+109
View File
@@ -179,6 +179,91 @@ async def test_resolver_refuses_a_shared_path_even_on_an_exact_key_hit(backend,
assert len(await backend.document_list()) == 2, "a refusal closes nothing"
# Two spellings of one file only exist where the filesystem folds case;
# ``os.path.normcase`` is the identity on POSIX, so these tests skip there.
_case_folding_fs = pytest.mark.skipif(
os.path.normcase("A") == "A", reason="filesystem is case-sensitive"
)
@_case_folding_fs
async def test_opening_a_case_variant_of_an_open_file_reloads_it(backend, tmp_path):
"""Registration keys by file identity, not by spelling.
``drawing_open`` of an already-open file spelled with another case used to
register a *second* entry (key compared as plain strings), and from there
every lookup of either entry was refused as ambiguous.
"""
path = tmp_path / "Gear.dxf"
await backend.drawing_save(str(path))
await backend.entity_create_circle(0, 0, 5) # unsaved edit
reopened = await backend.drawing_open(str(path).lower())
assert reopened["reloaded"] is True
assert reopened["open_documents"] == 1
rows = await backend.document_list()
assert len(rows) == 1 and rows[0]["entity_count"] == 0, "reload reads the disk"
assert (await backend.document_activate(str(path)))["ok"] is True
@_case_folding_fs
async def test_resolver_accepts_any_spelling_of_a_registered_path(backend, tmp_path):
"""``document_activate`` / ``document_close`` compare paths as the OS does.
The live engine lowercases before comparing (``_com_find_document``); a
lowercase drive letter or a case variant used to be refused headless as
'no open document named ...' - a dual-engine parity break.
"""
path = tmp_path / "Gear.dxf"
await backend.drawing_save(str(path))
key = os.path.abspath(str(path))
await backend.drawing_new()
spelled = str(path)
for variant in (
spelled.lower(),
spelled[0].lower() + spelled[1:],
spelled[0].upper() + spelled[1:],
spelled.upper(),
):
await backend.document_activate("untitled-2")
assert (await backend.document_activate(variant))["active"] == key, variant
assert len(await backend.document_list()) == 2
closed = await backend.document_close(spelled.lower())
assert closed["closed"] == key and closed["open_documents"] == 1
@_case_folding_fs
async def test_saving_to_a_case_variant_of_the_own_path_keeps_one_entry(backend, tmp_path):
path = tmp_path / "Self.dxf"
await backend.drawing_save(str(path))
key = os.path.abspath(str(path))
await backend.entity_create_circle(0, 0, 1)
assert (await backend.drawing_save_as(str(path).lower()))["ok"] is True
rows = await backend.document_list()
assert len(rows) == 1 and rows[0]["saved"] is True
assert backend._active_key == key, "the same file keeps its key"
assert (await backend.document_activate(key))["ok"] is True
async def test_drawing_close_with_save_refuses_a_shared_path(backend, tmp_path):
"""``drawing_close`` bypasses the resolver, so the close itself must check.
With two entries on one file, ``drawing_close(save=True)`` wrote the active
entry's bytes over the file the other entry holds and reported
``saved: True`` - the surviving row then claimed the disk held its content.
"""
path = tmp_path / "dup.dxf"
await backend.drawing_save(str(path))
await backend.entity_create_line(0, 0, 1, 1) # the owner is dirty
await backend.drawing_new()
await backend.entity_create_circle(0, 0, 5)
backend._docs[backend._active_key].path = str(path) # corrupt the registry behind its back
before = path.read_bytes()
with pytest.raises(ValueError, match="share the file"):
await backend.drawing_close(save=True)
assert path.read_bytes() == before, "a refusal writes nothing"
assert len(await backend.document_list()) == 2, "a refusal closes nothing"
async def test_activate_by_basename_full_path_or_key(backend, tmp_path):
path = tmp_path / "gear.dxf"
await backend.drawing_save(str(path))
@@ -303,6 +388,30 @@ async def test_document_list_tool_over_the_wire(monkeypatch):
assert closed["closed"] == "untitled-1" and closed["active"] == "untitled-2"
@_case_folding_fs
async def test_document_activate_accepts_a_lowercase_drive_over_the_wire(monkeypatch, tmp_path):
"""The wire canonicalises ``drawing_open`` paths (``validate_path`` resolves
them to disk casing) but not ``document_activate`` / ``document_close``
names, so a client spelling the drive letter in lowercase used to be
refused headless while the live engine accepted it."""
monkeypatch.setenv("AUTOCAD_MCP_BACKEND", "ezdxf")
path = tmp_path / "Gear.dxf"
spelled = str(path)
lowered_drive = spelled[0].lower() + spelled[1:]
async with Client(server.mcp) as client:
await client.call_tool("drawing_new", {"bootstrap": False})
await client.call_tool("drawing_save", {"path": spelled})
await client.call_tool("drawing_new", {"bootstrap": False})
activated = (
await client.call_tool("document_activate", {"name_or_path": lowered_drive})
).structured_content
assert activated["name"] == "Gear.dxf" and activated["previous"] == "untitled-2"
closed = (
await client.call_tool("document_close", {"name_or_path": spelled.lower()})
).structured_content
assert closed["closed"] == os.path.abspath(spelled) and closed["open_documents"] == 1
# ── live engine, against a fake ActiveX surface ──────────────────────────────