Skip to content

v0.9.0 - Pre 1.0 Release

Pre-release
Pre-release

Choose a tag to compare

@rracariu rracariu released this 19 May 18:13
· 15 commits to main since this release

Logic Mesh 1.0

First stable release afer a long hiatus.

TL;DR

  • Per-block actor execution model. Each block owns itself in a Tokio
    task; no UnsafeCell, no aliasing, no shared mutable state across
    blocks.
  • Block-level fault propagation. Watch payloads carry a quality
    status (Ok | Fault | Stale), so a broken upstream marks its
    downstream consumers and the UI renders both. Auto-recovery on the
    next clean execute.
  • Multi-threaded engine added. Block actor tasks ride directly on the
    caller's tokio MT runtime via tokio::spawn, so the runtime's
    work-stealing scheduler handles them.
  • Full save/load via the Rust API alone. The JS layer is no longer the
    canonical loader, so headless deployments are first-class.
  • Larger block catalog (psychrometrics, control patterns, lighting,
    scheduling, timers, UI widgets) and a SvelteKit web editor with five
    worked HVAC/lighting example programs.
  • Module layout switched to post-2018 edition (my_mod.rs next to
    my_mod/).

What changed since the last release

Execution model

The big one. Previously, blocks ran inside a shared scheduler that
used UnsafeCell to thread mutable access across the cycle. Miri did
not like it, and adding new awaits to block bodies was a constant
source of subtle bugs.

1.0 puts each scheduled block in its own Tokio actor task that owns
the block by value. Every external operation (write input/output,
inspect, wire/teardown links) is routed through a per-block
mpsc::Sender<BlockMailboxCmd>. The actor loop interleaves
block.execute() with mailbox handling via tokio::select!. When a
mailbox command arrives mid-cycle, the in-flight execute future is
dropped (cancellation-safe), the command is handled, and a fresh
execute starts.

Net effect: a UI write reaches the block in a few microseconds rather
than waiting a full polling window, and the engine is Miri-clean.

Fault propagation

Previously a faulted block kept executing silently and downstream
consumers had no way to tell. For a BAS engine that is the worst
class of bug: a frozen sensor would freeze a PID at the last-known
value forever.

1.0 introduces a per-wire Status (Ok | Fault | Stale) that rides
on every watch channel payload. The producer pushes Status::Fault
when it enters fault state, the consumer transitions itself to fault
on draining a fault-status value. BlockState collapsed to
Running | Fault { reason } | Disabled | Terminated, with the reason
carried through to inspect_block and to the UI (red ring on faulted
blocks, red edges on faulted links). Recovery is automatic on the
next clean execute.

The on-the-wire payload change is intentionally future-proofed: per-pin
fault status (so a 5-input block with one bad input does not go fully
dark) drops in without rewriting any channel plumbing.

Multi-threaded engine

New in 1.0. Schedule blocks with schedule_send and they run as
actor tasks spawned directly via tokio::spawn onto the caller's MT
runtime, which provides work-stealing and standard task lifecycle.
The engine itself owns no worker threads.

To make spawning Send-safe, the Block trait declares
execute(&mut self) -> impl Future<Output = ()> + Send on native
targets (wasm32 keeps the non-Send form so JsBlock continues to
work). Trait-object return types in BlockProps carry + Send, and
the MT scheduling site requires B: Send + Sync + 'static.

Behind a multi-threaded feature flag.

Program save/load via Rust alone

Previously the JS layer was the canonical loader. It would call
addBlock, then createLink, then writeBlockInput per input
constant, in a sequence of N+M+P round-trips through the wasm
boundary. Anyone trying to use the Rust engine standalone lost the
input constants.

1.0 adds a proper Program data type at the Rust level (blocks and
links keyed by uuid, with per-block label, position, pin values, and
isConnected) and async load_program / save_program methods on
both engines. New LoadProgramReq engine message; the wasm bridge
exposes a single loadProgram(program) call. The JS pushToEngine
is now one line.

Result: a Rust headless service can load a saved program from JSON
and run it without the web UI being in the loop.

Block catalog

New blocks added since the last release:

  • Control: Pid (with anti-windup, filtered derivative on
    measurement, configurable bias), Reset, Deadband, Clamp,
    Sequencer, LeadLag (rotation), TrimRespond (ASHRAE G36 trim
    and respond setpoint reset), Economizer, PriorityArray.
  • Timers: OnDelay, OffDelay, OneShot, RateLimit,
    Runtime (accumulator), CycleCount.
  • Time: Now, Schedule, Calendar, Sun (sunrise/sunset by
    lat/lon).
  • Psychrometrics: Enthalpy, Dewpoint, WetBulb (Stull
    approximation).
  • Logic edge-detecting: FlipFlop, Latch, Trigger.
  • Misc state: Ema (exponential moving average), MovingAverage,
    Derivative, Integrator, ChangeOfValue, SampleHold.
  • UI blocks (JS, registered via defineBlock): Slider, Gauge,
    Bar, Display, Led, Chart, MultiChart, Button,
    Checkbox, ComboBox, Table, Input, Label.

Total catalog is well over 80 blocks, all unit-aware where the
operation cares about units (PID, Reset, EMA, TrimRespond, math
between numbers with units, psychrometrics).

Web editor and worked examples

The web editor is now SvelteKit (was Vue + a Vue flow editor)
and ships with @xyflow/svelte for the graph. Five worked example
programs are bundled and loadable from a dropdown:

  • DAT Temperature Reset: G36-style reset of supply-air setpoint
    from outdoor temperature, driving a closed PID loop.
  • Cooling Tower Stage + Lead/Lag: demand to Sequencer to
    LeadLag to fan LEDs, with on/off delays.
  • Air-Side Economizer (Enthalpy): outdoor vs return air enthalpy
    comparison driving a free-cooling LED, both enthalpies on a
    MultiChart.
  • Anti-Short-Cycle Compressor: OnDelay warmup plus OffDelay
    lockout.
  • Outdoor Lighting (dusk-to-cutoff): Sun plus Schedule plus
    boolean composition driving a streetlight.

JS/TS block authoring

defineBlock(...) accepts a Zod schema for inputs and outputs and
returns a fully-typed block plus a TypeScript type. Block executors
are async functions that get typed input objects in and return a
typed output object. The block ends up registered into the wasm
engine as a JsBlock and runs in the same cycle as the Rust blocks.

Internals worth knowing about

  • read_block_inputs drains every input that has a fresh value in
    one pass per cycle, so multi-input blocks (PID, Reset, Enthalpy)
    see a temporally-coherent snapshot rather than being walked one
    input at a time.
  • read_inputs_until_ready backs off exponentially up to 2 seconds
    when no inputs are reactive, avoiding a polling spin.
  • wait_on_inputs waits the configured period for periodic blocks
    but returns early when an input actually arrives. No more double
    sleep on every reaction.
  • Watch channels use send_if_modified, so identical re-emissions do
    not wake downstream blocks. Convergent feedback loops actually
    quiesce.
  • Engine to UI watch channel is unbounded. Fault notifications under
    burst load (e.g., mass state transitions during program load) do
    not get dropped.
  • The block registry is generated at build time by a build.rs that
    scans src/blocks/ for #[block] annotations.
  • Trait aliases (EngineBlock, MtBlock, BlockInput<R,W>,
    BlockOutput<W>) keep the bound spellings readable.

Breaking changes

The 1.0 surface is not backwards-compatible at the Rust API level. If you
were depending on 0.x:

  • BlockState lost Stopped and gained associated data on Fault.
    Match arms need a Fault { reason } pattern.
  • BlockProps trait-object returns now carry + Send. Any
    out-of-tree BlockProps impl needs to add the bound. Concrete
    inputs/outputs (BaseInput, BaseOutput) gained Send bounds on
    their type parameters.
  • Block::execute returns impl Future + Send on native (the trait
    is cfg-gated for wasm).
  • Input::try_take returns Option<(Value, Status)> instead of
    Option<Value>. Input::set_value takes (value, status).
    Input::status() added.
  • Output::set is unchanged, but a new emit_status(Status) is
    required.
  • BlockProps::default() (returning InputDefault) removed.
    InputDefault struct removed. Nothing read it.
  • LinkState::Error removed. Channel/transport failures surface as
    Status::Stale on the receiving input instead.
  • Engine::load_blocks_and_links replaced by schedule_program_blocks
    (sync, on the trait) plus load_program (async, inherent on each
    engine). Program is a new data type matching the JS shape.
  • GetCurrentProgramRes(Result<(Vec<BlockData>, Vec<LinkData>), _>)
    is now GetCurrentProgramRes(Result<Program, _>). New
    LoadProgramReq / LoadProgramRes engine messages.
  • Module layout switched to post-2018 (foo.rs next to foo/). If
    you were importing internal modules, the file paths changed but
    the module paths did not.
  • BlockInputData (on the inspect_block snapshot) gained an
    is_connected: bool field.

Minimum supported Rust version

Stable Rust, edition 2024. The crate uses impl Future in trait
returns, so MSRV is whatever introduced that on stable. CI tests
the matrix {default, --features multi-threaded} against
stable-{linux,macOS,wasm32}.

Tested with

179 unit + integration tests pass on default, --features multi-threaded, and wasm32-unknown-unknown builds. The web editor's
demo programs each get exercised end-to-end on every CI run via the
demo-site build.

Known limitations

  • The engine is Tokio-specific. No embassy / smol / no_std
    support today. Documented; revisit when there is a concrete second
    runtime ask.
  • Per-pin (rather than per-block) fault status is sketched in the
    internal roadmap but not in 1.0. Drops in without channel changes.
  • No block lifecycle hooks (init / terminate) for resource-managing
    blocks (Modbus, network). Will add when the first such block lands.