Wingfoil's engine was rewritten. This page is the complete list of what
changes for Rust callers, and why. The Python half is
crates/wingfoil-python/docs/migration.rst
— it stands on its own; this page does not repeat it.
Ruled 2026-08-03 (cutover-plan 1.4): there is no compatibility facade. The new engine replaces the old one outright — the
MutableNodewiring path retired with the legacy tree and nothing re-exports it under the new name. Rust downstreams break at the major version bump, deliberately, and this guide is the answer. The Python binding made the same call.
The old engine fused three concerns into one object. A node was its
computation and its storage (RefCell fields) and its input plumbing
(peeking upstream Rc<dyn Stream>s). The new engine separates them: an Op
says only what a node computes, and the engine owns the rest.
That is a real break, not a rename. In exchange, one definition of a node's
semantics now drives the interpreted engine, a fully-monomorphized compiled
runner, and compiled islands nested inside interpreted graphs — with no
duplicated cycle logic to drift. See
wingfoil-architecture.md.
Everything the legacy tree could do, the new engine can do. The one exception is listed under What is gone.
Before — state in fields, inputs peeked from stored upstreams:
#[node(active = [upstream], output = value: f64)]
impl MutableNode for ScaleStream {
fn cycle(&mut self, _state: &mut GraphState) -> anyhow::Result<bool> {
self.value = self.upstream.peek_value() * self.factor;
Ok(true)
}
}After — config, state and inputs are parameters; the return says what to do:
pub struct Scale;
#[op(build = scale, fluent)]
impl Op for Scale {
type Cfg = f64; // construction-time config
type State = (); // engine-owned mutable state
type In<'a> = (&'a f64,); // typed inputs, passed in per cycle
type Out = f64;
const ACTIVATION: Activation = Activation::NONE;
fn cycle(cfg: &mut f64, _state: &mut (), input: (&f64,), _ctx: &mut Ctx<'_>)
-> Result<Tick<f64>>
{
Ok(Tick::Value(input.0 * *cfg))
}
}The mapping, item by item:
| Legacy | Now | Note |
|---|---|---|
&mut self fields for scratch state |
type State |
Engine-owned; Default-seeded unless the op declares otherwise |
&mut self fields for config |
type Cfg |
Closures live here |
self.upstream.peek_value() |
input.0 |
Typed and passed in; no stored upstream handles |
#[node(active = [a, b])] |
type In<'a> = (&'a A, &'a B) |
Position is the edge order |
#[node(passive = [x])] |
#[op(build = …, passive = [0])] |
A bitmask; positions index In |
Ok(true) / Ok(false) |
Tick::Value(v) / Tick::Quiet |
Plus Tick::Silent(v) — see below |
| no declaration | const ACTIVATION |
Scheduling is declared, not inferred from names |
fn setup/start/stop |
fn start/stop/teardown on Op |
All return anyhow::Result |
Ok(true)/Ok(false) could not express "update my value but do not tick
downstream" — the thing delay needs. That is Tick::Silent(v). If you ported
a node mechanically to Value/Quiet and its downstream now fires when it
should not, Silent is what you want.
#[op(build = name)] generates the interpreted builder method and the
nitro! forwarders the compiled paths dispatch through, both derived from the
declared shape. Your op and a built-in op take the identical path. If you are
looking for a match arm to register a node in, there isn't one — that is the
design, not an omission.
An op generic over a type nothing in Cfg/In/Out mentions (a marker, a
unit, a latency stage) declares it #[op(build = name, explicit = S)] and is
called .name::<S>().
Legacy sources were free functions returning Rc<dyn Stream<T>>, with the
graph assembled implicitly from whatever you passed to run. Now you hold a
GraphBuilder and build sources on it:
// before
let count = ticker(Duration::from_millis(10)).count();
count.run(RunMode::RealTime, RunFor::Cycles(100))?;
// after
let g = GraphBuilder::new();
let count = g.ticker(Duration::from_millis(10)).count();
let mut runner = g.build();
runner.run(RunMode::RealTime, RunFor::Cycles(100))?;| Legacy | Now |
|---|---|
ticker(d), constant(v) — free fns |
g.ticker(d), g.constant(v) — on the builder |
Rc<dyn Stream<T>> |
Stream<T> (a cheap handle, Clone) |
node.peek_value() |
runner.value(&handle) |
node.run(mode, for) |
g.build() then runner.run(mode, for) |
operator traits (StreamOperators) |
extension traits (StreamOps, SourceOps) |
RunMode, RunFor and NanoTime keep their shape — both engines share one
runtime core, so the variants, the arithmetic and the wire representation are
as they were. Two narrow removals on those types are listed under
what is gone: RunFor::done and three
of NanoTime's From impls.
Legacy exposed most adapters twice — a free function and an operator-trait
method. Now sinks are extension-trait methods on Stream<T> and sources are
free functions taking &GraphBuilder first:
use wingfoil::adapters::zmq::{ZeroMqPub, zmq_sub};
let (data, status) = zmq_sub::<Vec<u8>>(&g, RunMode::RealTime, "tcp://host:5556")?;
let sink = stream.zmq_pub(5556, ());Adapters stay out of the prelude — opt in per adapter with
use wingfoil::adapters::<name>::…;.
Statistics are an adapter, at the path legacy used.
wingfoil::adapters::statistics::StatisticsOps is unchanged from 8.x, so a
use line pointing at it still resolves. What is new is the statistics
feature: legacy compiled the module unconditionally, and now, like every other
adapter, you ask for it.
wingfoil = { version = "9", features = ["statistics"] }One consequence if you use statistics ops inside nitro!: the macro does not
glob feature-gated adapter traits into the module it generates, so the
surrounding file needs use wingfoil::adapters::statistics::StatisticsOps; —
the same import the fluent form needs.
Two behavioural differences worth knowing before you port an I/O graph:
- Connections are established at
start(), not at wiring. Wiring is pure, so a connection error now surfaces during the run (with node context) rather than during graph construction. - Live sources reject
RunMode::HistoricalFromat wiring. A historical run block-collects its input up front, so an unbounded live tail would deadlock atstart. You get an error naming the bounded reader instead of a hang.
The full list of behavioural deltas, adapter by adapter, is
deviation-register.md.
Every lifecycle function returns anyhow::Result. Propagate with ? and add
.context("…") at I/O boundaries. A producer thread pushes an error into the
graph with sender.send_error(e), which aborts the run with context.
Production code does not call .unwrap(); use .expect("invariant: WHY") only
where a precondition makes the branch unreachable.
Graph::export — the GML topology dump. The name is gone; the capability
is not, and is now strictly larger. The drop was deliberate (cutover-plan row
2.1, register C6) because we wanted a designed introspection story
rather than a same-shape port of a debug-only helper — that story is
introspect, and it has landed.
| legacy | wingfoil |
|---|---|
graph.export("g.gml")? |
runner.snapshot().to_gml() (or g.snapshot() before build) |
GraphSnapshot is a value rather than a side effect on a file path, so you can
assert on it in a test; it distinguishes active from passive edges, which GML
cannot express; and it renders to text, Mermaid, Graphviz DOT and JSON as well
as GML. See examples/core/introspect/ and
docs/planning/introspection-plan.md.
RunFor::done — the "has the run finished?" predicate on the run bound,
with no replacement needed. It had no callers in the tree and it disagreed
with the engine: its cycle test was cycle > cycles where Kernel::begin_cycle
uses cycles >= end, so anything trusting it would have run one cycle long.
RunFor now only declares the bound; evaluating it lives in the kernel, and
the runner already stops at the bound you gave it.
Three of NanoTime's From impls — From<i64>, From<f64> and
From<u128>, with no drop-in replacement. Each body was a bare as cast,
so a conversion the compiler could insert at any .into() could silently
reinterpret its input — NanoTime::from(-1i64) was NanoTime::MAX. Decide
what an out-of-range value means for your feed and go through the total path:
NanoTime::new(u64::try_from(n)?) // i64, rejecting negatives
NanoTime::new(x.max(0.0) as u64) // f64, if clamping is what you wantFrom<u64>, From<Duration> and From<NaiveDateTime> are untouched.
limit and skip count in usize, not u32 — matching buffer's
capacity, fan's width and every rolling window in the catalog. Literal call
sites (.limit(3)) are unaffected; a call site that had converted on the way
in (.limit(n as u32)) drops the conversion.
collapse iterates its input by reference. Its bound narrowed from
T: Clone + IntoIterator<Item = OUT> to
for<'b> &'b T: IntoIterator<Item = &'b OUT>, so it clones the one item it
emits rather than the whole container it then throws away. Burst<T> and
Vec<T> both satisfy the new bound; a container that implements
IntoIterator only by value no longer compiles.
AeronStatusStream — the type is gone, the capability is not (register
D17). Legacy exposed a MutableNode the producer drove through
clear()/record(); wingfoil multiplexes status with data internally and
hands you the status half of aeron_sub_fragment_with_status as an ordinary
Stream<Burst<AeronStatus>>. Transition-only emission, derivation order and
in-band ordering in threaded mode are all preserved.
stamp_if and stamp_precise_if — withdrawn in favour of
stamp_as::<S>(mode); see Latency capture (register
D28).
If you find anything else the legacy tree did that the new engine cannot, that is a bug in the port, not an intended break — please report it.
The stamping surface gains the [Stamping] mode and the fused stamp_all, one
signature moved, and the two _if stamps were withdrawn rather than kept —
the one place this surface is not a superset of legacy's, on record as
deviation D28. Every call they served is expressible as one stamp_as, and
the pair of them was how a stage got stamped twice.
| legacy | wingfoil |
|---|---|
s.stamp::<S>() / s.stamp_precise::<S>() |
unchanged |
s.stamp_if::<S>(on) |
removed — s.stamp_as::<S>(Stamping::on_if(on)) |
s.stamp_precise_if::<S>(on) |
removed — s.stamp_as::<S>(Stamping::new(on, true)) |
| — | s.stamp_as::<S>(mode) — the clock as a [Stamping] argument, so a config flag picks it in one call rather than two _ifs with opposite polarities |
| — | s.stamp_all::<(A, B)>(mode) — several stages from one node, one payload clone instead of N |
let sink = s.latency_report(true); |
let (sink, latency) = s.latency_report(ReportOutput::Stdout); |
ReportOutput replaces the print_on_teardown bool (false →
ReportOutput::Silent), and adds Log — legacy wrote the summary to stdout
and nowhere else. The second element of the return is a LatencyHandle: it
reads out as labelled HopStats (hops(), total()), resets (reset() /
take(), without which one outlier pins a p99 for the life of the process),
and wires to a Stream<LatencySnapshot> with windows(&g, period) — at most
one per handle, because the windowed read is destructive: a second windows
stream would steal samples from the first, so wiring it panics. Branch the
returned stream to fan a window out to several consumers.
One behavioural difference to know about, because it changes numbers you may
have been reading. Legacy recorded a same-cycle hop — two stages sharing
wall_time's per-cycle snap, so no measurement is possible — as a genuine 0 ns
sample, and silently dropped a backwards one. Both are now tallied instead
(same_instant / backwards / unstamped) and the report prints dashes and
the reason. A hop that legacy reported as count 120, min 0, p50 0 will now
report count 0 with a 120 same-cycle note: the same underlying facts, minus
the claim that a measurement was taken. Stamp those stages with
Stamping::Precise to measure them for real.
You do not have to move every process at once. The ZeroMQ wire format is byte-compatible between the two engines, so a publisher on one can feed a subscriber on the other, in either direction, including through the Python bindings. That is covered by tests specifically so a staged rollout stays safe (register C2).