Two research dossiers under docs/features: - async/: Asyncify currData contention — the clipboard crash and post-idle tool hang as one disease (single global suspension slot, three producers), asyncify internals walkthroughs, prior art, arbiter designs A/B. - wasm-exceptions/: -fexceptions vs -fwasm-exceptions — measured invoke-driven share of the asyncify tax on pcbnew (59% raw / 64% gzip; download 64.5->36 MB if migrated), KiCad dialog-in-catch audit (85 direct / 93 review of 636, catch_audit.py included), binaryen toolchain status (partial EH support since v125; TryTable unsupported), and a catch-arm-hoisting pre-pass design that would obsolete the KiCad-side refactor. Cross-linked with the parked KICAD_WASM_EH end-to-end experiment (docs/wasm-exceptions-experiment.md). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
109 lines
5.9 KiB
Python
109 lines
5.9 KiB
Python
#!/usr/bin/env python3
|
|
"""Audit KiCad catch blocks for wasm-EH safety (suspension inside catch handlers)."""
|
|
import os, re, sys, json
|
|
from collections import defaultdict
|
|
|
|
ROOT = "/Users/V/IdeaProjects/kicad-wasm/kicad"
|
|
SKIP_DIRS = {"thirdparty", ".git", "qa", "build"}
|
|
|
|
# Calls that suspend (Asyncify) directly or are dialog wrappers in KiCad/wx
|
|
DIRECT_SUSPEND = [
|
|
"DisplayErrorMessage", "DisplayError", "DisplayInfoMessage", "DisplayHtmlInfoMessage",
|
|
"wxMessageBox", "ShowModal", "ShowQuasiModal", "KIDIALOG", "OKOrCancelDialog",
|
|
"wxMessageDialog", "IsOK(", "DisplayLoadError", "ShowAboutDialog",
|
|
"wxGetSingleChoice", "wxTextEntryDialog", "wxFileDialog", "wxDirDialog",
|
|
"GetDataFromClipboard", "SaveToClipboard", "wxClipboard", "EnumerateFacenames",
|
|
]
|
|
INFOBAR = ["ShowInfoBarError", "ShowInfoBarMsg", "ShowInfoBarWarning"]
|
|
# Benign callees: logging (wxLog* is deferred to idle-time flush -> not inside catch),
|
|
# string formatting, rethrow, reporters writing text
|
|
BENIGN = {
|
|
"wxLogError", "wxLogWarning", "wxLogMessage", "wxLogTrace", "wxLogDebug", "wxLogVerbose",
|
|
"Format", "Printf", "printf", "fprintf", "snprintf", "What", "Problem", "Where",
|
|
"GetErrorMessage", "wxString", "FROM_UTF8", "TO_UTF8", "UTF8", "c_str", "mb_str",
|
|
"GetChars", "IsEmpty", "empty", "clear", "size", "length", "Report", "ReportTail",
|
|
"ReportHead", "Add", "push_back", "emplace_back", "insert", "append", "Append",
|
|
"SetError", "assert", "wxASSERT", "wxFAIL", "wxCHECK", "abort", "exit",
|
|
"GetMessages", "GetErrors", "reset", "get", "release", "find", "count", "at",
|
|
"begin", "end", "str", "Mid", "Left", "Right", "Trim", "Lower", "Upper",
|
|
"StartsWith", "EndsWith", "Contains", "Replace", "Remove", "make_unique",
|
|
"make_shared", "static_cast", "dynamic_cast", "const_cast", "reinterpret_cast",
|
|
"Clear", "Close", "swap", "resize", "erase", "Set", "SetBitmap", "Destroy",
|
|
"wxT", "_", "_HKI", "traceSchPlugin", "TRACE", "what", "THROW_IO_ERROR", "wxS", "wxFAIL_MSG", "IDF_ERROR", "LIBRARY_ERROR", "FUTURE_FORMAT_ERROR", "PARSE_ERROR", "KI_PARAM_ERROR", "fmt::format", "format", "GetFullPath", "HandleException", "Pgm", "current_exception", "rethrow_exception", "Nickname", "GetName", "GetLibNickname", "GetLibItemName", "wx_str", "GetMessageString", "UnescapeString", "exceptions", "CLOSE_STREAM", "AddError", "GetRequiredVersion", "Disconnect", "From_UTF8", "typeid", "LogException", "Instance", "GetFullName", "GetUniStringLibId", "ShowText", "SetStatusText", "GetItemDescription", "GetClass", "GetFriendlyName", "IsValid", "GetPath", "GetFileName", "GetExtension", "Length", "GetData", "data", "front", "back", "pop_back", "emplace", "GetSettingsManager",
|
|
}
|
|
CALL_RE = re.compile(r"\b([A-Za-z_][A-Za-z0-9_:]*)\s*\(")
|
|
CATCH_RE = re.compile(r"\bcatch\s*\(")
|
|
|
|
def find_block(text, start):
|
|
"""Return (block_text, end_idx) for brace-block starting at first '{' at/after start."""
|
|
i = text.find("{", start)
|
|
if i < 0: return None, start
|
|
depth, j, n = 0, i, len(text)
|
|
in_str = in_chr = in_lc = in_bc = False
|
|
while j < n:
|
|
c = text[j]; p = text[j-1] if j else ""
|
|
if in_lc:
|
|
if c == "\n": in_lc = False
|
|
elif in_bc:
|
|
if p == "*" and c == "/": in_bc = False
|
|
elif in_str:
|
|
if c == '"' and p != "\\": in_str = False
|
|
elif in_chr:
|
|
if c == "'" and p != "\\": in_chr = False
|
|
elif c == "/" and j+1 < n and text[j+1] == "/": in_lc = True
|
|
elif c == "/" and j+1 < n and text[j+1] == "*": in_bc = True
|
|
elif c == '"': in_str = True
|
|
elif c == "'": in_chr = True
|
|
elif c == "{": depth += 1
|
|
elif c == "}":
|
|
depth -= 1
|
|
if depth == 0: return text[i:j+1], j+1
|
|
j += 1
|
|
return None, start
|
|
|
|
stats = defaultdict(int)
|
|
direct_sites, review_sites = [], []
|
|
review_callees = defaultdict(int)
|
|
per_dir = defaultdict(lambda: defaultdict(int))
|
|
|
|
for dirpath, dirnames, filenames in os.walk(ROOT):
|
|
dirnames[:] = [d for d in dirnames if d not in SKIP_DIRS]
|
|
for fn in filenames:
|
|
if not fn.endswith(".cpp"): continue
|
|
path = os.path.join(dirpath, fn)
|
|
rel = os.path.relpath(path, ROOT)
|
|
top = rel.split(os.sep)[0]
|
|
try: text = open(path, encoding="utf-8", errors="replace").read()
|
|
except OSError: continue
|
|
for m in CATCH_RE.finditer(text):
|
|
block, _ = find_block(text, m.end())
|
|
if block is None: continue
|
|
stats["total"] += 1
|
|
line = text[:m.start()].count("\n") + 1
|
|
loc = f"{rel}:{line}"
|
|
if any(s in block for s in DIRECT_SUSPEND):
|
|
stats["direct_suspend"] += 1; per_dir[top]["direct"] += 1
|
|
direct_sites.append(loc)
|
|
continue
|
|
if any(s in block for s in INFOBAR):
|
|
stats["infobar"] += 1; per_dir[top]["infobar"] += 1
|
|
continue
|
|
calls = set(CALL_RE.findall(block)) - {"catch", "if", "for", "while", "switch", "return", "sizeof", "throw"}
|
|
unknown = {c for c in calls if c.split("::")[-1] not in BENIGN and c not in BENIGN}
|
|
if not unknown:
|
|
stats["trivial"] += 1; per_dir[top]["trivial"] += 1
|
|
else:
|
|
stats["needs_review"] += 1; per_dir[top]["review"] += 1
|
|
review_sites.append((loc, sorted(unknown)[:6]))
|
|
for c in unknown: review_callees[c] += 1
|
|
|
|
print(json.dumps(stats, indent=1))
|
|
print("\n== per top-level dir (direct/review/trivial/infobar):")
|
|
for d in sorted(per_dir, key=lambda d: -per_dir[d]["direct"]):
|
|
p = per_dir[d]
|
|
print(f" {d:24s} direct={p['direct']:3d} review={p['review']:3d} trivial={p['trivial']:3d} infobar={p['infobar']:2d}")
|
|
print("\n== top 25 unknown callees inside needs_review catches (freq):")
|
|
for c, n in sorted(review_callees.items(), key=lambda kv: -kv[1])[:25]:
|
|
print(f" {n:3d} {c}")
|
|
print(f"\n== direct-suspend sites ({len(direct_sites)}):")
|
|
for s in direct_sites: print(" " + s)
|