Skip to Content
ftui-runtimeRolloutExecution lanes

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

crates/ftui-runtime/src/program.rs
#[derive(Default)] pub enum RuntimeLane { Legacy, #[default] Structured, Asupersync, }
LaneSubscription modelCmd::Task executorCancellation
LegacyThread per subscription, manual stop coordination.Spawned (thread per task)Manual via StopSignal.
Structured (default)Thread per subscription + CancellationToken.EffectQueue (queueing scheduler).Structured; token propagates.
AsupersyncReserved — 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):

  1. Programmatic: ProgramConfig::with_lane(lane) at program.rs:2710.
  2. Environment variable: FTUI_RUNTIME_LANElegacy / structured / asupersync (case-insensitive). Read by RuntimeLane::from_env at program.rs:2248.
  3. Default: Structured.

The usual idiom ends the builder chain with .with_env_overrides() so operators can flip a lane without a rebuild:

main.rs
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:

crates/ftui-runtime/src/program.rs:2187
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::run receives a StopSignal that wraps the token.
  • Cmd::Task closures can snapshot the token via EffectQueueConfig / Cx and 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

main.rs
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.

Cross-references