Runtime lanes
The runtime supports three execution modes — called lanes — that represent a migration path from the original thread-per-subscription implementation to a structured-cancellation, queue-scheduled executor. Lanes are chosen per session and logged at startup so operators can verify which codepath is live.
File: crates/ftui-runtime/src/program.rs:2174 (enum) and
program.rs:2226 (backend resolution).
The three lanes
#[derive(Default)]
pub enum RuntimeLane {
Legacy,
#[default] Structured,
Asupersync,
}| Lane | Subscription model | Cmd::Task executor | Cancellation |
|---|---|---|---|
Legacy | Thread per subscription, manual stop coordination. | Spawned (thread per task) | Manual via StopSignal. |
Structured (default) | Thread per subscription + CancellationToken. | EffectQueue (queueing scheduler). | Structured; token propagates. |
Asupersync | Reserved — Asupersync-native execution. | Asupersync (blocking pool) when asupersync-executor feature is on. | Structured. |
Externally observable behaviour is identical between Legacy and
Structured. The difference is internal: Structured uses
CancellationToken plumbing consistent with future Asupersync work,
and it enqueues background tasks through the runtime’s SRPT / Smith’s-
rule scheduler instead of spawning a thread each time.
Selection
Three ways to pick a lane, in priority order (last one wins):
- Programmatic:
ProgramConfig::with_lane(lane)atprogram.rs:2710. - Environment variable:
FTUI_RUNTIME_LANE—legacy/structured/asupersync(case-insensitive). Read byRuntimeLane::from_envatprogram.rs:2248. - Default:
Structured.
The usual idiom ends the builder chain with .with_env_overrides()
so operators can flip a lane without a rebuild:
use ftui::prelude::*;
let cfg = ProgramConfig::default()
.with_lane(RuntimeLane::Structured)
.with_env_overrides();Fallback semantics
RuntimeLane::resolve normalises a requested lane into an available
one:
pub fn resolve(self) -> Self {
match self {
Self::Asupersync => {
tracing::info!(
target: "ftui.runtime",
requested = "asupersync",
resolved = "structured",
"Asupersync lane not yet available; falling back",
);
Self::Structured
}
other => other,
}
}Fallback is logged on ftui.runtime so the startup banner makes the
actual lane obvious. If a downstream operator grep-matches
resolved_lane, they will see the real codepath, not the requested
one.
Task-executor backend by lane
RuntimeLane::task_executor_backend (program.rs:2226) maps each
resolved lane to the default task executor:
fn task_executor_backend(self) -> TaskExecutorBackend {
match self {
Self::Legacy => TaskExecutorBackend::Spawned,
Self::Structured => TaskExecutorBackend::EffectQueue,
Self::Asupersync => {
#[cfg(feature = "asupersync-executor")]
{ TaskExecutorBackend::Asupersync }
#[cfg(not(feature = "asupersync-executor"))]
{ TaskExecutorBackend::EffectQueue }
}
}
}You can override the backend explicitly on
EffectQueueConfig::with_backend(...) — see
effect queue — but leaving it as the
lane default is the common case.
Cancellation model
Structured and Asupersync use a CancellationToken (see
crates/ftui-runtime/src/cancellation.rs) that propagates down through:
Subscription::runreceives aStopSignalthat wraps the token.Cmd::Taskclosures can snapshot the token viaEffectQueueConfig/Cxand cooperatively exit.- Shutdown cancels the root token; everything downstream unwinds promptly with bounded joins.
Legacy does not propagate a token; subscriptions receive a StopSignal
backed by the same primitive, but tasks run on plain spawned threads
with no cooperative cancellation.
Observability
On startup the runtime emits one runtime.startup event with fields
including:
requested_lane— what the config asked for.resolved_lane— after.resolve().rollout_policy— see shadow run.
Dashboards keyed on resolved_lane can therefore show the live mix
of lanes across a fleet.
Worked example: mixed-fleet rollout
use ftui::prelude::*;
fn main() -> std::io::Result<()> {
let cfg = ProgramConfig::fullscreen()
// Program default (for operators without an env var):
.with_lane(RuntimeLane::Structured)
// Optional shadow: compare against Legacy behaviour.
.with_rollout_policy(RolloutPolicy::Shadow)
// Honour FTUI_RUNTIME_LANE / FTUI_ROLLOUT_POLICY on the host.
.with_env_overrides();
Program::with_config(MyModel::new(), cfg)?.run()
}Operators on an Asupersync-ready host can export
FTUI_RUNTIME_LANE=asupersync without rebuilding; the runtime
resolves it — or falls back to Structured — and logs the decision.
Pitfalls
Don’t rely on Legacy’s concurrency accidents. Code that
(un)intentionally depends on a subscription running on its own
OS thread will still work under Structured, but Cmd::Task
semantics tighten: tasks now compete for queue slots. Audit any
test that uses thread-local state or thread::current().id().
Asupersync falls back silently without the feature. If you
deploy a binary built without asupersync-executor but set
FTUI_RUNTIME_LANE=asupersync, you will get Structured. Grep the
startup log for resolved_lane if this matters.