app
Adom Desktop - Fusion 360 Bridge
Public Made by Adomby adom
Drive Autodesk Fusion 360 from the cloud via Adom Desktop: component libraries, IPC package generation, board layout, exports (STEP/Gerbers/BOM/CPL), fast APS cloud search, and parametric modeling.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445
"""Background HTTP server for the Adom Bridge add-in.
Runs on a daemon thread inside Fusion 360's process.
Incoming POST /command requests fire a CustomEvent to marshal execution
to the main Fusion thread, then wait for the result.
Threading model:
- HTTP server is ThreadingHTTPServer: multiple HTTP threads can serve
requests concurrently (for /health, status, etc.).
- Fusion's main thread is a HARD SINGLETON. All Fusion API work is
marshaled via CustomEvent and executed serially there.
- A module-level _main_thread_lock serializes CustomEvent fires so that
only one request has work pending on the main thread at any time.
New HTTP threads block on the lock until the in-flight request
completes. This prevents request stacking (which wastes main-thread
cycles on exports whose HTTP waiter already gave up).
- A staleness check in set_result() drops results whose HTTP waiter has
already timed out. The CustomEventHandler also checks staleness
BEFORE dispatching, so it can skip heavy work entirely.
"""
import json
import os
import threading
import time
import uuid
from http.server import ThreadingHTTPServer, BaseHTTPRequestHandler
import adsk.core
from .commands.cloud_documents import get_walk_progress
PORT = 8774
# Add-in version — read from the sibling manifest so /health + /status can
# REPORT it. The bridge compares this against the version it bundles to catch a
# STALE add-in (issue #55: an old Roaming add-in that never re-synced from the
# bridge cache, which made fusion_aps_open fail SILENTLY). Sourced from the
# manifest so a single bump is the source of truth (no hardcode to drift).
def _read_addin_version():
try:
mf = os.path.join(os.path.dirname(__file__), "AdomBridge.manifest")
with open(mf, "r", encoding="utf-8") as f:
return (json.load(f) or {}).get("version", "unknown")
except Exception:
return "unknown"
ADDIN_VERSION = _read_addin_version()
_server = None
_server_thread = None
_custom_event_id = None
_app = None
# Thread-safe result passing: request_id -> (Event, result_dict)
_result_events = {}
_result_data = {}
_lock = threading.Lock()
# Main thread singleton lock — only ONE request may have a CustomEvent
# in flight to Fusion's main thread at any time. HTTP threads block
# here to prevent request stacking.
_main_thread_lock = threading.Lock()
# Main thread liveness tracking
_last_main_thread_ts = 0.0 # time.time() of last successful set_result()
_pending_request_ids = set() # request_ids currently waiting for main thread
# Busy state tracking — which command currently holds the main thread lock.
# Read by the /status endpoint (non-blocking) so ANY bridge can check if
# the add-in is busy, even cross-process. This is the single source of truth.
_busy_state_lock = threading.Lock()
_busy_state = None # None or {"command": str, "started_at": float, "request_id": str}
def _set_busy(command: str, request_id: str):
with _busy_state_lock:
global _busy_state
_busy_state = {"command": command, "started_at": time.time(), "request_id": request_id}
def _clear_busy():
with _busy_state_lock:
global _busy_state
_busy_state = None
def _get_busy() -> dict | None:
with _busy_state_lock:
return dict(_busy_state) if _busy_state else None
# ── Stale-lock watchdog (v1.0.2) ──────────────────────────────────────
#
# Failure mode being defended against:
# Fusion's native cloud SDK can wedge on a slow / failed cloud API call
# while holding the Python GIL. While wedged, Python code in the addin
# does NOT run — including the per-command timeout in _wait_for_result.
# The HTTP thread eventually times out (after `timeout` seconds) and
# returns to the client, but the `finally` block can't run because the
# thread is blocked at the C++/Fusion boundary. Net result:
# - _main_thread_lock stays held forever
# - _busy_state stays populated forever
# - Every new POST /command request blocks on _main_thread_lock.acquire()
# for its `timeout` seconds, then returns main_thread_busy
# - From the user's perspective, the addin is dead until Fusion restarts
#
# Defense: a daemon thread checks _busy_state every 5s. If a busy state
# has been active longer than the per-command timeout + WATCHDOG_GRACE,
# we forcibly release _main_thread_lock so new requests can proceed.
# We can't unwedge the previously-stuck command (it's blocked in C++),
# but at least subsequent calls work without restarting Fusion.
WATCHDOG_GRACE = 30 # seconds beyond per-command timeout before force-release
_watchdog_stop = threading.Event()
_watchdog_thread = None
def _watchdog_loop():
"""Force-release _main_thread_lock if a command has been busy too long."""
while not _watchdog_stop.is_set():
try:
busy = _get_busy()
if busy:
cmd = busy.get("command", "")
started = busy.get("started_at", 0.0)
elapsed = time.time() - started
# Get the timeout that applied to this command
cmd_timeout = PER_COMMAND_TIMEOUT.get(cmd, COMMAND_TIMEOUT)
if elapsed > cmd_timeout + WATCHDOG_GRACE:
try:
adsk.core.Application.log(
f"AdomBridge: WATCHDOG force-releasing _main_thread_lock — "
f"'{cmd}' held for {elapsed:.1f}s "
f"(> {cmd_timeout}+{WATCHDOG_GRACE}s grace). "
f"Fusion main thread likely wedged on native cloud call. "
f"Subsequent requests can now proceed; the wedged "
f"command will still leak its C++/Python state until "
f"Fusion restarts."
)
except Exception:
pass
# Clear the busy marker FIRST so /status no longer
# reports the stale command.
_clear_busy()
# Force-release. If the lock owner thread eventually
# unwedges and tries to release(), it'll get a
# RuntimeError which we accept as the cost of keeping
# subsequent requests flowing.
try:
_main_thread_lock.release()
except RuntimeError:
# Already released (race with the owner thread
# somehow making progress). Fine.
pass
# Also drop any pending request_ids so set_result()
# from a late-arriving wedged command can't leak.
with _lock:
_pending_request_ids.clear()
except Exception:
pass # watchdog must never die
_watchdog_stop.wait(5) # check every 5s
# Default per-command timeout. Heavy 3D exports (STEP/IGES/SAT/STL on
# panelized boards with 100+ placements) can take 120s+. Set generously.
COMMAND_TIMEOUT = 300 # seconds
# Per-command timeout overrides. Fast commands keep short timeouts so a
# hung main thread is surfaced quickly.
PER_COMMAND_TIMEOUT = {
"get_app_state": 10,
"document_info": 15,
"take_screenshot": 30,
"activate_document": 30,
"close_document": 30,
"close_all_documents": 60,
# Exports can be slow on heavy designs
"export_step": 300,
"export_iges": 300,
"export_sat": 300,
"export_stl": 300,
"export_3mf": 300,
"export_usdz": 300,
"export_obj": 300,
"export_f3d": 300,
"export_fbx": 300,
"export_dxf": 120,
"export_dwg": 120,
"export_skp": 300,
# Manufacturing exports (run EAGLE ULPs, can be slow)
"export_gerbers": 180,
"export_bom": 60,
"export_cpl": 60,
"export_board_image": 60,
# Cloud tree walking / search — BFS over hundreds of folders
"walk_cloud_tree": 600,
"search_cloud_files": 180,
}
def is_request_alive(request_id: str) -> bool:
"""Check whether an HTTP thread is still waiting for this request.
Called from the main thread (CustomEventHandler) BEFORE doing heavy
work, so we can skip exports whose HTTP waiter has already bailed.
"""
with _lock:
return request_id in _result_events
def set_result(request_id: str, result: dict):
"""Called from the main thread (CustomEventHandler) to deliver a result."""
with _lock:
_last_main_thread_ts_local = time.time()
# Update module-level timestamp
global _last_main_thread_ts
_last_main_thread_ts = _last_main_thread_ts_local
_pending_request_ids.discard(request_id)
event = _result_events.get(request_id)
if event:
# Normal path: HTTP thread is still waiting
_result_data[request_id] = result
event.set()
# else: HTTP thread already timed out — discard result (don't leak)
def _wait_for_result(request_id: str, timeout: float) -> dict:
"""Wait for the main thread to deliver a result for this request."""
event = threading.Event()
with _lock:
_result_events[request_id] = event
got_result = event.wait(timeout=timeout)
with _lock:
_result_events.pop(request_id, None)
result = _result_data.pop(request_id, None)
if not got_result:
# Timed out — remove from pending so set_result() won't leak
_pending_request_ids.discard(request_id)
if not got_result or result is None:
return {"success": False, "error": f"Command timed out after {timeout}s"}
return result
class AdomBridgeHandler(BaseHTTPRequestHandler):
"""HTTP handler for the add-in server."""
def do_GET(self):
if self.path == "/health":
with _lock:
pending = len(_pending_request_ids)
last_ts = _last_main_thread_ts
main_lock_held = _main_thread_lock.locked()
# Determine main thread status
now = time.time()
if last_ts == 0.0 and pending == 0:
main_status = "idle" # No commands processed yet, none pending
elif main_lock_held and pending > 0 and (last_ts == 0.0 or (now - last_ts) > COMMAND_TIMEOUT):
main_status = "blocked" # Commands pending but main thread not responding
elif main_lock_held:
main_status = "busy" # A command is running on main thread
else:
main_status = "responsive"
self._respond(200, {
"status": "ok",
"addin": "AdomBridge",
"version": ADDIN_VERSION,
"port": PORT,
"main_thread": main_status,
"main_thread_locked": main_lock_held,
"pending_commands": pending,
"main_thread_last_seen": round(last_ts, 1) if last_ts > 0 else None,
"walk_progress": get_walk_progress(),
})
elif self.path == "/status":
# Non-blocking busy probe — does NOT acquire _main_thread_lock.
# Any number of callers (from any bridge/container) can hit this
# concurrently. Returns which command holds the lock, how long
# it's been running, and walk progress if applicable.
busy = _get_busy()
walk = get_walk_progress()
if busy:
elapsed = round(time.time() - busy["started_at"], 1)
resp = {
"busy": True,
"version": ADDIN_VERSION,
"busyCommand": busy["command"],
"elapsedSeconds": elapsed,
"requestId": busy["request_id"],
}
if walk:
resp["walkProgress"] = walk
elif busy["command"] in ("walk_cloud_tree", "search_cloud_files") and elapsed > 5:
# Walk/search was dispatched (HTTP thread set _busy_state) but
# the main thread hasn't started the handler yet (_walk_progress
# is still None). This means the custom event is queued but the
# main thread's event loop isn't processing it — typically
# because a modal dialog has its own nested event loop.
resp["mainThreadStalled"] = True
resp["stalledHint"] = (
"Command dispatched but main thread has not started processing. "
"A modal dialog is likely blocking Fusion's event loop. "
"Dismiss dialogs via fusion_dismiss_blocking_dialogs, "
"then the command will auto-resume."
)
self._respond(200, resp)
else:
self._respond(200, {"busy": False, "version": ADDIN_VERSION})
else:
self._respond(404, {"error": "Not found"})
def do_POST(self):
if self.path != "/command":
self._respond(404, {"error": "Not found"})
return
content_length = int(self.headers.get("Content-Length", 0))
body = self.rfile.read(content_length)
try:
request = json.loads(body)
except json.JSONDecodeError as e:
self._respond(400, {"success": False, "error": f"Invalid JSON: {e}"})
return
command = request.get("command", "")
args = request.get("args", {})
request_id = str(uuid.uuid4())
# Per-command timeout
timeout = PER_COMMAND_TIMEOUT.get(command, COMMAND_TIMEOUT)
# Allow caller override (useful for very large models)
caller_timeout = request.get("timeout")
if isinstance(caller_timeout, (int, float)) and caller_timeout > 0:
timeout = float(caller_timeout)
# Serialize CustomEvent fires through the main-thread lock so
# requests don't stack. If another request is already in flight
# to the main thread, we block here. We bound the wait so a
# wedged main thread doesn't pile up indefinitely.
lock_wait = timeout # give us up to `timeout` seconds to acquire
acquired = _main_thread_lock.acquire(timeout=lock_wait)
if not acquired:
busy = _get_busy()
walk = get_walk_progress()
elapsed = round(time.time() - busy["started_at"], 1) if busy else None
error_msg = (
f"Main thread busy — {busy['command']} running for {elapsed}s."
if busy else
f"Main thread busy — lock held for >{lock_wait}s."
)
resp = {
"success": False,
"error": error_msg,
"errorCode": "main_thread_busy",
"busyCommand": busy["command"] if busy else None,
"elapsedSeconds": elapsed,
"_hint": (
"Add-in is busy with a long-running command. Do NOT retry add-in commands. "
"Do NOT press Escape — the add-in is working, not stuck on a dialog. "
"Commands that still work: fusion_window_info, fusion_screenshot_fusion, "
"fusion_click_fusion, fusion_send_key, fusion_close_window."
),
}
if walk:
resp["walkProgress"] = walk
self._respond(200, resp)
return
try:
_set_busy(command, request_id)
# Track as pending before firing
with _lock:
_pending_request_ids.add(request_id)
# Fire custom event to marshal to main thread
try:
_app.fireCustomEvent(
_custom_event_id,
json.dumps({"request_id": request_id, "command": command, "args": args}),
)
except Exception as e:
with _lock:
_pending_request_ids.discard(request_id)
self._respond(500, {"success": False, "error": f"Failed to fire event: {e}"})
return
# Wait for the main thread handler to deliver the result
result = _wait_for_result(request_id, timeout=timeout)
self._respond(200, result)
finally:
_clear_busy()
_main_thread_lock.release()
def _respond(self, status: int, data: dict):
self.send_response(status)
self.send_header("Content-Type", "application/json")
self.end_headers()
self.wfile.write(json.dumps(data).encode("utf-8"))
def log_message(self, format, *args):
# Suppress default HTTP logging to avoid cluttering Fusion's console
pass
def start(custom_event_id: str, app: adsk.core.Application) -> threading.Thread:
"""Start the HTTP server on a background daemon thread."""
global _server, _server_thread, _custom_event_id, _app, _watchdog_thread
_custom_event_id = custom_event_id
_app = app
# ThreadingHTTPServer so /health and concurrent requests don't block
# behind a slow request. Main-thread work is still serialized via
# _main_thread_lock + CustomEvent marshaling.
_server = ThreadingHTTPServer(("127.0.0.1", PORT), AdomBridgeHandler)
_server.daemon_threads = True
_server_thread = threading.Thread(target=_server.serve_forever, daemon=True)
_server_thread.start()
# Start stale-lock watchdog (see _watchdog_loop docstring for rationale)
_watchdog_stop.clear()
_watchdog_thread = threading.Thread(target=_watchdog_loop, daemon=True, name="AdomBridge-Watchdog")
_watchdog_thread.start()
return _server_thread
def stop():
"""Stop the HTTP server."""
global _server
_watchdog_stop.set()
if _server:
_server.shutdown()
_server = None