Live mode: load sheets off the UI thread, go offline cleanly on a lost device

Opening a sheet ran its Sox requests on the UI thread, so a device that had
gone away (a closed simulator) froze the window until DASP's 30 s receive
timeout. The load now runs on a background thread and the sheet is painted
when it finishes; a load the user has clicked away from is dropped.

When the session closes, the editor goes offline with "Connection lost:
<cause>" instead of printing a traceback. A send waiting for room in the
send window now fails when the session closes rather than going out after
the CLOSE. The tree-expand error callback no longer reads the exception
after its except block has ended.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-19 21:24:27 +03:00
co-authored by Claude Opus 5.5
parent 6dd01b13b9
commit 95be6613f3
5 changed files with 131 additions and 43 deletions
+1 -1
View File
@@ -8,7 +8,7 @@ so later rebinding would only ever be seen by the module that did it.
import configparser
import os
VERSION = "0.0.0.182"
VERSION = "0.0.0.183"
PROPERTIES_FILE = os.path.join(os.path.dirname(os.path.abspath(__file__)), "editor.properties")
DEFAULT_GRID_SIZE = 10
DEFAULT_ADD_TIMESTAMP = True
+3 -1
View File
@@ -598,7 +598,9 @@ class SendWindow:
while self._is_alive and (self._full() or self._exceeds_ack_more_limit(p)):
self._cond.wait()
if not self._is_alive:
return
# Waiting for room when the session closed: it must not go out after
# the CLOSE, and the caller must not take it as sent.
raise DaspException(f"Session closed: {self._session.close_cause}")
if self._tail is None:
self._head = self._tail = p
else:
+4
View File
@@ -111,6 +111,10 @@ class DeviceSession:
def is_connected(self):
return self.sox_client is not None and not self.sox_client.is_closed
@property
def close_cause(self):
return self.sox_client.close_cause if self.sox_client else "disconnected"
# ── Schema → Slot Registry ──────────────────────────────────
def _load_slot_registry(self, parts):
+118 -41
View File
@@ -1109,7 +1109,6 @@ def on_tree_select(event):
selected_id = selected_items[0]
element = app_state.tree_element_map.get(selected_id)
components_to_render = []
# Remembered so undo and tidy can repaint this same sheet from the DOM.
app_state.current_sheet_parent = element
@@ -1118,42 +1117,16 @@ def on_tree_select(event):
if app_state.live_mode and app_state.device_session and element is not None:
comp_id_str = element.get("id")
if comp_id_str is not None:
ds = app_state.device_session
ds.close_wiresheet()
try:
ds.open_wiresheet(int(comp_id_str))
sax_root = ds.build_dom_tree(ds.sox_client.cache_get(0))
ds.build_links_elements(sax_root)
app_state.xml_root_element = sax_root
sax_file.refresh_treeview_from_dom(sax_root)
# re-find element by comp id after DOM rebuild (tree item IDs changed)
element = None
for tid, elem in app_state.tree_element_map.items():
if elem.get("id") == comp_id_str:
element = elem
selected_id = tid
break
if element is not None:
_restoring_selection = True
tree.selection_set(selected_id)
tree.see(selected_id)
tree.after_idle(_clear_restoring_flag)
app_state.current_sheet_parent = element
except Exception:
import traceback; traceback.print_exc()
_load_live_sheet(app_state.device_session, comp_id_str, pending_link)
return
# The DOM rebuild replaced every element object, so a pending link from the
# old tree can no longer be found by identity. Re-resolve it by from/to.
if pending_link is not None:
old_from = pending_link.get("from", "")
old_to = pending_link.get("to", "")
links_container = app_state.xml_root_element.find("links") if app_state.xml_root_element is not None else None
if links_container is not None:
for lnk in links_container:
if lnk.get("from") == old_from and lnk.get("to") == old_to:
pending_link = lnk
break
_paint_sheet(element, selected_id, pending_link)
def _paint_sheet(element, selected_id, pending_link):
"""Paints the sheet of `element` (or of the tree row `selected_id` when it has none)."""
tree = app_state.tree
components_to_render = []
if element is not None:
components_to_render = [child for child in element if child.tag == "comp"]
elif tree.exists(selected_id):
@@ -1170,6 +1143,98 @@ def on_tree_select(event):
select_link_on_open_sheet(pending_link)
# Bumped by every live sheet load, so a load the user has clicked away from is dropped
# when it finishes rather than painted over the sheet they moved on to.
_sheet_load_gen = 0
# One load talks to the device at a time: each starts by unsubscribing the sheet the
# previous one subscribed.
_sheet_load_lock = threading.Lock()
def _load_live_sheet(ds, comp_id_str, pending_link):
"""Reads a folder's sheet from the device on a background thread, then paints it.
Sox requests wait for the device. A device that has gone away holds them until DASP
gives up (30 s), which on the UI thread froze the whole window.
"""
global _sheet_load_gen
_sheet_load_gen += 1
gen = _sheet_load_gen
# Blank until the device answers: the rows on screen belong to the DOM the load
# is about to replace.
wiresheet.render_components([])
app_state.set_status("Loading sheet...")
def _bg():
with _sheet_load_lock:
if gen != _sheet_load_gen:
return # clicked away while waiting for the previous load
try:
ds.close_wiresheet()
ds.open_wiresheet(int(comp_id_str))
sax_root = ds.build_dom_tree(ds.sox_client.cache_get(0))
ds.build_links_elements(sax_root)
except Exception as exc:
app_state.window.after(
0, lambda err=exc: _live_sheet_failed(ds, gen, err))
return
app_state.window.after(
0, lambda: _live_sheet_loaded(ds, gen, comp_id_str, pending_link, sax_root))
threading.Thread(target=_bg, daemon=True).start()
def _live_sheet_failed(ds, gen, exc):
if gen != _sheet_load_gen or app_state.device_session is not ds:
return
if not ds.is_connected:
_on_connection_lost(ds, ds.close_cause)
return
app_state.set_status(f"Could not open sheet: {exc}", warn=True)
def _live_sheet_loaded(ds, gen, comp_id_str, pending_link, sax_root):
"""UI thread: swaps in the freshly read DOM and paints the sheet."""
global _restoring_selection
if gen != _sheet_load_gen or app_state.device_session is not ds:
return
tree = app_state.tree
app_state.xml_root_element = sax_root
sax_file.refresh_treeview_from_dom(sax_root)
# re-find element by comp id after DOM rebuild (tree item IDs changed)
element = None
selected_id = None
for tid, elem in app_state.tree_element_map.items():
if elem.get("id") == comp_id_str:
element = elem
selected_id = tid
break
if element is not None:
_restoring_selection = True
tree.selection_set(selected_id)
tree.see(selected_id)
tree.after_idle(_clear_restoring_flag)
app_state.current_sheet_parent = element
# The DOM rebuild replaced every element object, so a pending link from the
# old tree can no longer be found by identity. Re-resolve it by from/to.
if pending_link is not None:
old_from = pending_link.get("from", "")
old_to = pending_link.get("to", "")
links_container = sax_root.find("links")
if links_container is not None:
for lnk in links_container:
if lnk.get("from") == old_from and lnk.get("to") == old_to:
pending_link = lnk
break
app_state.clear_status()
if element is not None:
_paint_sheet(element, selected_id, pending_link)
def delete_selected_link():
"""Removes every selected link from the DOM, as one undoable step, and repaints."""
link_elems = []
@@ -3157,6 +3222,15 @@ def do_device_disconnect(device_down=False):
wiresheet.render_components([])
def _on_connection_lost(ds, cause):
"""Main thread: the device stopped answering (closed simulator, unplugged cable) or
ended the session. Goes offline as Disconnect does, without asking anything of it."""
if app_state.device_session is not ds:
return # already disconnected, or a newer connection has taken its place
do_device_disconnect(device_down=True)
app_state.set_status(f"Connection lost: {cause}", warn=True)
def quit_app():
"""File > Exit and the window's close box: let go of the device before going.
@@ -3190,11 +3264,13 @@ def _start_cov_polling():
ds = app_state.device_session
if ds is None or not ds.is_connected:
_cov_poll_id = None
if app_state.live_mode:
do_device_disconnect()
app_state.set_status("Connection lost", warn=True)
if ds is not None and app_state.live_mode:
_on_connection_lost(ds, ds.close_cause)
return
# Scheduled first, so one bad event cannot stop the polling for good.
_cov_poll_id = app_state.window.after(100, poll)
events = ds.poll_events()
if events and app_state.xml_root_element is not None:
needs_repaint = False
@@ -3210,8 +3286,6 @@ def _start_cov_polling():
except Exception:
pass
_cov_poll_id = app_state.window.after(100, poll)
_cov_poll_id = app_state.window.after(100, poll)
@@ -3272,8 +3346,11 @@ def _live_tree_expand(comp_id, tree_item_id, select=True, on_loaded=None):
app_state.window.after(0, _ui)
except Exception as exc:
if not ds.is_connected:
# The COV poll sees the closed session, goes offline and says so.
return
app_state.window.after(
0, lambda: app_state.set_status(f"Load error: {exc}", warn=True)
0, lambda msg=f"Load error: {exc}": app_state.set_status(msg, warn=True)
)
threading.Thread(target=_bg, daemon=True).start()
+5
View File
@@ -421,6 +421,11 @@ class SoxClient:
def is_closed(self):
return self._session is None or self._session.is_closed
@property
def close_cause(self):
"""Why the session ended, e.g. "receive timeout"."""
return self._session.close_cause if self._session else "closed"
# ── Read Schema ─────────────────────────────────────────────
def read_schema(self):