fix(task-5): pid_from_spec shields its rollback so a real client cancellation rolls the sheet back on both engines
The MCP SDK cancels a request by cancelling an anyio scope, and anyio cancellation is level-triggered: task.cancel() is re-issued on every loop iteration while the task is inside the cancelled scope. run_spec's `except BaseException: await backend.transaction_rollback()` was therefore cancelled at its first await, before the rollback body ran. Measured through the real transport (fastmcp.Client + notifications/cancelled): five symbols left in an open transaction on ezdxf; on the COM shape EndUndoMark / _UNDO B never sent while the backend's `finally` already cleared _transaction_active, so the next transaction_begin nested a StartUndoMark. A native Task.cancel() (what the plan's Step-4 note reasoned about) is delivered once and clears, which is why raising CancelledError from a mock passed trivially. The rollback now runs inside anyio.CancelScope(shield=True). The COM hard timeout (asyncio.wait_for inside ComBackend._run) still fires inside the shield, measured at 0.62 s with a 0.5 s deadline, so a hung rollback cannot freeze the server. Tests: the mock-raise test is replaced by real cancellations that fail on the unshielded code — a cancelled anyio task-group scope (parametrized alongside the native Task.cancel() case), the real transport via Client.cancel(request id) polled mid-run, and the COM shape (real ComBackend._run, a real single-thread executor and the real transaction_rollback over a recording ActiveX document, with a call in flight when the scope is cancelled) which asserts StartUndoMark, EndUndoMark, _UNDO B land and the next begin is fresh. 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
ac1fba80b7
commit
32821c6410
+14
-1
@@ -6,6 +6,8 @@ import copy
|
||||
import math
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import anyio
|
||||
|
||||
from .critique import PID_FOCUSES, issues_for
|
||||
from .drawlines import draw_line
|
||||
from .graph import build_graph
|
||||
@@ -443,7 +445,18 @@ async def run_spec(backend: AutoCADBackend, spec: dict, dry_run: bool = False) -
|
||||
# a ``BaseException``) leave the placed symbols in the drawing inside
|
||||
# an open transaction — on COM with the undo mark still open, so every
|
||||
# later ``pid_from_spec`` was refused until a manual rollback.
|
||||
await backend.transaction_rollback()
|
||||
#
|
||||
# The rollback is shielded because the MCP SDK cancels a request by
|
||||
# cancelling an anyio scope, and anyio cancellation is level-triggered:
|
||||
# ``task.cancel()`` is re-issued on every loop iteration while the task
|
||||
# is still inside the cancelled scope. A bare ``await`` here would be
|
||||
# cancelled at its first suspension, before the rollback body ran —
|
||||
# measured through the real transport: five symbols left in an open
|
||||
# transaction, and on COM the undo mark left open while the backend's
|
||||
# flag already said "no transaction". A native ``Task.cancel()`` is
|
||||
# delivered once and clears, so it was never the case that mattered.
|
||||
with anyio.CancelScope(shield=True):
|
||||
await backend.transaction_rollback()
|
||||
raise
|
||||
await backend.transaction_commit()
|
||||
graph = await build_graph(backend)
|
||||
|
||||
Reference in New Issue
Block a user