fix: seven defects an adversarial audit found, and the gates that missed them
Release / test (full, windows-latest, 3.11) (push) Canceled after 0s
Release / test (pdf, ubuntu-latest, 3.11) (push) Canceled after 0s
Release / test (pdf, ubuntu-latest, 3.12) (push) Canceled after 0s
Release / build (push) Canceled after 0s
Release / publish-pypi (push) Canceled after 0s
Release / github-release (push) Canceled after 0s

Every one of these was reported ok/clean to the caller while being wrong.

Silently wrong numbers
- selection_filter(min_area) read `entity.dxf.get("area", 0.0)` for SOLID,
  TRACE and HATCH, none of which carry that attribute, so every hatch
  measured 0.0. A 1600 mm^2 hatch was excluded by min_area=1.0 while
  `filtered_by` asserted the filter had run, and COM (which reads ActiveX
  .Area) returned a different selection for the same call. It now answers
  from the measurement `entity_measure` uses, so the two cannot disagree.
- Every headless dimension rendered through ezdxf's bare `Standard` style:
  1.0 mm text with a comma decimal marker, against ISO 3098's 2.5 mm minimum
  and ISO 129's point -- while the document header itself said $DIMTXT 2.5.
  `ezdxf.new(setup=True)` is not the fix; measured, it renders a 100 mm
  dimension as "10000". The attributes are now stored explicitly.
- Saving writes `dimrnd = 0.0` onto the Standard style, and ezdxf applies
  `xround(value, dimrnd)` whenever the attribute is present at all --
  `xround(33.333, 0.0)` is 33. So on any reopened file, including one this
  server saved, every dimension was rounded to a whole unit before DIMDEC
  formatted it: a 12.75 mm feature dimensioned 13. AutoCAD means DIMRND 0 as
  *no* rounding; the sentinel is now discarded at every load site, and a real
  0.5 mm rounding is left alone.
- The DIM* header fold gave out-of-range values teeth they did not have when
  they died in the header: dim_decimals=-1 returned ok and then killed the
  next dimension with `ValueError: Format specifier missing precision` from
  inside ezdxf, and 40 printed forty digits of float noise onto the sheet.
  Ranges are now AutoCAD's own (DIMDEC 0-8, DIMZIN 0-15, lengths positive).

Security
- security.py recorded the policy as "sanitize_command and sanitize_lisp each
  have exactly ONE caller ... the policy is scoped to the CHANNEL". The
  premise was false. `_ensure_linetype_loaded` and `ComBackend.linetype_load`
  build `_-LINETYPE _LOAD {name} {file}` and hand it to SendCommand, which
  treats a newline and ';' as segment separators -- so a `linetype=` argument
  carrying either injected further AutoCAD commands, and the 36-verb denylist
  never saw that channel. Guarded with the DXF symbol-name rule (the
  injection characters fall out of it rather than being enumerated) plus
  validate_path for the file argument, which was also the one path-taking
  tool reaching the filesystem unchecked. The policy comment is corrected.

Gates that could not see
- drawing_critique returned zero issues on a quarantined document: all seven
  checks wrapped their backend call in a bare `except Exception`. CLAUDE.md
  rule 7 makes that call the gate an agent is told to trust. Only the
  quarantine now escapes; an ordinary broken check still degrades.
  `_check_dimension_legibility` also read `_doc` directly from the event loop
  thread, missing the gate and performing exactly the unsynchronised access
  the quarantine exists to prevent. `construction_clear` answered
  "deleted: 0" -- there was nothing to clear -- for the same reason.
- The iso128 focus checked layer lineweights only, so a sheet lettered at
  1.0 mm critiqued clean. It now reads the effective dimension height and
  separator, header-then-style, in the order the renderer resolves.
- check_doc_numbers.py derived five values it never checked, and those were
  the ones that had drifted. The collected-test count (README said 1274, the
  tree had 1369) and the A/B pass-rate and Fixed columns are now gated.
- benchmarks/README.md's v1.4.0 table was impossible on its face -- 21
  passing plus 5 fixed is 26, not 24 -- and contradicted the report it
  linked, because `ab-v1.4.0-vs-v1.5.0.json` was regenerated from a tree that
  already carried the dimension fixes and so never measured the v1.5.0 tag.
  Replaced with a real run: ab-v1.4.0-vs-v1.5.1.json, 21/26 -> 26/26.

Release gate
- release.yml ran one ubuntu/3.11 lane, so a Windows-only defect could reach
  PyPI with no Windows job having executed. That is not hypothetical: v1.5.0
  shipped `except pywintypes.com_error` raising NameError wherever pywin32 is
  absent. It now runs the same three lanes as ci.yml, Windows included.

1368 passed, 1 xfailed. ruff clean. Tool inventory and all 16 gated document
numbers match the tree.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Umut CAN
2026-08-06 23:07:50 +03:00
co-authored by Claude Opus 5
parent 6b9860e68e
commit ca1c79fd01
28 changed files with 3376 additions and 148 deletions
+75 -17
View File
@@ -19,6 +19,7 @@ from typing import Any
import config
from engineering.measure import is_self_intersecting, polygon_area_perimeter
from security import sanitize_macro_argument, sanitize_symbol_name, validate_path
from . import ocs
from .base import (
@@ -203,6 +204,9 @@ def _ensure_linetype_loaded(name: str) -> None:
"""
if not name or name.lower() in _BUILTIN_LINETYPES:
return
# Before any COM call: this name is about to be interpolated into a
# SendCommand macro, where a newline or ';' would start a second command.
name = sanitize_symbol_name(name, kind="linetype")
doc = _acad_doc()
existing = {doc.Linetypes.Item(i).Name.lower() for i in range(doc.Linetypes.Count)}
if name.lower() in existing:
@@ -293,6 +297,10 @@ def _apply_dim_tolerance(dim, tol_upper, tol_lower, tol_mode="none", text_overri
#: ActiveX object names that bound no area at all. Not a capability gap — no
#: engine can give a LINE an area — so these raise a plain error.
#: Solids carry a volume but no surface area over ActiveX. Verified on a live
#: AutoCAD 2026 (2026-08-06): AcDb3dSolid answers `.Volume` and raises on `.Area`.
_COM_NO_SURFACE_AREA_TYPES = frozenset({"AcDb3dSolid", "AcDbBody"})
_COM_NO_AREA_TYPES = frozenset(
{
"AcDbLine",
@@ -652,6 +660,14 @@ class ComBackend(AutoCADBackend):
looking identical to an expired deadline. ``future.cancelled()`` tells
them apart — ``wait_for`` cancels the future it is waiting on, an
ordinary failure completes it — mirroring ``EzdxfBackend._async``.
R32 deliberately stops short of the ezdxf document quarantine here. There
is no ``self._doc`` to replace: the drawing belongs to the operator, lives
in AutoCAD's process, and AutoCAD's own single-threaded command processor
arbitrates the abandoned call — not us. The only "reopen" available would
close the operator's drawing and discard unsaved work the server never
created. What COM is exposed to is idempotency, so the message says the
call may still land.
"""
if self._executor is None:
raise RuntimeError("ComBackend not connected. Call connect() first.")
@@ -691,8 +707,11 @@ class ComBackend(AutoCADBackend):
raise RuntimeError(
f"AutoCAD did not respond within {timeout:.0f}s. The application "
"may be showing a modal dialog or have an active command prompt "
"(press ESC in AutoCAD). Increase COM_CALL_TIMEOUT if a long "
"operation is expected."
"(press ESC in AutoCAD). The abandoned call may still complete in "
"AutoCAD after this error - the worker is blocked inside an "
"uncancellable COM call, not stopped - so verify the drawing before "
"retrying: a retry can double-apply the operation. Increase "
"COM_CALL_TIMEOUT if a long operation is expected."
) from e
except _COM_ERROR as e:
hr = e.args[0] if e.args else 0
@@ -2903,15 +2922,27 @@ class ComBackend(AutoCADBackend):
# dialog deadlocks SendCommand; (2) ISO/metric drawings need acadiso.lin,
# imperial drawings need acad.lin. We pick automatically from MEASUREMENT
# if the caller didn't specify a file.
#
# Both arguments end up inside a SendCommand macro, so both are checked
# before the COM thread is entered: the name against the DXF
# symbol-table rule, the path against the same allowlist every other
# file-taking tool uses. This was the one path-taking tool that reached
# the filesystem with neither.
safe_name = sanitize_symbol_name(name, kind="linetype")
safe_file = None
if file is not None:
resolved = validate_path(str(file))
safe_file = sanitize_macro_argument(str(resolved), kind="linetype file")
def _sync():
app = _acad_app()
doc = _acad_doc()
existing = {doc.Linetypes.Item(i).Name.lower() for i in range(doc.Linetypes.Count)}
if name.lower() in existing:
return {"ok": True, "name": name, "already_loaded": True}
if safe_name.lower() in existing:
return {"ok": True, "name": safe_name, "already_loaded": True}
if file is None:
if safe_file is None:
try:
measurement = int(app.GetVariable("MEASUREMENT"))
except Exception as exc:
@@ -2919,7 +2950,7 @@ class ComBackend(AutoCADBackend):
measurement = 1
lin_file = "acadiso.lin" if measurement == 1 else "acad.lin"
else:
lin_file = file
lin_file = safe_file
try:
old_filedia = int(app.GetVariable("FILEDIA"))
@@ -2930,7 +2961,7 @@ class ComBackend(AutoCADBackend):
app.SetVariable("FILEDIA", 0)
# Double newline: first ends the file name, second exits
# -LINETYPE's [?/Create/Load/Set] option menu.
doc.SendCommand(f"_-LINETYPE _LOAD {name} {lin_file}\n\n")
doc.SendCommand(f"_-LINETYPE _LOAD {safe_name} {lin_file}\n\n")
finally:
try:
app.SetVariable("FILEDIA", old_filedia)
@@ -2938,13 +2969,13 @@ class ComBackend(AutoCADBackend):
log.debug("FILEDIA restore failed: %s", exc)
after = {doc.Linetypes.Item(i).Name.lower() for i in range(doc.Linetypes.Count)}
if name.lower() not in after:
if safe_name.lower() not in after:
raise RuntimeError(
f"Failed to load linetype '{name}' from '{lin_file}'. "
f"Failed to load linetype '{safe_name}' from '{lin_file}'. "
"Check the linetype name spelling and that the .lin file is "
"on AutoCAD's support path."
)
return {"ok": True, "name": name, "file": lin_file}
return {"ok": True, "name": safe_name, "file": lin_file}
return await self._run(_sync)
@@ -3189,10 +3220,16 @@ class ComBackend(AutoCADBackend):
area = float(ent.Area)
except Exception as exc:
log.debug("ActiveX .Area unavailable for %s: %s", obj_name, exc)
try:
perimeter = float(ent.Length)
except Exception as exc:
log.debug("ActiveX .Length unavailable for %s: %s", obj_name, exc)
# Measured on a live AutoCAD 2026 (2026-08-06): the perimeter lives
# under a different member per type, and on some types nowhere.
# AcDbPolyline -> .Length, AcDbCircle -> .Circumference,
# AcDbRegion -> .Perimeter, AcDbHatch -> none of them.
for member in ("Length", "Circumference", "Perimeter"):
try:
perimeter = float(getattr(ent, member))
break
except Exception as exc:
log.debug("ActiveX .%s unavailable for %s: %s", member, obj_name, exc)
method = "activex_area"
closed = True
@@ -3215,6 +3252,17 @@ class ComBackend(AutoCADBackend):
for i in range(0, len(coords) - 1, 2)
]
if not vertices:
if obj_name in _COM_NO_SURFACE_AREA_TYPES:
# Verified on a live AutoCAD 2026 (2026-08-06): AcDb3dSolid
# exposes `.Volume` and no `.Area` whatsoever. The generic
# message would send the caller hunting for geometry that
# was never the problem.
raise RuntimeError(
f"entity_measure: handle {handle} is {obj_name}, and ActiveX "
"exposes no surface-area member for it — only .Volume. "
"AutoCAD's own MASSPROP reports the surface area; this server "
"does not wrap it."
)
raise RuntimeError(
f"entity_measure: AutoCAD reported neither an area nor usable "
f"vertices for handle {handle} ({obj_name})."
@@ -3436,12 +3484,22 @@ class ComBackend(AutoCADBackend):
def _sync():
doc = _acad_doc()
# 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
# AutoCAD while the server recorded no transaction, and the next
# transaction_begin nested a second mark nobody could see — the
# 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.
# A `_acad_doc()` that fails leaves the flag alone, which is right:
# nothing reached AutoCAD.
self._transaction_active = True
doc.StartUndoMark()
return {"ok": True, "message": "Transaction begun (AutoCAD undo mark set)"}
result = await self._run(_sync)
self._transaction_active = True
return result
return await self._run(_sync)
async def transaction_commit(self) -> dict:
def _sync():
+9 -5
View File
@@ -117,12 +117,16 @@ class AnalysisContract(ABC):
islands inside them — and the headless engine adds ``hatch_style``
(``normal``/``outer``/``ignore``) so the number can be read against the
drawing's own island setting. The live backend does not carry that key:
it reads ActiveX ``.Area``, and whether that number already has the
island style applied is **not verified against a live seat**. Until it
is, do not read a COM hatch area as equivalent to the headless one.
it reads ActiveX ``.Area``, which **does** apply the island style —
verified on a live AutoCAD 2026 (2026-08-06): a hatch of r20 with an
r10 island returns 942.478, which is pi*(400-100).
``perimeter`` is ``None`` where ActiveX exposes no length for the type
(REGION and 3DSOLID among them), and ``perimeter_exact`` is then False.
``perimeter`` is ``None`` where ActiveX exposes no length member for the
type, and ``perimeter_exact`` is then False. The member differs per
type and was measured rather than assumed: LWPOLYLINE ``.Length``,
CIRCLE ``.Circumference``, REGION ``.Perimeter``, HATCH none. A
3DSOLID has no ``.Area`` at all — only ``.Volume`` — and is refused by
name.
A missing perimeter is not a reason to discard an area that was read
successfully — doing exactly that is what made this method raise on
every ACIS type it advertised.
+7
View File
@@ -11,6 +11,8 @@ import math
from abc import ABC, abstractmethod
from typing import TYPE_CHECKING, Any
from backends.quarantine import DocumentQuarantineError
if TYPE_CHECKING:
from backends.base import EntityInfo
from engineering.plan_spec import (
@@ -579,6 +581,11 @@ class PremiumContract(ABC):
layer = layer or self._role_layer("construction")
try:
ents = await self.entity_list(layer_filter=layer, limit=5000)
except DocumentQuarantineError:
# "deleted: 0" here would read as "there was nothing to clear" on a
# document the backend has declared untrusted -- and this is step 9
# of the standard workflow, immediately before finalize.
raise
except Exception:
ents = []
deleted = 0
+86 -4
View File
@@ -22,8 +22,13 @@ class SettingsContract:
With ``settings=None`` returns a snapshot of every known setting. With a
dict, applies each provided key and returns ``{applied, errors, current}``.
Friendly keys: units, linear_precision, angular_precision, ltscale,
dimscale, text_size, point_mode, point_size, osmode, fillet_radius.
`units` accepts mm/cm/m/inch/feet (mapped to the INSUNITS code)."""
dimscale, dim_text_height, dim_arrow_size, dim_decimals,
decimal_separator, zero_suppression, text_size, point_mode, point_size,
osmode, fillet_radius. `units` accepts mm/cm/m/inch/feet (mapped to the
INSUNITS code) and `decimal_separator` accepts "." or ",".
Note that a no-argument snapshot is one ``system_get_variable`` per key.
Free headless; a COM round trip each on a live seat."""
if not settings:
snapshot: dict = {}
for key, (var, kind) in _SETTING_MAP.items():
@@ -68,6 +73,16 @@ _SETTING_MAP: dict[str, tuple[str, str]] = {
"angular_precision": ("AUPREC", "int"),
"ltscale": ("LTSCALE", "float"),
"dimscale": ("DIMSCALE", "float"),
# The dimension variables had no friendly name until v1.5.2, so the only
# way to reach dimension lettering was a raw `system_set_variable` — which
# is precisely the path that silently did nothing on the headless engine.
# `text_size` is TEXTSIZE, the height of a standalone TEXT entity; it has
# never had anything to do with the numbers on a dimension.
"dim_text_height": ("DIMTXT", "float"),
"dim_arrow_size": ("DIMASZ", "float"),
"dim_decimals": ("DIMDEC", "int"),
"decimal_separator": ("DIMDSEP", "char"),
"zero_suppression": ("DIMZIN", "int"),
"text_size": ("TEXTSIZE", "float"),
"point_mode": ("PDMODE", "int"),
"point_size": ("PDSIZE", "float"),
@@ -96,6 +111,13 @@ _UNIT_TO_CODE: dict[str, int] = {
}
_CODE_TO_UNIT: dict[int, str] = {0: "unitless", 1: "inch", 2: "feet", 4: "mm", 5: "cm", 6: "m"}
#: DIMDSEP is a character *code*, not a character — ezdxf raises DXFValueError
#: on a string in the header — so the friendly key is where the translation
#: belongs. Only two markers are in real use: ISO 129's point and the comma most
#: of continental Europe drafts with. Anything else lands an arbitrary glyph in
#: the middle of every number on the sheet, so it is refused rather than stored.
_DECIMAL_MARKERS = (".", ",")
def _encode_setting(key: str, kind: str, value: Any) -> Any:
"""Coerce a friendly value into the raw system-variable value."""
@@ -109,10 +131,62 @@ def _encode_setting(key: str, kind: str, value: Any) -> Any:
f"{sorted(set(_UNIT_TO_CODE))} or an INSUNITS integer."
)
return code
if kind == "char":
if isinstance(value, bool):
marker = ""
elif isinstance(value, (int, float)):
code = int(value)
marker = chr(code) if 0 < code < 0x110000 else ""
else:
marker = str(value).strip()
if marker not in _DECIMAL_MARKERS:
raise ValueError(
f"{key}: unknown value {value!r}. Use one of {list(_DECIMAL_MARKERS)}."
)
return ord(marker)
if kind == "int":
return int(float(value))
return _in_range(key, int(float(value)))
if kind == "float":
return float(value)
return _in_range(key, float(value))
return value
#: AutoCAD's own ranges for the settings that now reach the renderer.
#:
#: Before the DIM* header fold, an out-of-range value died harmlessly in the
#: header. Folding it into the per-dimension override is what gave it teeth:
#: `dim_decimals=-1` reported `ok: True` and then killed the *next* dimension
#: with `ValueError: Format specifier missing precision` raised from inside
#: ezdxf's formatter, nowhere near the call responsible; `dim_decimals=40`
#: printed forty digits of float noise onto the sheet; and a non-positive
#: length was reported applied and silently discarded. `applied` has to mean
#: applied.
_SETTING_RANGES: dict[str, tuple[float, float]] = {
"dim_decimals": (0, 8), # DIMDEC
"zero_suppression": (0, 15), # DIMZIN is a bitmask
"linear_precision": (0, 8), # LUPREC
"angular_precision": (0, 8), # AUPREC
"dim_text_height": (1e-9, 1e6), # DIMTXT, strictly positive
"dim_arrow_size": (1e-9, 1e6), # DIMASZ, strictly positive
"text_size": (1e-9, 1e6), # TEXTSIZE
"dimscale": (0, 1e6), # 0 means "scale to the viewport" and is legal
"ltscale": (1e-9, 1e6),
"point_size": (-100, 1e6), # negative is a percentage of the screen
"fillet_radius": (0, 1e6),
}
def _in_range(key: str, value: float) -> float:
"""Refuse a value the renderer cannot use, naming the range."""
bounds = _SETTING_RANGES.get(key)
if bounds is None:
return value
low, high = bounds
if not low <= value <= high:
low_text = "greater than 0" if 0 < low < 1e-6 else f"at least {low:g}"
raise ValueError(
f"{key}: {value:g} is out of range - must be {low_text} and at most {high:g}."
)
return value
@@ -126,6 +200,14 @@ def _decode_setting(key: str, kind: str, raw: Any) -> Any:
except (TypeError, ValueError):
return raw
return {"code": code, "name": _CODE_TO_UNIT.get(code, "unknown")}
if kind == "char":
try:
code = int(raw)
except (TypeError, ValueError):
return raw
# ezdxf's renderer reads DIMDSEP 0 as a comma rather than as "unset",
# so report the marker that would actually be drawn.
return "," if code == 0 else chr(code)
if kind == "int":
try:
return int(raw)
+458 -56
View File
@@ -39,8 +39,25 @@ from .base import (
normalize_lineweight,
shoelace_area,
)
from .quarantine import (
AbandonedCall,
DocumentQuarantineError,
QuarantineRecord,
quarantine_refusal,
real_call_name,
)
__all__ = ["EzdxfBackend", "UnsupportedCapabilityError", "_unsupported"]
__all__ = [
"DocumentQuarantineError",
"EzdxfBackend",
"UnsupportedCapabilityError",
"_unsupported",
]
#: How many abandoned calls ``system_status`` keeps. The interesting one is
#: always the most recent; older entries exist so a support transcript shows a
#: document that has been abandoned on repeatedly rather than once.
_MAX_ABANDONED_RECORDS = 10
log = logging.getLogger(__name__)
@@ -337,6 +354,64 @@ def _flatten_measure(ent, tolerance: float, result: dict) -> dict:
return result
#: ISO-conformant dimension style for a new headless document, in mm.
#:
#: ezdxf's bare `Standard` dimstyle leaves every one of these unset, and the
#: renderer then falls back to its own defaults rather than to the DXF schema
#: defaults -- 1.0 mm text with a *comma* decimal marker. ISO 3098 puts the
#: minimum lettering height at 2.5 mm and ISO 129 wants a point, so every
#: headless drawing this server made was non-conformant on both counts while
#: the document header claimed `$DIMTXT 2.5`.
#:
#: `ezdxf.new(setup=True)` is not the fix: measured, it renders a 100 mm
#: dimension as "10000" at 0.25 mm text, because its metric styles carry a
#: length factor. Setting the attributes explicitly is what reaches the
#: renderer, because only a *stored* value does.
_ISO_DIMSTYLE = {
"dimtxt": 2.5, # ISO 3098 minimum lettering height
"dimasz": 2.5, # arrowhead, matched to the text
"dimdsep": ord("."), # ISO 129 decimal marker; ezdxf defaults to a comma
"dimdec": 2,
"dimexe": 1.25, # extension line beyond the dimension line
"dimexo": 0.625, # offset from the measured feature
"dimgap": 0.625, # gap around the text
}
def _normalise_dimstyle_rounding(doc) -> None:
"""Discard a stored ``dimrnd`` of 0.0, which AutoCAD means as *no* rounding.
ezdxf applies ``xround(value, dimrnd)`` whenever the attribute is present at
all, and ``xround(33.333, 0.0)`` is ``33``. Saving a drawing writes the
attribute out, so every reopened file — including one this server saved
itself — silently rounded every dimension to a whole unit *before* DIMDEC
got to format it: a 12.75 mm feature dimensioned as 13, R6.35 as R6.
Only the 0.0 sentinel is removed. A drafter who genuinely asked for 0.5 mm
rounding keeps it.
"""
try:
style = doc.dimstyles.get("Standard")
except Exception:
return
if style.dxf.hasattr("dimrnd") and float(style.dxf.dimrnd) == 0.0:
style.dxf.discard("dimrnd")
def _apply_iso_dimstyle(doc) -> None:
"""Store ISO defaults on the document's `Standard` dimension style."""
try:
style = doc.dimstyles.get("Standard")
except Exception: # a template without a Standard style is the caller's own
return
for attribute, value in _ISO_DIMSTYLE.items():
style.dxf.set(attribute, value)
doc.header["$DIMTXT"] = _ISO_DIMSTYLE["dimtxt"]
doc.header["$DIMASZ"] = _ISO_DIMSTYLE["dimasz"]
doc.header["$DIMDSEP"] = _ISO_DIMSTYLE["dimdsep"]
doc.header["$DIMDEC"] = _ISO_DIMSTYLE["dimdec"]
#: AutoCAD's HPISLANDDETECTION, by the code DXF stores in group 75.
_HATCH_STYLE_NAMES = {0: "normal", 1: "outer", 2: "ignore"}
@@ -443,27 +518,147 @@ def _measure_hatch(ent, tolerance: float, result: dict) -> dict:
return _round_measure(result)
def _with_header_dimscale(doc, override: dict | None) -> dict | None:
"""Carry `$DIMSCALE` onto the dimension being created.
#: The dimension style every headless dimension is rendered from.
#:
#: `msp.add_*_dim` defaults to `dimstyle="EZDXF"` (`"EZ_RADIUS"` for the radial
#: ones) and ezdxf silently substitutes `Standard` when that entry is missing —
#: which it is in every document this server creates, so the five call sites got
#: `Standard` by accident rather than by name. Naming it keeps three things
#: pointed at one style: `_apply_iso_dimstyle`, which writes it; the `iso128`
#: critique, which grades it; and the header fold below, which compares against
#: it. A file authored by `ezdxf.new(setup=True)` *does* carry an `EZDXF` entry,
#: and its metric styles carry a length factor — measured, that renders a 100 mm
#: dimension as "10000" — so on an opened file the substitution would have
#: pointed the dimension at a style nobody here configured and left both the
#: fold and the ISO defaults inert.
_DIMSTYLE_NAME = "Standard"
`drawing_settings({"dimscale": ...})` writes the header variable, which is
what AutoCAD consults when it draws a dimension — so the call worked on the
live backend. ezdxf renders from the dimstyle table entry and never reads
the header, so headlessly the same call reported success and changed
nothing. Folding it into the per-dimension override makes one call mean one
thing on both engines.
1.0 is the default and is deliberately not written, so an untouched drawing
does not gain an override on every dimension.
#: DIM* header variables folded onto the dimension being created, as
#: ``dimstyle attribute -> (header variable, renderer fallback, coercion)``.
#:
#: The third column is what ezdxf's *renderer* resolves the attribute to when
#: the dimstyle stores nothing — `DimStyleOverride.get(attr, default)` with the
#: defaults spelled out in `render/dim_base.py` and `get_decimal_separator`.
#: Deliberately NOT the DXF schema default, which differs for three of the six:
#: dimtxt 1.0 vs 2.5, dimasz 0.25 vs 2.5, dimdsep 0 (a comma) vs 44. Keying the
#: "header already agrees, nothing to fold" test on the schema default is the
#: invisible way to get this wrong — it switches the fold off for exactly the
#: opened-file case it exists for, and switches it *on* for a fresh drawing,
#: where folding dimdsep would put ezdxf's comma back onto a sheet ISO 129 wants
#: a point on.
#:
#: This is an allowlist, not "every DIM* variable". $DIMADEC and $DIMAZIN sit at
#: 0 in a fresh header while the renderer falls back to 2 and 2, so completing
#: the table would silently reformat every angular dimension this server has
#: ever drawn (measured: 239.04° becomes 239°). $DIMGAP is the other one that
#: looks safe and is not: `build_dim_override` sets dimgap=-1.0 for
#: `tol_mode="basic"` to draw the theoretically-exact box, and a fold would be
#: competing with it for the same key.
_HEADER_DIMVARS: dict[str, tuple[str, Any, Any]] = {
"dimscale": ("$DIMSCALE", 1.0, float),
"dimtxt": ("$DIMTXT", 1.0, float),
"dimasz": ("$DIMASZ", 0.25, float),
"dimdec": ("$DIMDEC", 2, int),
"dimdsep": ("$DIMDSEP", 0, int), # a character code; 0 renders as a comma
"dimzin": ("$DIMZIN", 8, int),
}
def _coerce_dimvar(value, coerce):
"""The value as the override dict needs it, or None if it is junk.
ezdxf validates the *header* (a string in `$DIMDSEP` raises DXFValueError)
but not the override dict, so a float 46.0 or a stray string reaching
`dimdsep` renders garbage instead of raising. Coercing here is what keeps an
opened file's odd header out of the drawing.
"""
try:
scale = float(doc.header.get("$DIMSCALE", 1.0) or 1.0)
except (AttributeError, TypeError, ValueError):
return override
if abs(scale - 1.0) < 1e-9:
return override
merged = dict(override or {})
merged.setdefault("dimscale", scale)
return coerce(value)
except (TypeError, ValueError):
return None
def _header_dimvar(doc, header_var: str, coerce):
"""The header's value for a DIM* variable, or None if it has none usable."""
try:
raw = doc.header.get(header_var, None)
except Exception: # an opened document may not have a readable header
return None
return None if raw is None else _coerce_dimvar(raw, coerce)
def _style_dimvar(doc, attribute: str, fallback, coerce):
"""What the renderer would use with no header and no override in play."""
try:
style = doc.dimstyles.get(_DIMSTYLE_NAME)
if style.dxf.hasattr(attribute):
stored = _coerce_dimvar(style.dxf.get(attribute), coerce)
if stored is not None:
return stored
except Exception: # a document without a Standard style is the caller's own
pass
return fallback
def effective_dimvar(doc, attribute: str):
"""The value the next dimension will actually be rendered with.
Header first, then the stored dimstyle attribute, then ezdxf's renderer
fallback — the order `_with_header_dimvars` produces below, and the order a
live seat uses (the header DIMVARS *are* the current settings; the dimstyle
table entry is only the base underneath them). `drawing_critique` has to
resolve the same value, or it reports a defect the sheet does not have and
clears one it does.
"""
header_var, fallback, coerce = _HEADER_DIMVARS[attribute]
from_header = _header_dimvar(doc, header_var, coerce)
if from_header is not None:
return from_header
return _style_dimvar(doc, attribute, fallback, coerce)
def _with_header_dimvars(doc, override: dict | None) -> dict | None:
"""Carry the DIM* header variables onto the dimension being created.
`system_set_variable("DIMTXT", 5.0)` writes the header variable, which is
what AutoCAD consults when it builds a dimension — so the call worked on the
live backend. ezdxf renders from the dimstyle table entry plus the
per-dimension override and never reads the header, so headlessly the same
call reported success and changed nothing. Measured on v1.5.1: after
DIMTXT=5.0, DIMASZ=6.0, DIMDEC=1, DIMDSEP=44 and DIMZIN=0 the header read
all five back, and the next dimension was byte-identical to the one before
them — still 2.5 mm text, 2.5 arrows, "33.33". Folding them into the
override makes one call mean one thing on both engines.
v1.5.1 folded `$DIMSCALE` alone, which is why this is a table rather than a
fifth copy of the same six lines.
A variable whose header value already matches what the renderer would use is
skipped, so an untouched `drawing_new` drawing gains no override at all —
header and style agree on all six there. `setdefault` is what keeps the fold
below `build_dim_override`: an explicit tolerance/limit/basic key, or any
value a caller passed, always outranks the header.
"""
merged = override
for attribute, (header_var, fallback, coerce) in _HEADER_DIMVARS.items():
from_header = _header_dimvar(doc, header_var, coerce)
if from_header is None:
continue
if attribute == "dimscale" and not from_header:
# DIMSCALE 0 means "scale to the paper-space viewport". There is no
# viewport to scale to here, and folding a literal 0 would render
# the dimension to nothing; 1.0 is what the renderer already uses.
continue
stored = _style_dimvar(doc, attribute, fallback, coerce)
if isinstance(from_header, float):
if abs(from_header - float(stored)) < 1e-9:
continue
elif from_header == stored:
continue
if merged is override: # only allocate once something is actually folded
merged = dict(override or {})
merged.setdefault(attribute, from_header)
return merged
@@ -744,6 +939,10 @@ class EzdxfBackend(AutoCADBackend):
self._transaction_stack: list[Path] = [] # isolated transaction snapshots
self._connected = False
self._lock = asyncio.Lock()
# R32: the document a timed-out call was abandoned on is untrusted until
# it is replaced. See backends/quarantine.py for what that buys.
self._quarantine: QuarantineRecord | None = None
self._abandoned_calls: list[QuarantineRecord] = []
@property
def name(self) -> str:
@@ -837,6 +1036,9 @@ class EzdxfBackend(AutoCADBackend):
async def disconnect(self) -> None:
self._cleanup_undo_stack()
self._reset_document_state()
# The document goes away with the backend, so nothing is left to protect.
# The record stays in _abandoned_calls for anyone reading the transcript.
self._clear_quarantine("disconnect")
self._connected = False
def _cleanup_undo_stack(self):
@@ -887,17 +1089,128 @@ class EzdxfBackend(AutoCADBackend):
)
@staticmethod
def _timeout_error(func, timeout: float) -> RuntimeError:
name = getattr(func, "__name__", "call")
def _timeout_error(call: str, timeout: float) -> RuntimeError:
return RuntimeError(
f"ezdxf call '{name}' timed out after {timeout:g}s. asyncio.to_thread "
"cannot be cancelled, so the worker thread was abandoned and the drawing "
"may be left mid-write - reopen it before trusting its contents. Raise "
"EZDXF_CALL_TIMEOUT (seconds; 0 disables the timeout entirely) if this "
"operation is legitimately slow."
f"ezdxf call '{call}' timed out after {timeout:g}s. asyncio.to_thread "
"cannot be cancelled, so the worker thread was abandoned and may still be "
"writing to the drawing. Every later call on this document is refused - "
"reads included - until drawing_open(path), drawing_new() or "
"drawing_close() rebinds it, or until the abandoned call is seen to finish "
"cleanly, which lifts the refusal by itself. system_status() stays "
"answerable throughout. Raise EZDXF_CALL_TIMEOUT (seconds; 0 disables the "
"timeout entirely) if this operation is legitimately slow."
)
async def _async(self, func, *args, **kwargs):
# ── document quarantine (R32) ─────────────────────────────────────────────
def _reap_quarantine(self) -> None:
"""The free exit: an abandoned call that finished cleanly is no corruption.
The common real trigger for the deadline is a legitimately slow
``drawing_audit`` or a big ``drawing_export_pdf`` against the 120s
default, not a hang. If the ``_sync`` body ran to completion the document
holds exactly the state that call intended, and forcing a reopen for that
would be its own defect.
This inference is sound *only* because every other caller was refused in
the meantime, so "the call finished" really does mean "the document is
what that call intended". Add one mutating exemption to the gate and the
lift becomes a lie.
The lift is not silent: the caller never received the return value, so a
late-completing ``entity_create_*`` leaves an entity whose handle nobody
holds. That fact survives in the record.
"""
record = self._quarantine
if record is None or not record.watcher.finished:
return
if record.watcher.error is not None:
if record.outcome == "running":
error = record.watcher.error
record.outcome = "failed_after_deadline"
record.detail = f"{type(error).__name__}: {error}"
log.error(
"abandoned ezdxf call %r raised %s after its deadline; the document "
"stays quarantined - it was left mid-write",
record.call,
record.detail,
)
return
record.outcome = "completed_after_deadline"
record.capture_lost_result()
self._quarantine = None
log.warning(
"abandoned ezdxf call %r completed %.1fs after its deadline; lifting the "
"document quarantine. Its return value never reached the caller (%s), so "
"anything it created is in the drawing with no handle reported",
record.call,
record.age(),
record.result_repr,
)
def _clear_quarantine(self, released_by: str, new_path: str | None = None):
"""Release the quarantine because ``self._doc`` was just rebound.
Only the three calls that construct a *fresh* ``Drawing`` may do this:
the runaway keeps the object it captured, so its remaining writes land on
an orphan nobody reads. That holds for a ``_sync`` which captured
doc/msp once at entry — the shape of nearly every closure in this file.
One that re-reads ``self._msp()`` inside its own loop would follow the
rebind into the fresh document, and ``_async`` cannot prevent that, which
is why a still-live runaway is said out loud rather than implied away.
"""
record = self._quarantine
if record is None:
return None
self._quarantine = None
record.outcome = "released"
record.released_by = released_by
if record.thread_alive():
record.detail = "the abandoned worker was still running at recovery time"
log.warning(
"%s cleared the document quarantine while the abandoned %r worker is "
"STILL RUNNING; its writes go to the replaced Drawing unless its _sync "
"re-reads self._msp() inside its own loop",
released_by,
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
if same:
log.warning(
"%s is reopening %s, the same path the abandoned %r call was working "
"against; if that call is inside doc.saveas the file is being written "
"right now and this read may be of a truncated document",
released_by,
new_path,
record.call,
)
return record
def _quarantine_document(self, func, watcher, timeout: float) -> str:
"""Record an abandoned call and refuse the document until it is replaced.
``func`` is the caller's closure (it carries the qualname the message
needs); ``watcher`` is the wrapper the worker thread is actually running,
and the only thing that can report whether it ever finished.
"""
call = real_call_name(func)
record = QuarantineRecord(
call,
timeout,
watcher,
document_path=self._doc_path,
document_id=id(self._doc),
)
self._quarantine = record
self._abandoned_calls.append(record)
del self._abandoned_calls[:-_MAX_ABANDONED_RECORDS]
return call
async def _async(self, func, *args, quarantine_exit: bool = False, **kwargs):
"""Run a blocking ezdxf call in a worker thread, serialised and timed out.
The ezdxf ``Drawing`` is not thread-safe, so every backend method funnels
@@ -922,9 +1235,34 @@ class EzdxfBackend(AutoCADBackend):
for the lock does not abandon it: that lock is held by a live call which
will release it normally. Outside cancellation (client disconnect) also
releases normally, as it did before the timeout existed.
R32 — swapping the lock is only half the answer, and on its own it is the
more dangerous half: it removes the mutual exclusion the lock existed
for, so the *next* call runs against the same ``Drawing`` the runaway is
still mutating. Measured, that returned a fresh circle handle, an entity
count for a modelspace 50 entities further on, and a valid DXF of a state
that never existed. So the abandonment quarantines the document, and
every later call is refused **before** ``lock.acquire()`` — after the
acquire the refusal would queue behind an unrelated in-flight call and
the caller would wait the full timeout to be told the document is
untrusted. Reads are refused too: a read of a container another thread is
appending to does not tear loudly, it runs unbounded — ``drawing_info``
blew its own 2.0s deadline at 2.03s and abandoned a *second* worker.
``quarantine_exit=True`` is for the three calls that rebind ``self._doc``
to a freshly constructed object. They still take the lock, because two
concurrent recoveries would otherwise both rebind it and one fresh
document would be silently discarded with its caller's work in it.
"""
# The gate, before any await: a refusal must not queue.
if self._quarantine is not None:
self._reap_quarantine()
if self._quarantine is not None and not quarantine_exit:
raise quarantine_refusal(self._quarantine)
timeout = config.settings.ezdxf_call_timeout
if timeout <= 0:
# No deadline means no abandon branch, so nothing here can quarantine.
async with self._lock:
return await asyncio.to_thread(func, *args, **kwargs)
@@ -939,11 +1277,15 @@ class EzdxfBackend(AutoCADBackend):
# distinguishable, which is the whole reason they were separate.
acquired = False
call: asyncio.Future | None = None
# The only place an abandoned thread's outcome is observable is inside
# the thread: `call` is CANCELLED by the deadline, so its done-callback
# reports the await ending and never the worker. See AbandonedCall.
watched = AbandonedCall(func)
try:
async with asyncio.timeout_at(deadline):
await lock.acquire()
acquired = True
call = asyncio.ensure_future(asyncio.to_thread(func, *args, **kwargs))
call = asyncio.ensure_future(asyncio.to_thread(watched, *args, **kwargs))
result = await call
except TimeoutError as exc:
if not acquired:
@@ -960,13 +1302,18 @@ class EzdxfBackend(AutoCADBackend):
raise
if self._lock is lock:
self._lock = asyncio.Lock()
# Same handler, same branch as the lock swap: the two other exits
# above touched nothing, and quarantining on either would refuse the
# whole document for a failure that never reached it.
name = self._quarantine_document(func, watched, timeout)
log.error(
"ezdxf call %r exceeded EZDXF_CALL_TIMEOUT=%gs; abandoning the worker "
"thread and the lock it holds",
getattr(func, "__name__", func),
"thread and the lock it holds, and quarantining the document it may "
"still be writing to",
name,
timeout,
)
raise self._timeout_error(func, timeout) from exc
raise self._timeout_error(name, timeout) from exc
except BaseException:
# Only ours to release if we ever took it — cancellation can land
# while still queueing, and releasing someone else's lock there
@@ -1025,17 +1372,26 @@ class EzdxfBackend(AutoCADBackend):
def _sync():
if template and Path(template).exists():
self._doc = ezdxf.readfile(template)
_normalise_dimstyle_rounding(self._doc)
else:
self._doc = ezdxf.new(dxfversion="R2010")
_apply_iso_dimstyle(self._doc)
# R32: the rebind above is the release — the runaway keeps the
# Drawing it captured, so its remaining writes land on an orphan.
# Clear before _reset_history_baseline, which snapshots the new doc.
released = self._clear_quarantine("drawing_new")
self._doc_path = None
self._dirty = False
self._current_layer = "0"
self._reset_document_state()
self._current_space = "Model"
self._reset_history_baseline()
return {"ok": True, "name": "untitled.dxf"}
result = {"ok": True, "name": "untitled.dxf"}
if released is not None:
result["quarantine_cleared"] = released.to_dict()
return result
return await self._async(_sync)
return await self._async(_sync, quarantine_exit=True)
async def drawing_open(self, path: str) -> dict:
def _sync():
@@ -1051,6 +1407,12 @@ class EzdxfBackend(AutoCADBackend):
f"({size} > {max_bytes}). Set MAX_DXF_BYTES env var to override."
)
self._doc = ezdxf.readfile(path)
_normalise_dimstyle_rounding(self._doc)
# R32: only reached if the read succeeded. Reopening the very path a
# runaway doc.saveas is writing either fails loudly here (acceptable)
# or hands back a truncated document (not) — _clear_quarantine
# compares the two paths and says so.
released = self._clear_quarantine("drawing_open", path)
self._doc_path = path
self._dirty = False
try:
@@ -1061,9 +1423,12 @@ class EzdxfBackend(AutoCADBackend):
self._reset_document_state()
self._current_space = "Model"
self._reset_history_baseline()
return {"ok": True, "name": Path(path).name, "path": path}
result = {"ok": True, "name": Path(path).name, "path": path}
if released is not None:
result["quarantine_cleared"] = released.to_dict()
return result
return await self._async(_sync)
return await self._async(_sync, quarantine_exit=True)
async def drawing_save(self, path: str | None = None) -> dict:
save_path = path or self._doc_path
@@ -2042,16 +2407,32 @@ class EzdxfBackend(AutoCADBackend):
async def drawing_close(self, save: bool = True) -> dict:
def _sync():
if save and self._dirty and self._doc_path:
# R32: closing is a way out of a quarantine, but saving on the way is
# not. That save is the exact defect measured — a valid DXF holding
# 15200 of the 60000 entities the runaway went on to write, a
# considered document of a state that never existed. Say what was
# dropped instead of writing it.
quarantined = self._quarantine is not None
if save and self._dirty and self._doc_path and not quarantined:
self._doc.saveas(self._doc_path)
self._cleanup_undo_stack()
self._doc = None
released = self._clear_quarantine("drawing_close")
self._doc_path = None
self._dirty = False
self._reset_document_state()
return {"ok": True}
result = {"ok": True}
if released is not None:
result["quarantine_cleared"] = released.to_dict()
result["saved"] = False
result["warning"] = (
"the document was quarantined after a call was abandoned mid-write, "
"so unsaved changes were discarded rather than serialised out of a "
"drawing another thread may still have been mutating"
)
return result
return await self._async(_sync)
return await self._async(_sync, quarantine_exit=True)
async def drawing_undo(self) -> dict:
self._require_undo_history("drawing_undo")
@@ -2179,6 +2560,7 @@ class EzdxfBackend(AutoCADBackend):
def _restore_snapshot(self, path: Path) -> None:
self._doc = ezdxf.readfile(str(path))
_normalise_dimstyle_rounding(self._doc)
self._current_layer = str(self._doc.header.get("$CLAYER", "0"))
self._dirty = True
@@ -2909,7 +3291,7 @@ class EzdxfBackend(AutoCADBackend):
from engineering.tolerances import build_dim_override
override, text = build_dim_override(tol_upper, tol_lower, tol_mode, text_override)
override = _with_header_dimscale(self._require_doc(), override)
override = _with_header_dimvars(self._require_doc(), override)
msp = self._msp()
dim = msp.add_linear_dim(
base=(float(dim_x), float(dim_y)),
@@ -2917,6 +3299,7 @@ class EzdxfBackend(AutoCADBackend):
p2=(float(x2), float(y2)),
angle=float(rotation),
text=text if text is not None else "<>",
dimstyle=_DIMSTYLE_NAME,
override=override or None,
)
dim.render()
@@ -2946,7 +3329,8 @@ class EzdxfBackend(AutoCADBackend):
distance=math.sqrt(
(float(dim_x) - float(x1)) ** 2 + (float(dim_y) - float(y1)) ** 2
),
override=_with_header_dimscale(self._require_doc(), None),
dimstyle=_DIMSTYLE_NAME,
override=_with_header_dimvars(self._require_doc(), None),
)
dim.render()
ent = dim.dimension
@@ -2977,7 +3361,8 @@ class EzdxfBackend(AutoCADBackend):
line1=((vxf, vyf), (float(x1), float(y1))),
line2=((vxf, vyf), (float(x2), float(y2))),
location=(float(tx), float(ty)),
override=_with_header_dimscale(self._require_doc(), None),
dimstyle=_DIMSTYLE_NAME,
override=_with_header_dimvars(self._require_doc(), None),
)
dim.render()
ent = dim.dimension
@@ -3005,7 +3390,7 @@ class EzdxfBackend(AutoCADBackend):
from engineering.tolerances import build_dim_override
override, text = build_dim_override(tol_upper, tol_lower, tol_mode, text_override)
override = _with_header_dimscale(self._require_doc(), override)
override = _with_header_dimvars(self._require_doc(), override)
msp = self._msp()
cxf, cyf = float(cx), float(cy)
radius = math.sqrt((chord_x - cxf) ** 2 + (chord_y - cyf) ** 2)
@@ -3021,6 +3406,7 @@ class EzdxfBackend(AutoCADBackend):
cyf + (radius + leader) * math.sin(angle_rad),
),
text=text if text is not None else "<>",
dimstyle=_DIMSTYLE_NAME,
override=override or None,
)
dim.render()
@@ -3049,7 +3435,7 @@ class EzdxfBackend(AutoCADBackend):
from engineering.tolerances import build_dim_override
override, text = build_dim_override(tol_upper, tol_lower, tol_mode, text_override)
override = _with_header_dimscale(self._require_doc(), override)
override = _with_header_dimvars(self._require_doc(), override)
msp = self._msp()
x1f, y1f, x2f, y2f = float(x1), float(y1), float(x2), float(y2)
cx = (x1f + x2f) / 2
@@ -3070,6 +3456,7 @@ class EzdxfBackend(AutoCADBackend):
cy + (radius + leader) * math.sin(angle_rad),
),
text=text if text is not None else "<>",
dimstyle=_DIMSTYLE_NAME,
override=override or None,
)
dim.render()
@@ -3718,7 +4105,6 @@ class EzdxfBackend(AutoCADBackend):
min_area: float | None = None,
) -> dict:
def _sync():
from engineering.measure import polygon_area_perimeter
if min_area is not None and float(min_area) < 0:
return {"ok": False, "error": "min_area cannot be negative; no area is negative"}
@@ -3739,19 +4125,26 @@ class EzdxfBackend(AutoCADBackend):
]
def _area(entity) -> float | None:
"""None means "this shape has no area", not "its area is zero"."""
dxftype = entity.dxftype()
if dxftype == "CIRCLE":
return math.pi * float(entity.dxf.radius) ** 2
if dxftype == "LWPOLYLINE" and entity.closed:
points = [[p[0], p[1], p[4]] for p in entity.get_points()]
return polygon_area_perimeter(points, closed=True)[0]
if dxftype in ("SOLID", "TRACE", "HATCH"):
try:
return float(entity.dxf.get("area", 0.0) or 0.0)
except Exception:
return None
return None
"""None means "this shape has no area", not "its area is zero".
Answered by the same measurement `entity_measure` uses, so the
two cannot disagree about the same entity. They did: this used
to read ``entity.dxf.get("area", 0.0)`` for SOLID, TRACE and
HATCH, none of which carry an ``area`` DXF attribute, so every
one of them measured 0.0 and any positive threshold silently
excluded every hatch in the drawing while ``filtered_by``
reported that the filter had run.
The two refusals stay distinguishable. A type that bounds no
area on any engine raises RuntimeError and is *excluded*; a
type this engine cannot evaluate (ACIS) refuses with a
capability and is also excluded, because "I could not measure
it" must never read as "it is big enough".
"""
try:
return float(_measure_dxf_entity(entity, 0.001)["area"])
except (UnsupportedCapabilityError, RuntimeError, ValueError, TypeError):
return None
handles = []
for entity in self._msp():
@@ -4846,6 +5239,7 @@ class EzdxfBackend(AutoCADBackend):
p = self._transaction_stack.pop()
try:
self._doc = ezdxf.readfile(str(p))
_normalise_dimstyle_rounding(self._doc)
self._current_layer = str(self._doc.header.get("$CLAYER", "0"))
self._dirty = True
finally:
@@ -4860,6 +5254,12 @@ class EzdxfBackend(AutoCADBackend):
# ── system ────────────────────────────────────────────────────────────────
async def system_status(self) -> dict:
# R32: deliberately NOT routed through _async. It reads self._doc
# directly, which is what keeps it answerable while the document is
# quarantined; putting it behind the lock would make the diagnostic
# channel the first thing the quarantine closes, leaving the user with a
# refusal and no way to ask what happened.
self._reap_quarantine()
has_doc = self._doc is not None
return {
"backend": "ezdxf",
@@ -4868,6 +5268,8 @@ class EzdxfBackend(AutoCADBackend):
"document_path": self._doc_path,
"unsaved_changes": self._dirty,
"transaction_depth": len(self._transaction_stack),
"quarantine": self._quarantine.to_dict() if self._quarantine else None,
"abandoned_calls": [record.to_dict() for record in self._abandoned_calls],
"capabilities": [
"file_read_write",
"dxf_export",
+208
View File
@@ -0,0 +1,208 @@
"""Document quarantine — the vocabulary for a call that was abandoned mid-write.
``EzdxfBackend._async`` cannot cancel an ``asyncio.to_thread`` worker. When a
call overruns ``EZDXF_CALL_TIMEOUT`` the handler swaps in a fresh
``asyncio.Lock`` so later callers do not deadlock behind the orphaned one — and
that swap is precisely what removes the mutual exclusion the lock existed for.
The runaway thread keeps a live reference to the one shared ``ezdxf.Drawing``,
which is explicitly not thread-safe, through the closure it was handed.
Measured, with a runaway ``_sync`` that really appends LINEs to ``_doc``:
``entity_create_circle`` after the abandonment returned handle ``A1``;
``drawing_info`` reported 116 entities for a modelspace observed at 302 and
climbing; ``drawing_save`` returned ``{"ok": True}`` and wrote a valid DXF of
15200 of the 60000 entities the runaway would go on to write — a considered
document of a state that never existed. Only the timed-out caller was warned.
So the abandonment quarantines the DOCUMENT, not the backend. The document stays
untrusted until it is REPLACED by an object the abandoned thread has no
reference to (``drawing_open`` / ``drawing_new`` / ``drawing_close``), or until
the abandoned call is seen to finish cleanly — see ``AbandonedCall``.
This module deliberately has no ezdxf import: ``server.py`` needs the exception
type to classify a refusal on a box where a backend's dependencies are missing.
"""
from __future__ import annotations
import time
from typing import Any
__all__ = [
"AbandonedCall",
"DocumentQuarantineError",
"QuarantineRecord",
"quarantine_refusal",
"real_call_name",
]
#: Longest ``repr`` kept for a return value nobody received. A late
#: ``entity_list`` can return thousands of entities; the diagnostic is that the
#: value was lost, not what was in it.
_MAX_RESULT_REPR = 200
def real_call_name(func: Any) -> str:
"""The backend method ``func`` belongs to, not the closure's own name.
Every real backend method hands ``_async`` a closure literally named
``_sync`` — measured: ``__name__='_sync'``,
``__qualname__='EzdxfBackend.drawing_save.<locals>._sync'`` — so
``__name__`` made every production timeout read ``ezdxf call '_sync' timed
out``. The qualname carries the name the user typed and was being discarded.
"""
qualname = getattr(func, "__qualname__", "") or ""
parts = qualname.split(".")
if "<locals>" in parts:
index = parts.index("<locals>")
if index:
return parts[index - 1]
return getattr(func, "__name__", None) or "call"
class AbandonedCall:
"""Observation post for a worker thread ``_async`` can no longer await.
The obvious hook does not work, and this was checked before being relied on:
``asyncio.timeout_at`` **cancels** the Future ``_async`` awaits, so by the
time the abandon branch runs that Future is already done and
``add_done_callback`` fires immediately with ``cancelled()`` True. It reports
the *await* ending, never the thread — which ``asyncio.to_thread`` cannot
cancel. The only place the thread's outcome is observable is inside the
thread, so ``_async`` wraps every timed call in one of these.
Deliberately no ``threading.Event``: this is allocated on every ``_async``
call and an Event carries a Condition and a Lock. ``finished`` is a plain
bool written once by the worker and read from the event loop; a bool store is
atomic under the GIL and nothing here ever waits on it.
"""
__slots__ = ("func", "finished", "result", "error")
def __init__(self, func):
self.func = func
self.finished = False
self.result: Any = None
self.error: BaseException | None = None
def __call__(self, *args, **kwargs):
try:
self.result = self.func(*args, **kwargs)
return self.result
except BaseException as exc:
self.error = exc
raise
finally:
self.finished = True
class QuarantineRecord:
"""One abandoned call, and what became of it.
Holds the identity of the ``Drawing`` that was live at abandon time as well
as its path: rebinding ``self._doc`` isolates a ``_sync`` that captured
doc/msp once at entry (the shape of nearly every closure in the backend), but
one that re-reads ``self._msp()`` inside its own loop would follow the rebind
into the fresh document. ``_async`` cannot prevent that, so recovery says
when the runaway was still alive instead of implying it was fenced off.
"""
def __init__(
self,
call: str,
timeout: float,
watcher: Any,
*,
document_path: str | None = None,
document_id: int | None = None,
):
self.call = call
self.timeout = float(timeout)
self.watcher = watcher
self.document_path = document_path
self.document_id = document_id
self.stamp = time.monotonic()
self.outcome = "running"
self.detail = ""
self.released_by: str | None = None
self.result_lost = False
self.result_repr: str | None = None
def age(self) -> float:
return time.monotonic() - self.stamp
def thread_alive(self) -> bool:
return not self.watcher.finished
def capture_lost_result(self) -> None:
"""Record that the return value never reached the caller.
A late-completing ``entity_create_*`` leaves an entity in the document
whose handle nobody holds. That is not recoverable, so it is at least
reported rather than dropped.
"""
self.result_lost = True
try:
text = repr(self.watcher.result)
except Exception: # a __repr__ that raises must not break the report
text = f"<unreprable {type(self.watcher.result).__name__}>"
if len(text) > _MAX_RESULT_REPR:
text = text[: _MAX_RESULT_REPR - 3] + "..."
self.result_repr = text
def to_dict(self) -> dict:
return {
"call": self.call,
"timeout": self.timeout,
"age_seconds": round(self.age(), 3),
"thread_alive": self.thread_alive(),
"outcome": self.outcome,
"detail": self.detail,
"released_by": self.released_by,
"result_lost": self.result_lost,
"lost_return_value": self.result_repr,
"document_path": self.document_path,
}
def quarantine_refusal(record: QuarantineRecord) -> DocumentQuarantineError:
"""The refusal every later call on a quarantined document gets."""
thread = "still running" if record.thread_alive() else "has since stopped"
return DocumentQuarantineError(
record,
f"ezdxf document quarantined: '{record.call}' overran "
f"EZDXF_CALL_TIMEOUT={record.timeout:g}s {record.age():.1f}s ago and its worker "
f"thread ({thread}) cannot be cancelled, so it may still be mutating this "
"drawing. Every call on it is refused, reads included - a read of a container "
"another thread is appending to does not fail loudly, it never finishes. "
"Recover with drawing_open(path), drawing_new() or drawing_close(): those "
"rebind the document to a freshly constructed object the abandoned thread "
"cannot reach. Undo and rollback are not a way back - their snapshots may "
"themselves have been serialised mid-write. system_status() stays answerable "
"and reports the abandoned call.",
)
class DocumentQuarantineError(RuntimeError):
"""The open document is untrusted because a call was abandoned on it.
Carries **no** ``capability`` attribute, on purpose.
``server._batch_capability_of`` classifies refusals structurally — any
exception with a non-empty ``capability`` str plus a callable ``to_dict()``
is reported to the client as ``kind: "unsupported"`` and filed by
``cad_batch`` as a capability step row. Borrowing one here would tell the
client the ezdxf engine cannot draw a circle. The engine is fine; the
document is not. This needs its own kind, not a borrowed one.
"""
def __init__(self, record: QuarantineRecord, message: str):
super().__init__(message)
self.quarantine = record.to_dict()
def to_dict(self) -> dict:
return {
"ok": False,
"kind": "quarantined",
"error": str(self),
"quarantine": self.quarantine,
}