← Commit history

0.2.28 source: one thread per request, named agent_busy; battery aborts on a blocked bridge

John Lauer ·c4d9eb4f20 ·1mo ago ·parent 0b78e6d
7 files changed +37−7
bridge-rs/Cargo.lock+1−1
@@ -10,7 +10,7 @@ checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"  [[package]] name = "altium-bridge"-version = "0.2.27"+version = "0.2.28" dependencies = [  "getrandom",  "serde",
bridge-rs/Cargo.toml+1−1
@@ -1,6 +1,6 @@ [package] name = "altium-bridge"-version = "0.2.27"+version = "0.2.28" edition = "2021" description = "Adom altium-bridge — the bridge process, in Rust. Calls ab's verbs for the desktop; speaks file IPC to the resident DelphiScript agent inside Altium." license = "MIT"
bridge-rs/src/agent.rs+9−1
@@ -154,6 +154,9 @@ pub fn call(command: &str, params: &[(&str, String)], payload: Option<&str>, tim /// diagnostics) have no selection, so `workspace_dir()` points at the legacy root and goes blind /// to a per-instance loop (adom/altium-bridge#8); they resolve a live workspace via /// `live_workspaces()` and call here directly.+/// The agent command currently posted (the agent serves one at a time): named in agent_busy.+static IN_FLIGHT: std::sync::Mutex<Option<(String, Instant)>> = std::sync::Mutex::new(None);+ pub fn call_in(ws: &std::path::Path, command: &str, params: &[(&str, String)], payload: Option<&str>, timeout: Duration) -> Value {     let ws = ws.to_path_buf();     let id = rid();@@ -171,8 +174,13 @@ pub fn call_in(ws: &std::path::Path, command: &str, params: &[(&str, String)], p     }     let t_inbox = t0.elapsed().as_millis() as u64;     if inbox.exists() {-        return json!({"success": false, "errorCode": "agent_busy", "error": "previous request still in the inbox"});+        let holder = IN_FLIGHT.lock().ok().and_then(|g| g.clone());+        let (what, secs) = holder.map(|(c, at)| (c, at.elapsed().as_secs())).unwrap_or_else(|| ("(unknown)".into(), 0));+        return json!({"success": false, "errorCode": "agent_busy", "busyWith": what, "busyForSec": secs,+            "error": format!("the Altium agent is still running '{what}' ({secs} s); '{command}' was not started"),+            "_hint": "The agent runs ONE command at a time inside Altium. Wait and retry, or if busyForSec is far beyond what that command should take: altium_check_dialogs (a box may be blocking it), then altium_agent_recover."});     }+    if let Ok(mut g) = IN_FLIGHT.lock() { *g = Some((command.to_string(), Instant::now())); }     if let Some(p) = payload {         let tmp = ws.join("request.payload.tmp");         if std::fs::write(&tmp, p).is_ok() {
bridge-rs/src/main.rs+12−1
@@ -25,7 +25,7 @@ mod win32; use std::time::Instant; use tiny_http::{Header, Method, Response, Server}; -pub const BRIDGE_VERSION: &str = "0.2.27";+pub const BRIDGE_VERSION: &str = "0.2.28";  fn json_response(status: u16, body: &serde_json::Value) -> Response<std::io::Cursor<Vec<u8>>> {     let data = serde_json::to_vec(body).unwrap_or_else(|_| b"{}".to_vec());@@ -52,7 +52,14 @@ fn main() {     dialogs::start_reaper();     taskbar::start_idle_watcher();     lifecycle::start_close_watchdog(); // adom/altium-bridge#4: exit the agent the instant a user-initiated close begins+    // ONE THREAD PER REQUEST. Until 0.2.28 requests ran inline in this loop, so a single slow verb+    // (a 25 s UIA search in signin, an agent call waiting on a parked script engine) blocked EVERY+    // request behind it, /health included; ab dropped its connection at its own budget while the+    // bridge kept grinding the abandoned request, and the queue only grew (the 30-verb cascades the+    // verb battery found, 2026-09-20). Agent-bound work still serialises where it must: the agent's+    // one-file inbox, which now answers a NAMED agent_busy instead of a dropped connection.     for mut req in server.incoming_requests() {+        let spawned = std::thread::Builder::new().name("altium-request".into()).spawn(move || {         let t0 = Instant::now();         let path = req.url().to_string();         let method = req.method().clone();@@ -85,5 +92,9 @@ fn main() {             _ => json_response(404, &serde_json::json!({"error": "Not found"})),         };         let _ = req.respond(resp);+        });+        if let Err(e) = spawned {+            eprintln!("[altium] could not spawn a request thread: {e}");+        }     } }
bridge.json+1−1
@@ -2,7 +2,7 @@   "manifest_version": 1,   "name": "altium",   "displayName": "Altium Designer",-  "version": "0.2.27",+  "version": "0.2.28",   "description": "Drive Altium Designer from Adom: a resident DelphiScript agent (eval + the 1,100-command process launcher), the X2 -R command line, UIA over the VCL shell, dialogs by PID, screenshots of every Altium window, and Altium 365.",   "homepage": "https://wiki.adom.inc/adom/altium-bridge",   "docs": "https://wiki.adom.inc/adom/altium-bridge",
dev-skills/altium-bridge-test/run_verb_tests.py+12−1
@@ -262,6 +262,7 @@ def main():     only = [x for x in a.only.split(",") if x]      rows = []+    transport_run, first_blocked_after, aborted = 0, "", False     order = sorted(M.items(), key=lambda kv: (kv[1].get("phase", 9), list(M).index(kv[0])))     for verb, spec in order:         if verb not in verbs or (only and verb not in only):@@ -292,10 +293,20 @@ def main():                 outcome, detail = "FAIL", f"left dialog(s) on screen: {left} | was: {detail}"                 ab(T, TH, "altium_agent_recover", {}, timeout=90)         rows.append(dict(verb=verb, outcome=outcome, detail=detail, sec=d.get("_sec"), note=spec.get("note", "")))+        # Three transport failures in a row = the bridge is blocked, not three bugs. Stop: firing+        # the rest of the matrix into a blocked bridge tests nothing and hammers the user's Altium.+        transport_run = transport_run + 1 if detail.startswith("transport/timeout") else 0+        if transport_run == 1:+            first_blocked_after = rows[-2]["verb"] if len(rows) > 1 else "(start)"+        if transport_run >= 3:+            print(f"\nABORT: 3 transport failures in a row. The bridge stopped answering right after '{first_blocked_after}'. "+                  f"That verb (or the one that failed first, '{rows[-3]['verb']}') is the defect; the rest of the run is void.", flush=True)+            aborted = True+            break         print(f"{outcome:5s} {verb:28s} {str(d.get('_sec')):>6}s  {detail[:130]}", flush=True)      n = {k: sum(1 for r in rows if r["outcome"] == k) for k in ("PASS", "FAIL", "SKIP")}-    red = n["FAIL"] or uncovered+    red = n["FAIL"] or uncovered or aborted     print("\n==== altium-bridge verb battery on", T, "| bridge", live.get("bridgeVersion"), "====")     print(f"verbs in catalog {len(verbs)} | PASS {n['PASS']} | FAIL {n['FAIL']} | SKIP {n['SKIP']} (safety) | uncovered {len(uncovered)} | stale {len(stale)}")     if uncovered: print("UNCOVERED (add to MATRIX):", uncovered)
page.json+1−1
@@ -140,4 +140,4 @@     "skills/altium-wiki-import/SKILL.md",     "uninstall.sh"   ]-}\ No newline at end of file+}