Per-command timings for script runs and batches (#1868)

Thanks @DuBearte.
This commit is contained in:
DuBearte authored and GitHub committed 2026-10-17 07:26:20 -04:00
1 parent d1b96100d3
commit b04e0801d6
17 files changed
+526 -37

No files matched your search

+8
View File
@@ -21,6 +21,11 @@ pub trait Backend {
fn design_path(&self) -> Option<String> {
None
}
/// Commands run in this process, on this thread (the kernel's time in them can be measured
/// here, #1833).
fn in_process(&self) -> bool {
false
}
}
/// Longest reply line accepted from the app.
@@ -308,4 +313,7 @@ impl Backend for Headless {
fn design_path(&self) -> Option<String> {
self.session.path.clone()
}
fn in_process(&self) -> bool {
true
}
}
+41
View File
@@ -1479,3 +1479,44 @@ fn measure_items_gives_diameters_and_centre_distances() {
assert!((two["min"].as_f64().unwrap() - 45.0).abs() < 1e-9 && (two["max"].as_f64().unwrap() - 55.0).abs() < 1e-9, "{two}");
assert!(two["distance_mm"].is_number() && two["from"].is_array(), "{two}");
}
/// #1833: `timings` / `profile` time every step run, the failing one included, headless with the
/// kernel's share; bridged, the app is asked to time them and its numbers are passed on.
#[test]
fn batch_reports_timings_and_profile() {
let mut s = server();
let steps = json!([
{"command": "solid.box", "params": {"length": 10, "width": 10, "height": 10}},
{"command": "solid.box", "params": {"length": 4, "width": 4, "height": 30, "operation": "join"}},
]);
let r = raw_tool(&mut s, "batch", json!({"commands": steps, "profile": true}));
let body: Value = serde_json::from_str(r["result"]["content"][0]["text"].as_str().unwrap()).unwrap();
let rows = body["timings"].as_array().unwrap();
assert_eq!(rows.len(), 2, "{body}");
assert_eq!((rows[1]["index"].as_u64(), rows[1]["command"].as_str()), (Some(1), Some("solid.box")));
assert!(rows[1]["profile"]["boolean_ms"].as_f64().unwrap() > 0.0, "{body}");
assert!(body["total_ms"].as_f64().is_some(), "{body}");
let r = raw_tool(
&mut s,
"batch",
json!({"timings": true, "commands": [{"command": "solid.box", "params": {"length": 1, "width": 1, "height": 1}}, {"command": "NoSuchCommand"}]}),
);
let body: Value = serde_json::from_str(r["result"]["content"][0]["text"].as_str().unwrap()).unwrap();
assert_eq!(body["timings"].as_array().map(Vec::len), Some(2), "the failing step is timed too: {body}");
assert!(body["timings"][0].get("profile").is_none());
// Not asked: no timings.
let d = tool(&mut s, "batch", json!({"commands": [{"command": "solid.box", "params": {"length": 1, "width": 1, "height": 1}}]}));
assert!(d.get("timings").is_none(), "{d}");
let reply =
json!({"completed": 1, "results": [{"x": 1}], "undone": 0, "timings": [{"index": 0, "command": "a.one", "ms": 2.5}], "total_ms": 2.6});
let (b, body, _) = recorded_batch(Ok(reply), json!({"commands": [{"command": "a.one"}], "profile": true}));
assert_eq!(b.calls[0].1["timings"], true);
assert_eq!(b.calls[0].1["profile"], true);
assert_eq!((body["timings"][0]["ms"].as_f64(), body["total_ms"].as_f64()), (Some(2.5), Some(2.6)), "{body}");
// An app without engine.batch is timed step by step here, without a kernel split it can't measure.
let (_, body, _) = recorded_batch(Err("unknown method `engine.batch`".into()), json!({"commands": [{"command": "a.one"}], "profile": true}));
assert_eq!(body["timings"][0]["command"], "a.one", "{body}");
assert!(body["timings"][0].get("profile").is_none(), "{body}");
}
+52 -19
View File
@@ -5,6 +5,7 @@ use std::sync::atomic::{AtomicU64, Ordering};
use serde_json::{Map, Value, json};
use solidcraft_engine::io::vfs;
use solidcraft_engine::timing;
use crate::backend::Backend;
@@ -109,6 +110,8 @@ pub fn tool_definitions() -> Vec<Value> {
"items": {"type": "object", "properties": {"command": {"type": "string"}, "params": {"type": "object"}}, "required": ["command"]},
},
"rollback": {"type": "boolean", "description": "Undo completed steps when one fails (default true)"},
"timings": {"type": "boolean", "description": "Add `timings`: [{index, command, ms}] for every step run (the failing one included) and `total_ms` (default false)"},
"profile": {"type": "boolean", "description": "Like timings, and each step's `profile`: {boolean_ms, tessellate_ms, other_ms}, its time in kernel booleans, in tessellation and the rest (default false)"},
"verbose": {"type": "boolean", "description": "false: a compact reply. Feature steps leave out the full body list (created_bodies, changed_bodies and removed_bodies stay), sketch steps the ids they repeat outside `created` (default true)"},
}),
&["commands"],
@@ -496,10 +499,16 @@ fn batch(b: &mut dyn Backend, a: &Map<String, Value>) -> Value {
}
let rollback = a.get("rollback").and_then(Value::as_bool).unwrap_or(true);
let verbose = a.get("verbose").and_then(Value::as_bool).unwrap_or(true);
let profile = a.get("profile").and_then(Value::as_bool).unwrap_or(false);
let timed = profile || a.get("timings").and_then(Value::as_bool).unwrap_or(false);
// An app that runs batches itself answers the whole batch in one request, so one frame
// (#743). One too long for a control line goes step by step: the app would refuse it unrun.
// The margin covers the envelope and the `allow_paths` the backend adds.
let params = json!({"commands": steps, "rollback": rollback});
let mut params = json!({"commands": steps, "rollback": rollback});
if timed && let Some(o) = params.as_object_mut() {
o.insert("timings".into(), Value::Bool(true));
o.insert("profile".into(), Value::Bool(profile));
}
if params.to_string().len() + 64 * 1024 <= solidcraft_engine::MAX_LINE {
match b.call("engine.batch", params) {
Ok(r) => return batch_reply(&r, rollback, verbose),
@@ -516,42 +525,57 @@ fn batch(b: &mut dyn Backend, a: &Map<String, Value>) -> Value {
}
let start = if rollback { Undo::begin(b) } else { Undo::None };
let mut results = Vec::with_capacity(steps.len());
let mut rows = Vec::new();
let clock = timing::Clock::start();
// The kernel's share can only be measured where the commands run: in this process.
let profile = profile && b.in_process();
for (i, step) in steps.iter().enumerate() {
let command = step.get("command").or_else(|| step.get("id")).and_then(Value::as_str);
let r = match command {
let (r, took) = timing::time(profile, || match command {
Some(id) => exec(b, id, step.get("params").cloned().filter(|p| !p.is_null()).unwrap_or(json!({}))),
None => Err("missing `command`".to_string()),
};
});
if timed {
rows.push(took.to_json(i, command.unwrap_or_default()));
}
match r {
Ok(v) => results.push(if verbose { v } else { compact(v) }),
Err(e) => {
let undone = start.rollback(b, i);
return text_result(
&json!({
"ok": false,
"completed": i,
"failed": {"index": i, "command": command, "error": e},
"rolled_back": rollback,
"undone": undone,
"results": results,
}),
true,
);
let reply = json!({
"ok": false,
"completed": i,
"failed": {"index": i, "command": command, "error": e},
"rolled_back": rollback,
"undone": undone,
"results": results,
});
return text_result(&with_timings(reply, timed, rows, clock.ms()), true);
}
}
}
start.finish(b);
text_result(&json!({"ok": true, "completed": results.len(), "results": results}), false)
let reply = json!({"ok": true, "completed": results.len(), "results": results});
text_result(&with_timings(reply, timed, rows, clock.ms()), false)
}
/// `reply` with `timings` and `total_ms` when they were asked for.
fn with_timings(mut reply: Value, timed: bool, rows: Vec<Value>, total_ms: f64) -> Value {
if timed && let Some(o) = reply.as_object_mut() {
o.insert("timings".into(), Value::Array(rows));
o.insert("total_ms".into(), json!(timing::round_ms(total_ms)));
}
reply
}
/// The `batch` reply from the app's `engine.batch` answer, shaped as the per-step path shapes it.
fn batch_reply(r: &Value, rollback: bool, verbose: bool) -> Value {
let results: Vec<Value> =
r["results"].as_array().cloned().unwrap_or_default().into_iter().map(|v| if verbose { v } else { compact(v) }).collect();
match r.get("failed").filter(|f| !f.is_null()) {
None => text_result(&json!({"ok": true, "completed": results.len(), "results": results}), false),
Some(failed) => text_result(
&json!({
let (mut reply, error) = match r.get("failed").filter(|f| !f.is_null()) {
None => (json!({"ok": true, "completed": results.len(), "results": results}), false),
Some(failed) => (
json!({
"ok": false,
"completed": r["completed"],
"failed": failed,
@@ -561,7 +585,16 @@ fn batch_reply(r: &Value, rollback: bool, verbose: bool) -> Value {
}),
true,
),
};
// The app timed the steps where they ran.
if let Some(o) = reply.as_object_mut() {
for k in ["timings", "total_ms"] {
if let Some(v) = r.get(k).filter(|v| !v.is_null()) {
o.insert(k.into(), v.clone());
}
}
}
text_result(&reply, error)
}
/// A command's result without what the design already had (`execute` or `batch` with `verbose: false`): a