Engine: a macro's footprint dry runs keep to the run's timeLimit

footprint always ran its dry run with the default 60 second limit, so
macro.run with timeLimit 5 could run for over two minutes (a dry run,
then another after a shift) and fail with "ran longer than 60 seconds".

footprint now takes the run's clock and limit. make_way starts the
run's clock before the dry runs, so the checks and the run share one
limit, and macro.check takes timeLimit too.
This commit is contained in:
prxnvt
2026-10-22 02:26:32 -04:00
parent 4c6962a97c
commit bf456c9ff8
4 changed files with 33 additions and 13 deletions
+1 -1
View File
@@ -21,7 +21,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Check Macro",
[],
None,
"{name, inputs?} validates the inputs and dry-runs the macro on the workbook (then puts everything back) → {ok, errors?, needsApproval?, inputs, output?, conflicts, footprint}",
"{name, inputs?, timeLimit?: seconds} validates the inputs and dry-runs the macro on the workbook (then puts everything back) → {ok, errors?, needsApproval?, inputs, output?, conflicts, footprint}",
has_doc,
check
),
+16 -9
View File
@@ -346,13 +346,13 @@ fn holds(v: &Value, wanted: gridcraft_model::ColType) -> &'static str {
/// A stopwatch for the time limit. `Instant::now` traps on wasm, so there it measures nothing.
#[derive(Clone, Copy, Debug)]
struct Stopwatch {
pub struct Stopwatch {
#[cfg(not(target_arch = "wasm32"))]
start: std::time::Instant,
}
impl Stopwatch {
fn start() -> Stopwatch {
pub fn start() -> Stopwatch {
#[cfg(not(target_arch = "wasm32"))]
#[allow(clippy::disallowed_methods)] // never on wasm (see above)
let w = Stopwatch { start: std::time::Instant::now() };
@@ -907,8 +907,8 @@ impl Saved {
}
/// Runs the macro in dry-run mode on its workbook, puts everything back, and says what it would
/// change.
pub fn footprint(s: &mut Session, p: &Prepared) -> Result<Footprint> {
/// change. The dry run stops once `time_limit_ms` has passed on `clock`.
pub fn footprint(s: &mut Session, p: &Prepared, clock: Stopwatch, time_limit_ms: f64) -> Result<Footprint> {
let i = s.docs.iter().position(|d| d.uid == p.doc).ok_or(EngineError::NoDocument)?;
s.set_active(i);
let saved = Saved::take(s, p.doc);
@@ -918,6 +918,7 @@ pub fn footprint(s: &mut Session, p: &Prepared) -> Result<Footprint> {
d.touched = Some(Vec::new());
}
let mut m = Machine::new(p, true);
(m.clock, m.time_limit_ms) = (clock, time_limit_ms);
let outcome = m.run_to_end(s);
let (after, touched) = match s.docs.iter_mut().find(|d| d.uid == p.doc) {
Some(d) => (d.wb.clone(), d.touched.take().unwrap_or_default()),
@@ -1107,14 +1108,15 @@ pub fn begin(s: &mut Session, name: &str, p: &Json) -> Result<Begun> {
approve(s, &m, doc, &asks, p.get("approve").and_then(Json::as_bool).unwrap_or(false))?;
let prepared = prepare(s, &m, p.get("inputs"))?;
let rule = conflict_rule(p, &m)?;
let time_limit_ms = p.get("timeLimit").and_then(Json::as_f64).map(|t| (t * 1000.0).clamp(1.0, 600_000.0)).unwrap_or(DEFAULT_TIME_LIMIT_MS);
let time_limit_ms = time_limit(p);
let inputs = prepared.inputs_json(&s.doc()?.wb);
Ok(Begun { mac: m, prepared, rule, time_limit_ms, inputs })
}
/// The footprint check and the output rule, inside the run's undo group: an error when the macro
/// would fail or write over data it may not, after shifting the data if the rule says so. Returns
/// the machine for the run and whether it shifted.
/// the machine for the run and whether it shifted. The dry runs count towards the run's time
/// limit.
///
/// A partial footprint proves nothing about the steps it didn't reach, so then the machine
/// checks each step as it runs and stops the run at one that writes over data.
@@ -1125,7 +1127,7 @@ pub fn make_way(s: &mut Session, b: &Begun) -> Result<(Machine, bool)> {
if rule == OnConflict::Overwrite {
return Ok((machine, false));
}
let mut fp = footprint(s, &b.prepared)?;
let mut fp = footprint(s, &b.prepared, machine.clock, b.time_limit_ms)?;
if let Some(e) = fp.error.take() {
return Err(e.into_engine(&m.name));
}
@@ -1144,7 +1146,7 @@ pub fn make_way(s: &mut Session, b: &Begun) -> Result<(Machine, bool)> {
}
OnConflict::ShiftDown | OnConflict::ShiftRight => {
make_room(s, &fp, rule == OnConflict::ShiftDown)?;
fp = footprint(s, &b.prepared)?;
fp = footprint(s, &b.prepared, machine.clock, b.time_limit_ms)?;
if let Some(e) = fp.error.take() {
return Err(e.into_engine(&m.name));
}
@@ -1191,6 +1193,11 @@ pub fn run(s: &mut Session, name: &str, p: &Json) -> Result<Json> {
})
}
/// The run's `timeLimit` (seconds) in milliseconds, or the default.
fn time_limit(p: &Json) -> f64 {
p.get("timeLimit").and_then(Json::as_f64).map(|t| (t * 1000.0).clamp(1.0, 600_000.0)).unwrap_or(DEFAULT_TIME_LIMIT_MS)
}
/// The conflict rule for this run: `onConflict`, or `overwrite: true` (the answer to the
/// question), else the macro's own.
fn conflict_rule(p: &Json, m: &Macro) -> Result<OnConflict> {
@@ -1240,7 +1247,7 @@ pub fn check(s: &mut Session, name: &str, p: &Json) -> Result<Json> {
if !asks.is_empty() && !approved {
return Ok(out);
}
let fp = footprint(s, &prepared)?;
let fp = footprint(s, &prepared, Stopwatch::start(), time_limit(p))?;
out["ok"] = json!(fp.error.is_none());
out["conflicts"] = json!(fp.conflict_count());
if let Some(e) = &fp.error {
+11
View File
@@ -356,6 +356,17 @@ fn macros_are_bounded() {
assert!(err.contains("ran more than 100000 steps"), "{err}");
let err = s.execute("macro.run", json!({"name": "Spin", "timeLimit": 0.001})).unwrap_err().to_string();
assert!(err.contains("ran longer than"), "{err}");
// The footprint's dry run keeps to the time limit too, and so does the check's.
put(
&mut s,
json!({"name": "Spin", "output": {"onConflict": "fail"}, "steps": [{"type": "repeat", "over": "A1:A400", "body": [
{"type": "repeat", "over": "A1:A400", "body": [{"type": "if", "cell": "A1", "op": "empty"}]}
]}]}),
);
let err = s.execute("macro.run", json!({"name": "Spin", "timeLimit": 0.001})).unwrap_err().to_string();
assert!(err.contains("ran longer than"), "{err}");
let c = s.execute("macro.check", json!({"name": "Spin", "timeLimit": 0.001})).unwrap();
assert!(c["errors"][0].as_str().unwrap().contains("ran longer than"), "{c}");
}
#[test]
+5 -3
View File
@@ -42,7 +42,9 @@ no macro debugger, recorder or management commands in this version, and no macro
`nonEmpty` and `defaultRef`. A run binds each input to the range it is given, its default, or
(for the first input) the selection, and refuses with messages that name the cells:
"Input column 2 (Amount) must hold numbers; C4 holds text."
- **Limits**: 1,000 macros a workbook, 10,000 steps a macro, and Repeat and If nested 4 deep.
- **Limits**: 1,000 macros a workbook, 10,000 steps a macro, Repeat and If nested 4 deep,
100,000 steps executed per run, and 60 seconds a run, footprint checks included (`timeLimit`
to change; the web build has no clock, so there only the step limit applies).
## Tokens
@@ -94,8 +96,8 @@ called Output, even when a token or input has that name.
step: `Step 3.1 (cell.set): …` (paths are 1-based; `2.1` is inside a Repeat, `4.then.1` inside
an If).
`macro.check {name, inputs?}` reports the bound inputs, the output anchor, any approval needed and
the footprint, without changing anything.
`macro.check {name, inputs?, timeLimit?}` reports the bound inputs, the output anchor, any
approval needed and the footprint, without changing anything.
## Storage