Problem
conveyor_compile::run_phase_a (src/simlin-engine/src/conveyor_compile.rs, around line 3041) iterates the conveyor plan list and, for belt i, calls check_slat_bound(...)? before states[i].phase_a(...):
for (i, plan) in plans.iter().enumerate() {
// ...
if !arrested[i] && sample && transit.is_finite() {
check_slat_bound(&plan.name, transit, dt)?; // <-- may bail here
}
let r = states[i].phase_a(PhaseAInputs { ... }); // <-- but belts 0..i already ran this
// ...
}
When belt i trips the slat bound (ErrorCode::ConveyorTransitTooLong), the ? propagates out of run_phase_a -> run_coupled_passes -> Vm::run_to (src/simlin-engine/src/vm.rs, around lines 958-972). But belts 0..i have already had phase_a applied: their ConveyorState slat vectors were advanced and their driven outflow/leak rates were written into curr.
Vm::run_to's error path is:
) {
self.data = Some(data);
return Err(Error::new(ErrorKind::Simulation, code, Some(msg)));
}
It hands the data Box back to self but restores nothing: neither curr's values nor self.conveyors.
The comment just above it (vm.rs:956-957, "restore the data buffer before propagating (as init_belts does), so the Vm stays reusable") is misleading: self.data = Some(data) only returns ownership of the Box; it does not restore any values.
Consequences
- Silent double-advance on resume. A caller that ignores the
Err and calls run_to again -- or fixes the offending <len> via set_value and resumes -- re-runs the step from the same curr[TIME] (no save_advance happened), so phase_a is applied a second time to belts 0..i. Those belts silently double-advance.
- Half-written pass output is observable.
curr is left holding driven rates for belts 0..i and none for the rest, which a subsequent vm.get_value() / get_series() would read.
The VM's own mid-run PREVIEW path (vm.rs:1187-1216) is careful about exactly this -- it snapshots curr, clones the side tables, and restores curr on failure -- but the real step path is not.
Why it matters
Narrow, but real. The error is deterministic in transit, so a naive re-run just re-errors at the same belt (having double-advanced the earlier ones), and most callers discard the Vm on Err. It becomes observable when a host resumes after changing an input, or reads state after the error.
Note the wasm backend (GH #921) deliberately diverges here: its runtime error channel is sticky until reset, precisely so a host cannot resume onto a half-advanced side table. That divergence is documented in src/simlin-engine/CLAUDE.md and is currently the safer of the two behaviors -- resolving this issue would let the two backends converge.
Components affected
src/simlin-engine/src/conveyor_compile.rs (run_phase_a)
src/simlin-engine/src/vm.rs (Vm::run_to pass-error path, and the misleading comment at 956-957)
Possible approaches
- (a) Make the pass atomic. Validate every belt's slat bound in a pre-pass over the plan list, before any belt mutates. The bound check is a pure function of
curr[len_off] and dt, so it can run for all belts up front. This is the cleanest option: it makes run_phase_a all-or-nothing and removes the need for any restore.
- (b) Snapshot/restore in
Vm::run_to. Have the pass-error path snapshot curr and clone the side tables the way the preview path already does, restoring both on Err.
- (c) Poison the Vm. Latch a flag so a post-error
run_to returns the same error rather than resuming -- mirroring the wasm backend's sticky channel.
(a) is preferred; (c) is a reasonable belt-and-braces addition on top, since it also covers any future pass that cannot be pre-validated.
Whichever is chosen, the vm.rs:956-957 comment should be corrected so it stops claiming a restore that does not happen.
Regression test: use the test-only SlatBoundGuard override (src/simlin-engine/src/conveyor.rs:69-86) with a tiny multi-belt fixture where belt 1 trips the bound; assert belt 0's slats and curr are unchanged after the Err. Do NOT build a 1e6-slat model to trip the production bound.
Discovered during
Implementation of GH #921 (the wasm backend's runtime error channel), while establishing what the VM's semantics actually are so the wasm side could match them.
Problem
conveyor_compile::run_phase_a(src/simlin-engine/src/conveyor_compile.rs, around line 3041) iterates the conveyor plan list and, for belti, callscheck_slat_bound(...)?beforestates[i].phase_a(...):When belt
itrips the slat bound (ErrorCode::ConveyorTransitTooLong), the?propagates out ofrun_phase_a->run_coupled_passes->Vm::run_to(src/simlin-engine/src/vm.rs, around lines 958-972). But belts0..ihave already hadphase_aapplied: theirConveyorStateslat vectors were advanced and their driven outflow/leak rates were written intocurr.Vm::run_to's error path is:It hands the data
Boxback toselfbut restores nothing: neithercurr's values norself.conveyors.The comment just above it (
vm.rs:956-957, "restore the data buffer before propagating (asinit_beltsdoes), so the Vm stays reusable") is misleading:self.data = Some(data)only returns ownership of theBox; it does not restore any values.Consequences
Errand callsrun_toagain -- or fixes the offending<len>viaset_valueand resumes -- re-runs the step from the samecurr[TIME](nosave_advancehappened), sophase_ais applied a second time to belts0..i. Those belts silently double-advance.curris left holding driven rates for belts0..iand none for the rest, which a subsequentvm.get_value()/get_series()would read.The VM's own mid-run PREVIEW path (
vm.rs:1187-1216) is careful about exactly this -- it snapshotscurr, clones the side tables, and restorescurron failure -- but the real step path is not.Why it matters
Narrow, but real. The error is deterministic in
transit, so a naive re-run just re-errors at the same belt (having double-advanced the earlier ones), and most callers discard theVmonErr. It becomes observable when a host resumes after changing an input, or reads state after the error.Note the wasm backend (GH #921) deliberately diverges here: its runtime error channel is sticky until
reset, precisely so a host cannot resume onto a half-advanced side table. That divergence is documented insrc/simlin-engine/CLAUDE.mdand is currently the safer of the two behaviors -- resolving this issue would let the two backends converge.Components affected
src/simlin-engine/src/conveyor_compile.rs(run_phase_a)src/simlin-engine/src/vm.rs(Vm::run_topass-error path, and the misleading comment at 956-957)Possible approaches
curr[len_off]anddt, so it can run for all belts up front. This is the cleanest option: it makesrun_phase_aall-or-nothing and removes the need for any restore.Vm::run_to. Have the pass-error path snapshotcurrand clone the side tables the way the preview path already does, restoring both onErr.run_toreturns the same error rather than resuming -- mirroring the wasm backend's sticky channel.(a) is preferred; (c) is a reasonable belt-and-braces addition on top, since it also covers any future pass that cannot be pre-validated.
Whichever is chosen, the
vm.rs:956-957comment should be corrected so it stops claiming a restore that does not happen.Regression test: use the test-only
SlatBoundGuardoverride (src/simlin-engine/src/conveyor.rs:69-86) with a tiny multi-belt fixture where belt 1 trips the bound; assert belt 0's slats andcurrare unchanged after theErr. Do NOT build a 1e6-slat model to trip the production bound.Discovered during
Implementation of GH #921 (the wasm backend's runtime error channel), while establishing what the VM's semantics actually are so the wasm side could match them.