← All Pull Requests

No dialog storms from a broken add-in deploy, and refuse incomplete payloads (issue 69) #5

Closed opened by Colby Knox 2026-08-28

Companion PR for issue 69, based on repo commit c11a96c2 (Publish 1.9.237). Three surgical changes, two files.

addin/AdomBridge/AdomBridge.py:

  1. run() shows the failure messageBox ONCE per Fusion session (_start_error_shown); repeats go to Application.log. Several installed copies each failing, plus lifecycle retries, turned one broken deploy into a dialog storm.
  2. stop() never raises a modal: Fusion re-fires the lifecycle on ordinary user actions, so a stop-time messageBox cost Colby one dialog PER ACTION today. run() already told them once; stop failures log.
  3. run() treats a port-8774 bind OSError as a healthy sibling copy serving (install() writes every AddIns location and current Fusion loads more than one; both loaded on Colby-ROG2), logs it, and stands down quietly. Without this, the fix for 69 trades the traceback dialog for a port-in-use dialog on every launch.

install_addin.py: 4. _check_relative_imports() preflight: every relative import in the payload must resolve to a file IN the payload, or install() refuses and leaves the installed add-in untouched. Replayed against 69's exact state: a payload missing commands/activity_palette.py is refused naming commands/init.py's import; the complete payload passes clean. This is the transactional-sync ask made cheap: the sync cannot deliver the half that references the missing half, and the mirror step cannot delete a hand-restored file to re-brick a machine.

py_compile clean on both files; the checker is unit-exercised offline (complete payload CLEAN, ablated payload refused with the named import). Not live-run inside Fusion; the run/stop changes are guard-path only. Base-drift caveat as always: if your working tree is ahead of c11a96c2, hand-lift rather than merge.

Diff Skip to comments (2)

--- a/addin/AdomBridge/AdomBridge.py+++ b/addin/AdomBridge/AdomBridge.py@@ -1,114 +1,144 @@⋯ 14 unchanged lines ⋯ _custom_event = None _event_handler = None _http_thread = None+# One start-failure modal per Fusion session; repeats go to the log (#69).+_start_error_shown = False  CUSTOM_EVENT_ID = "AdomBridgeCommandEvent" ⋯ 55 unchanged lines ⋯          # Start the HTTP server on a background daemon thread         from . import http_server-        _http_thread = http_server.start(CUSTOM_EVENT_ID, _app)+        try:+            _http_thread = http_server.start(CUSTOM_EVENT_ID, _app)+        except OSError:+            # Port 8774 already bound: a SIBLING copy of this add-in is+            # serving. install() writes every known AddIns location and newer+            # Fusion builds scan more than one of them (seen live on #69:+            # FusionAddins AND API/AddIns both loaded), so a second copy+            # binding the port is normal operation, not an error the user can+            # act on. Stand down quietly; the serving copy has the job.+            adsk.core.Application.log(+                "Adom Bridge add-in: port 8774 already served by a sibling copy; this copy is standing down")+            return          # Log to the Text Commands palette (non-blocking)         adsk.core.Application.log(f"Adom Bridge add-in started (port 8774)")      except Exception:-        # Only use messageBox for errors — worth blocking to show the user-        if _ui:+        # One modal, total. The first failure is worth blocking to show the+        # user; repeats (Fusion can retry the lifecycle, and several installed+        # copies can each fail the same way) turn one broken deploy into a+        # dialog storm (#69). After the first box, log instead.+        global _start_error_shown+        if _ui and not _start_error_shown:+            _start_error_shown = True             _ui.messageBox(f"Adom Bridge failed to start:\n{traceback.format_exc()}")+        else:+            try:+                adsk.core.Application.log(f"Adom Bridge failed to start:\n{traceback.format_exc()}")+            except Exception:+                pass   def stop(context):⋯ 19 unchanged lines ⋯         _http_thread = None      except Exception:-        if _ui:-            _ui.messageBox(f"Adom Bridge stop error:\n{traceback.format_exc()}")+        # NEVER a modal from stop. Fusion re-fires the lifecycle on ordinary+        # events, so when the add-in is broken a stop-time messageBox costs the+        # user one dialog PER ACTION they take (seen live 2026-08-28, #69).+        # run() already told them once; stop failures go to the log.+        try:+            adsk.core.Application.log(f"Adom Bridge stop error:\n{traceback.format_exc()}")+        except Exception:+            pass+--- a/install_addin.py+++ b/install_addin.py@@ -1,152 +1,189 @@⋯ 77 unchanged lines ⋯                     print(f"  Removed dir: {rel}")  +def _check_relative_imports(source: Path) -> list[str]:+    """Refuse an incomplete payload BEFORE it ships. #69's failure mode: a+    payload carried the new commands/__init__.py but not the+    activity_palette.py it imports, so the sync faithfully delivered the half+    that references the missing half (and, because the sync mirrors, deleted+    any hand-restored copy as an 'extra'). Every relative import in the+    payload must resolve inside the payload; anything else bricks Fusion+    startup with a raw traceback dialog."""+    import re+    problems = []+    for py in source.rglob("*.py"):+        try:+            text = py.read_text(encoding="utf-8", errors="replace")+        except OSError:+            continue+        for m in re.finditer(r"^\s*from\s+(\.+)([\w.]+)\s+import\s", text, re.M):+            dots, mod = m.group(1), m.group(2)+            base = py.parent+            for _ in range(len(dots) - 1):+                base = base.parent+            target = base.joinpath(*mod.split("."))+            if not (target.with_suffix(".py").exists() or (target / "__init__.py").exists()):+                problems.append(f"{py.relative_to(source)}: 'from {dots}{mod} import ...' resolves to no file in the payload")+    return problems++ def find_addins_dir() -> Path | None:     """Find an existing Fusion AddIns directory, or return the first candidate."""     for candidate in TARGET_CANDIDATES:⋯ 20 unchanged lines ⋯         print(f"ERROR: Source add-in not found at {SOURCE_DIR}")         sys.exit(1) +    problems = _check_relative_imports(SOURCE_DIR)+    if problems:+        print("ERROR: refusing to install an incomplete add-in payload:")+        for p in problems:+            print(f"  {p}")+        print("Syncing this would brick Fusion startup with a traceback dialog (#69), "+              "and the mirror step would delete any hand-restored copy of the missing file. "+              "The existing installed add-in is left untouched.")+        sys.exit(1)+     print(f"Source:  {SOURCE_DIR}")     installed_to = []     for addins_dir in TARGET_CANDIDATES:⋯ 40 unchanged lines ⋯  if __name__ == "__main__":     main()+

Comments

John Lauer · 2mo ago

Landed by hand in 1.9.238 (base drift from c11a96c2): all three AdomBridge.py changes and the install preflight are in, verbatim in intent, and replayed against issue 69's exact state. Also fixed the cause upstream of your ask: the release zip is now built from the tree with its own import preflight, so a payload like 69's cannot be built, let alone installed. Please close as landed or rebase onto 1.9.238. Detail on #69.

Colby Knox · 2mo ago

Closing as landed-by-hand in 1.9.238, verified from this side: the zip carries all 20 commands modules, and the cause fix (tree-globbed payload + refuse-on-unresolvable-imports in BOTH the builder and the installer) is stronger than what this PR asked for. Base-drift hand-lift was the right call again.

Log in to comment.