fix(task-5): a rollback that fails after a cancellation no longer kills the session, and the COM rollback goes back to an UNDO Mark instead of prompting
Two review findings on 32821c6.
1. Once the shielded rollback body runs after a real client cancellation it
can fail (the COM hard timeout, _safe_send_command's own deadline, an
ezdxf quarantine refusal). That RuntimeError escaped run_spec as an
ordinary error and the mcp lowlevel server, which had already answered
the cancelled request, tried to respond a second time: `assert not
self._completed` in mcp/shared/session.py escaped the server task group
and the whole session died (measured through the real transport: the next
system_status was never answered and Client.__aexit__ re-raised the
AssertionError). run_spec now decides by the exception it is handling:
after a cancellation the rollback error is logged and the cancellation is
what propagates, so the SDK's cancellation suppression applies; after any
other error the rollback error propagates as before, with the original
chained as its context.
2. The docstring promised the rollback on both engines, but on the live
AutoCAD 2026 ComBackend.transaction_rollback sent `_UNDO B` with no UNDO
Mark in the stack (StartUndoMark/EndUndoMark form an undo *group*, not a
Mark), AutoCAD answered "This will undo everything. OK? <Y>", SendCommand
blocked at that prompt, _run gave up after 60 s, the entities stayed and
every COM client was rejected until a human pressed ESC. transaction_begin
now types `_.UNDO _M` before StartUndoMark (refused with ok:false while
CMDACTIVE, where the Mark would be swallowed as input), and
transaction_rollback ends the group and sends `_.UNDO _B`, which returns
exactly to that Mark - also on an empty transaction, where `_UNDO 1` would
be one step too many. A rollback with no transaction is refused the way the
headless engine refuses it, because an unmarked Back is that prompt.
Measured with the real ComBackend in a scratch Documents.Add() document on
the running AutoCAD 2026 (closed with Close(False)): begin 31 ms ->
entity_create_line + circle -> rollback 63 ms -> 3 entities back to 1,
CMDACTIVE 0, no prompt; an empty begin/rollback leaves the older line;
begin/commit unchanged.
Tests: the two failure modes on run_spec (anyio scope cancellation with a
raising rollback completes the task group and logs; a ValueError with a
raising rollback still raises the rollback error with the ValueError as
context), the real-transport case (client.cancel mid-run, rollback raises,
the next request is answered, cleanup through the real rollback), the COM
shape now pins `_.UNDO _M, StartUndoMark, EndUndoMark, _.UNDO _B`, and
test_com_sprint3 grows a Mark-keeping fake document whose unmarked Back
fails the way the prompt does: begin before the group, rollback consumes the
Mark, rollback without a transaction sends nothing, begin refuses on
CMDACTIVE.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
This commit is contained in:
co-authored by
Claude Opus 5
parent
32821c6410
commit
2e101b95e2
+41
-4
@@ -4368,6 +4368,22 @@ class ComBackend(AutoCADBackend):
|
||||
|
||||
def _sync():
|
||||
doc = _acad_doc()
|
||||
# `_UNDO _M` below is typed at the command line; with a command or
|
||||
# prompt already active it would be swallowed as that command's
|
||||
# input and no mark would exist for the rollback to go back to.
|
||||
try:
|
||||
cmd_active = int(doc.GetVariable("CMDACTIVE"))
|
||||
except Exception as exc:
|
||||
log.debug("CMDACTIVE read failed, proceeding anyway: %s", exc)
|
||||
cmd_active = 0
|
||||
if cmd_active:
|
||||
return {
|
||||
"ok": False,
|
||||
"error": (
|
||||
f"AutoCAD has an active command or prompt (CMDACTIVE={cmd_active}); "
|
||||
"press ESC in AutoCAD before transaction_begin."
|
||||
),
|
||||
}
|
||||
# R32: record the mark from inside the worker, immediately before
|
||||
# setting it. `_transaction_active = True` used to run only after
|
||||
# `_run` returned, so a timed-out begin left StartUndoMark open in
|
||||
@@ -4376,10 +4392,22 @@ class ComBackend(AutoCADBackend):
|
||||
# exact inverse of the R16 fix transaction_commit already carries.
|
||||
# The abandoned STA thread cannot be cancelled, so the flag has to
|
||||
# be written where the call happens; and it is written *before*
|
||||
# StartUndoMark because a call that hangs inside it may still land.
|
||||
# the marks because a call that hangs inside them may still land.
|
||||
# A `_acad_doc()` that fails leaves the flag alone, which is right:
|
||||
# nothing reached AutoCAD.
|
||||
self._transaction_active = True
|
||||
# Two marks, deliberately. StartUndoMark/EndUndoMark form an undo
|
||||
# *group* (one UNDO step), not an UNDO Mark: on the live AutoCAD
|
||||
# 2026 `_UNDO B` with no Mark answers "This will undo everything.
|
||||
# OK? <Y>" and SendCommand blocks at that prompt until a human
|
||||
# presses ESC (measured: rollback timed out at 60 s, the entities
|
||||
# stayed, every COM client was rejected meanwhile). The Mark is set
|
||||
# *before* the group so `_UNDO _B` in transaction_rollback lands
|
||||
# exactly at the transaction's start — also when the group is
|
||||
# empty, where `_UNDO 1` would be one step too many. Measured on
|
||||
# AutoCAD 2026: Mark + group + 2 entities + `_UNDO _B` -> entities
|
||||
# gone, CMDACTIVE 0, 47 ms; empty group -> nothing else undone.
|
||||
doc.SendCommand("_.UNDO _M\n")
|
||||
doc.StartUndoMark()
|
||||
return {"ok": True, "message": "Transaction begun (AutoCAD undo mark set)"}
|
||||
|
||||
@@ -4399,12 +4427,21 @@ class ComBackend(AutoCADBackend):
|
||||
self._transaction_active = False
|
||||
|
||||
async def transaction_rollback(self) -> dict:
|
||||
# Without a transaction there is no Mark to go back to, and `_UNDO _B`
|
||||
# would then prompt "This will undo everything. OK? <Y>" and block the
|
||||
# STA worker at it (see transaction_begin). Refuse the same way the
|
||||
# headless engine does.
|
||||
if not self._transaction_active:
|
||||
return {"ok": False, "error": "No active transaction to rollback"}
|
||||
|
||||
def _sync():
|
||||
doc = _acad_doc()
|
||||
doc.EndUndoMark()
|
||||
# R16: route UNDO B through the CMDACTIVE-aware sender instead of a raw
|
||||
# SendCommand that could deadlock if a command/prompt is active.
|
||||
self._safe_send_command(doc, "_UNDO B")
|
||||
# R16: route UNDO Back through the CMDACTIVE-aware sender instead of
|
||||
# a raw SendCommand that could deadlock if a command/prompt is active.
|
||||
# Back to the Mark transaction_begin set before the group, so this
|
||||
# never prompts and never undoes anything older than the transaction.
|
||||
self._safe_send_command(doc, "_.UNDO _B")
|
||||
return {"ok": True, "message": "Transaction rolled back"}
|
||||
|
||||
try:
|
||||
|
||||
Reference in New Issue
Block a user