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; noUnsafeCell, 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'stokioMT runtime viatokio::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.rsnext 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
Sequencerto
LeadLagto 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:
OnDelaywarmup plusOffDelay
lockout. - Outdoor Lighting (dusk-to-cutoff):
SunplusScheduleplus
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_inputsdrains 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_readybacks off exponentially up to 2 seconds
when no inputs are reactive, avoiding a polling spin.wait_on_inputswaits 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.rsthat
scanssrc/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:
BlockStatelostStoppedand gained associated data onFault.
Match arms need aFault { reason }pattern.BlockPropstrait-object returns now carry+ Send. Any
out-of-treeBlockPropsimpl needs to add the bound. Concrete
inputs/outputs (BaseInput,BaseOutput) gainedSendbounds on
their type parameters.Block::executereturnsimpl Future + Sendon native (the trait
is cfg-gated for wasm).Input::try_takereturnsOption<(Value, Status)>instead of
Option<Value>.Input::set_valuetakes(value, status).
Input::status()added.Output::setis unchanged, but a newemit_status(Status)is
required.BlockProps::default()(returningInputDefault) removed.
InputDefaultstruct removed. Nothing read it.LinkState::Errorremoved. Channel/transport failures surface as
Status::Staleon the receiving input instead.Engine::load_blocks_and_linksreplaced byschedule_program_blocks
(sync, on the trait) plusload_program(async, inherent on each
engine).Programis a new data type matching the JS shape.GetCurrentProgramRes(Result<(Vec<BlockData>, Vec<LinkData>), _>)
is nowGetCurrentProgramRes(Result<Program, _>). New
LoadProgramReq/LoadProgramResengine messages.- Module layout switched to post-2018 (
foo.rsnext tofoo/). If
you were importing internal modules, the file paths changed but
the module paths did not. BlockInputData(on theinspect_blocksnapshot) gained an
is_connected: boolfield.
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.