Skip to content

conveyor-engine: a mid-pass ConveyorTransitTooLong leaves belts 0..i already phase_a-advanced; run_to restores nothing #938

Description

@bpowers

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

  1. 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.
  2. 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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions