Connect: read the whole app from app.sab instead of component by component

Saves the app on the device, reads app.sab and fills the Sox cache with the tree,
config and links in one go: 8 s instead of 116 s for 230 components on a P-ION,
0.9 s instead of 16 s for 194 on an Orion. Sox keeps the cache current from
there as before. On any failure, or when app.sab does not match the running
tree, it falls back to lazy loading. A sheet's first open reads only runtime
values; a tree expand with nothing to load no longer rebuilds the DOM.

bench_sab_seed.py times both ways against a device.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-11 14:01:32 +03:00
co-authored by Claude Opus 5.5
parent db7b6632af
commit cb0196caa7
4 changed files with 277 additions and 8 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 configparser
import os import os
VERSION = "0.0.0.169" VERSION = "0.0.0.170"
PROPERTIES_FILE = os.path.join(os.path.dirname(os.path.abspath(__file__)), "editor.properties") PROPERTIES_FILE = os.path.join(os.path.dirname(os.path.abspath(__file__)), "editor.properties")
DEFAULT_GRID_SIZE = 10 DEFAULT_GRID_SIZE = 10
DEFAULT_ADD_TIMESTAMP = True DEFAULT_ADD_TIMESTAMP = True
+162
View File
@@ -0,0 +1,162 @@
"""Benchmark: lazy Sox loading vs seeding the cache from app.sab.
python bench_sab_seed.py HOST [--port 1876] [--user admin] [--password PW]
Each method runs on a fresh connection, so it starts from an empty cache.
lazy-seq every component's tree, one request at a time (as used_kit_names does)
lazy-tree the tree folder by folder, each folder's children in one window
lazy-full lazy-tree plus config and links of every component: what app.sab holds
lazy-full-seq lazy-seq plus config and links, one request at a time
sab App.save, get app.sab, decode it
"""
import argparse
import time
import app_state # noqa: F401 loads Sedona home
import schema
from device_session import DeviceSession
from sox import CONFIG
import app_backup
import sab_app
class Counter:
"""Counts Sox requests sent through the client."""
def __init__(self, client):
self.n = 0
orig_requests = client.requests
def requests(reqs, *a, **k):
self.n += len(reqs)
return orig_requests(reqs, *a, **k)
client.requests = requests
def connect(args):
ds = DeviceSession()
# DeviceSession.connect also loads the root's children and their values; time
# only the bare session here, so every method starts from the same place.
from sox import SoxClient
ds.sox_client = SoxClient(args.host, args.port, args.user, args.password)
ds.sox_client.checksum_resolver = schema.resolve_truncated_checksum
ds.sox_client.connect()
parts = ds.sox_client.read_schema()
ds.sox_client.read_version()
ds._load_slot_registry(parts)
return ds
def lazy_seq(ds):
client = ds.sox_client
pending, n = [0], 0
while pending:
comp = client.load_ids([pending.pop()], checked=False)[0]
if comp is not None:
n += 1
pending.extend(comp.children)
return n
def lazy_tree(ds):
client = ds.sox_client
client.load(0)
pending, comps = [0], []
while pending:
cid = pending.pop()
comps.append(client.cache_get(cid))
for child in ds.load_children(cid):
if child.children:
pending.append(child.id)
else:
comps.append(child)
return [c for c in comps if c is not None]
def lazy_full(ds):
client = ds.sox_client
comps = lazy_tree(ds)
# In folder-sized windows, as open_wiresheet sends them.
for i in range(0, len(comps), 16):
client.update(comps[i:i + 16], CONFIG)
for c in comps:
c.links = client.read_links(c.id)
return len(comps)
def lazy_full_seq(ds):
"""lazy-seq plus config and links, every request alone: a P-ION's pace."""
client = ds.sox_client
pending, n = [0], 0
while pending:
comp = client.load_ids([pending.pop()], checked=False)[0]
if comp is None:
continue
n += 1
pending.extend(comp.children)
client.update([comp], CONFIG)
comp.links = client.read_links(comp.id)
return n
def sab(ds, timing):
client = ds.sox_client
t = time.perf_counter()
app_backup.save_app(client)
timing["save"] = time.perf_counter() - t
t = time.perf_counter()
data = client.get_file(app_backup.APP_SAB)
timing["get"] = time.perf_counter() - t
timing["bytes"] = len(data)
t = time.perf_counter()
app = sab_app.SabApp(data)
timing["decode"] = time.perf_counter() - t
timing["links"] = len(app.links)
return len(app.comps)
def run(name, args, fn):
try:
ds = connect(args)
except Exception as e:
print(f"{name:10s} connect failed: {e}")
return
counter = Counter(ds.sox_client)
timing = {}
t = time.perf_counter()
try:
result = fn(ds, timing) if name == "sab" else fn(ds)
elapsed = time.perf_counter() - t
n = result if isinstance(result, int) else len(result)
extra = " ".join(f"{k}={v:.2f}s" if isinstance(v, float) else f"{k}={v}"
for k, v in timing.items())
print(f"{name:10s} {elapsed:7.2f}s comps={n:4d} requests={counter.n:5d} {extra}")
except Exception as e:
print(f"{name:10s} FAILED after {time.perf_counter() - t:.2f}s "
f"({counter.n} requests): {e}")
finally:
ds.disconnect()
time.sleep(1.0)
def main():
p = argparse.ArgumentParser()
p.add_argument("host")
p.add_argument("--port", type=int, default=1876)
p.add_argument("--user", default="admin")
p.add_argument("--password", default="")
p.add_argument("--only", nargs="*",
default=["lazy-seq", "lazy-tree", "lazy-full", "sab"])
args = p.parse_args()
fns = {"lazy-seq": lazy_seq, "lazy-tree": lazy_tree,
"lazy-full": lazy_full, "lazy-full-seq": lazy_full_seq, "sab": sab}
print(f"== {args.host}:{args.port}")
for name in args.only:
run(name, args, fns[name])
if __name__ == "__main__":
main()
+104 -6
View File
@@ -31,6 +31,18 @@ def _slot_precision(type_name, slot_name):
return 2 return 2
def _as_sox_value(slot, val):
"""An app.sab config value as a Sox config read leaves it in the cache."""
if "s" in slot.flags:
return val
if slot.type in ("sys::Buf", "buf"):
# Sox reads every Buf as a Str: its bytes but the last, as text.
return val[:-1].decode("utf-8", errors="replace") if val else ""
if slot.type == "bool":
return val is not False # Sox reads a bool as u1 != 0, so null is true
return val
class DeviceSession: class DeviceSession:
"""High-level session manager for a live Sedona device connection.""" """High-level session manager for a live Sedona device connection."""
@@ -38,6 +50,10 @@ class DeviceSession:
self.sox_client = None self.sox_client = None
self._event_queue = queue.Queue() self._event_queue = queue.Queue()
self._current_subs = set() # comp_ids we subscribed for the open wiresheet self._current_subs = set() # comp_ids we subscribed for the open wiresheet
# Components whose config and links came from app.sab and have not been read
# since: their first sheet open skips reading those again.
self._seeded = set()
self.seed_info = "" # how the cache was filled, for the status bar
# ── Connect / Disconnect ──────────────────────────────────── # ── Connect / Disconnect ────────────────────────────────────
@@ -53,11 +69,18 @@ class DeviceSession:
self._load_slot_registry(parts) self._load_slot_registry(parts)
root_comp = self.sox_client.load(0) self.sox_client.load(0)
try:
count = self.seed_from_sab()
self.seed_info = f"app read from app.sab: {count} components"
except Exception as e:
self.seed_info = f"app.sab not used: {e}"
root_comp = self.sox_client.cache_get(0)
if root_comp and root_comp.children: if root_comp and root_comp.children:
children = self.load_children(root_comp.id) children = self.load_children(root_comp.id)
if children: if children:
self.sox_client.update(children, CONFIG | RUNTIME) self.sox_client.update(children, self._values_to_read(children))
return root_comp return root_comp
def disconnect(self, unsubscribe=True): def disconnect(self, unsubscribe=True):
@@ -161,6 +184,74 @@ class DeviceSession:
if c is not None and c.kit_id == kit_id and c.type_id == type_id_val: if c is not None and c.kit_id == kit_id and c.type_id == type_id_val:
c.type_name = full_name c.type_name = full_name
# ── Seeding from app.sab ────────────────────────────────────
def seed_from_sab(self):
"""Fills the cache with the whole app — tree, config and links — from app.sab.
Reading component by component costs a round trip each, and a device answers one
request per pass of its loop: 230 components took 116 s on a P-ION, where saving
the app and reading app.sab took 8 s. The app is saved first, so app.sab holds
what is running. From here on Sox keeps the cache current, as it does for a
lazily loaded one; runtime values are still read when a sheet opens.
Nothing goes into the cache unless the whole file decodes and its tree matches the
device's, so on any failure the caller falls back to lazy loading. Returns the
number of components.
"""
import app_backup
import sab_app
from sox import Link
client = self.sox_client
root = client.cache_get(0) or client.load(0)
app_backup.save_app(client)
app = sab_app.SabApp(client.get_file(app_backup.APP_SAB))
# app.sab numbers kits by the app's schema and Sox by kits.scode's; the two can
# differ, so types are matched by name, and a kit only when its checksum agrees.
device_kits = {p.name: f"{p.checksum & 0xFFFFFFFF:08x}" for p in client.schema_parts}
for name, checksum in app.kit_parts:
if device_kits.get(name) != checksum:
raise ValueError(f"kit {name} in app.sab is not the device's")
type_key = {qname: key for key, qname in client.type_name_map.items()}
if [k["id"] for k in app.app["kids"]] != root.children:
raise ValueError("app.sab does not match the running app")
comps = {}
for c in app.comps.values():
key = type_key.get(c["type"])
if key is None:
raise ValueError(f"no manifest for {c['type']}")
comp = SoxComponent(c["id"], key[0], key[1], c["type"])
comp.name = c["name"]
comp.parent = c["parentId"]
comp.children = [k["id"] for k in c["kids"]]
comp.permissions = root.permissions
for slot, val in c["props"]:
comp.values[slot.name] = _as_sox_value(slot, val)
comps[comp.id] = comp
comps[0].subscription = root.subscription
# Sox reads a component's links both ways, into it and out of it.
for from_c, from_slot, to_c, to_slot in app.links:
link = Link(from_c["id"], from_slot, to_c["id"], to_slot)
comps[from_c["id"]].links.append(link)
if to_c["id"] != from_c["id"]:
comps[to_c["id"]].links.append(link)
for comp in comps.values():
client.cache_add(comp)
self._seeded = set(comps)
return len(comps)
def _values_to_read(self, comps):
"""What to read for `comps` on a sheet open: config too, unless app.sab gave it."""
if all(c.id in self._seeded for c in comps):
return RUNTIME
return CONFIG | RUNTIME
# ── Lazy Loading ──────────────────────────────────────────── # ── Lazy Loading ────────────────────────────────────────────
def load_children(self, comp_id): def load_children(self, comp_id):
@@ -253,9 +344,9 @@ class DeviceSession:
all_link_remote_ids = set() all_link_remote_ids = set()
for child in children: for child in children:
links = self.sox_client.read_links(child.id) if child.id not in self._seeded:
child.links = links child.links = self.sox_client.read_links(child.id)
for lnk in links: for lnk in child.links:
all_link_remote_ids.add(lnk.from_comp_id) all_link_remote_ids.add(lnk.from_comp_id)
all_link_remote_ids.add(lnk.to_comp_id) all_link_remote_ids.add(lnk.to_comp_id)
@@ -267,7 +358,14 @@ class DeviceSession:
if remote_ids: if remote_ids:
self.sox_client.load_ids(remote_ids, checked=False) self.sox_client.load_ids(remote_ids, checked=False)
self.sox_client.update(children, CONFIG | RUNTIME) seeded = [c for c in children if c.id in self._seeded]
others = [c for c in children if c.id not in self._seeded]
if seeded:
self.sox_client.update(seeded, RUNTIME)
if others:
self.sox_client.update(others, CONFIG | RUNTIME)
# Subscribed from here on, so Sox keeps them current; a later open reads again.
self._seeded -= child_ids
folder_comp = self.sox_client.cache_get(folder_comp_id) folder_comp = self.sox_client.cache_get(folder_comp_id)
to_subscribe = [folder_comp] + children if folder_comp else children to_subscribe = [folder_comp] + children if folder_comp else children
+10 -1
View File
@@ -2953,7 +2953,8 @@ def _on_connected(ds, root_comp):
menu_device.entryconfig("Download to SAX...", state="normal") menu_device.entryconfig("Download to SAX...", state="normal")
menu_app_info.entryconfig("Get with Files...", state="normal") menu_app_info.entryconfig("Get with Files...", state="normal")
menu_app_info.entryconfig("Put with Files...", state="normal") menu_app_info.entryconfig("Put with Files...", state="normal")
app_state.set_status(f"Connected to {app_state.device_host}:{app_state.device_port}") app_state.set_status(f"Connected to {app_state.device_host}:{app_state.device_port}"
f" ({ds.seed_info})")
sax_root = ds.build_dom_tree(root_comp) sax_root = ds.build_dom_tree(root_comp)
app_state.xml_root_element = sax_root app_state.xml_root_element = sax_root
@@ -3097,6 +3098,14 @@ def _live_tree_expand(comp_id, tree_item_id, select=True, on_loaded=None):
if ds is None or not ds.is_connected: if ds is None or not ds.is_connected:
return return
# Every child already in the tree (app.sab gave the whole app, or it was opened
# before): nothing to load, and rebuilding the DOM would only cost the undo steps.
elem = app_state.tree_element_map.get(tree_item_id)
if elem is not None and all(c.get("name") != "..." for c in elem.findall("comp")):
if on_loaded is not None:
on_loaded(tree_item_id)
return
app_state.set_status("Loading children...") app_state.set_status("Loading children...")
def _bg(): def _bg():