Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions frb_codegen/src/binary/commands_parser.rs
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,8 @@ fn compute_codegen_config_from_naive_command_args(args: GenerateCommandArgsPrima
dump_all: positive_bool_arg(args.dump_all),
rust_features: args.rust_features,
use_oxidized: None,
// CLI args don't carry lane routing; it's a yaml-only config.
lane_routing: None,
}
}

Expand Down
31 changes: 31 additions & 0 deletions frb_codegen/src/library/codegen/config/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -47,13 +47,43 @@ pub struct Config {
pub dump_all: Option<bool>,
pub rust_features: Option<Vec<String>>,
pub use_oxidized: Option<bool>,
pub lane_routing: Option<LaneRoutingConfig>,
}

#[derive(Debug, Serialize, Deserialize, Default)]
pub struct MetaConfig {
pub watch: bool,
}

/// Optional policy for the `#[frb(thread = <lane>)]` executor-routing feature.
///
/// Flutter Rust Bridge itself assigns no meaning to worker lanes — it only
/// stamps the lane key onto `TaskInfo.thread` and hands it to the embedder's
/// `ThreadRouter`. This config lets the embedder declare, in
/// `flutter_rust_bridge.yaml`, which lane keys are valid and which functions
/// must be annotated, so codegen can enforce it with no hardcoded domain
/// knowledge. When absent, codegen accepts any lane key and enforces nothing.
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct LaneRoutingConfig {
/// Valid worker-lane keys for `#[frb(thread = <key>)]` (besides the implicit
/// `Main`). When set, codegen errors on any annotation outside this set.
pub lanes: Option<Vec<String>>,
/// When set, codegen errors if a function matching the predicate has no
/// `#[frb(thread = ...)]` annotation.
pub require_annotation_when: Option<RequireAnnotationWhen>,
}

/// Predicate selecting functions that must declare a `#[frb(thread = ...)]` lane.
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct RequireAnnotationWhen {
/// Require a lane on every `#[frb(sync)]` function.
pub sync: Option<bool>,
/// Require a lane on any function with a parameter named one of these.
pub param_named: Option<Vec<String>>,
}

macro_rules! generate_merge {
($($field:ident,)*) => (
impl Config {
Expand Down Expand Up @@ -111,4 +141,5 @@ generate_merge!(
dump_all,
rust_features,
use_oxidized,
lane_routing,
);
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,7 @@ impl InternalConfig {
enable_lifetime: config.enable_lifetime.unwrap_or_default(),
type_64bit_int: config.type_64bit_int.unwrap_or_default(),
default_dart_async: config.default_dart_async.unwrap_or(true),
lane_routing: config.lane_routing.clone(),
},
},
generator,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,12 @@ fn generate_wrap_info_obj(func: &MirFunc) -> String {
"None"
},
mode = ffi_call_mode(func.mode),
thread = func.thread.as_deref().unwrap_or("Main"),
// `Main` (or unset) is the inline lane; any other key is an opaque
// worker-lane string the embedder's `ThreadRouter` resolves.
thread = match func.thread.as_deref() {
None | Some("Main") => "Main".to_owned(),
Some(key) => format!("Worker({key:?})"),
},
)
}

Expand Down
2 changes: 2 additions & 0 deletions frb_codegen/src/library/codegen/parser/mir/internal_config.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
use crate::codegen::config::config::LaneRoutingConfig;
use crate::codegen::generator::codec::structs::{CodecMode, CodecModePack};
use crate::codegen::ir::mir::ty::rust_opaque::RustOpaqueCodecMode;
use crate::utils::namespace::Namespace;
Expand All @@ -13,6 +14,7 @@ pub(crate) struct ParserMirInternalConfig {
pub enable_lifetime: bool,
pub type_64bit_int: bool,
pub default_dart_async: bool,
pub lane_routing: Option<LaneRoutingConfig>,
}

// TODO rename - this is no longer an "input-namespace"-only pack
Expand Down
34 changes: 13 additions & 21 deletions frb_codegen/src/library/codegen/parser/mir/parser/attribute.rs
Original file line number Diff line number Diff line change
Expand Up @@ -709,30 +709,19 @@ impl Parse for FrbAttributeName {
}
}

/// Value of `#[frb(thread = <Ident>)]` — the executor routing lane.
/// Mirrors `flutter_rust_bridge::for_generated::Thread`. Stored as the variant
/// identifier so codegen can emit `Thread::<variant>` verbatim.
/// Value of `#[frb(thread = <Ident>)]` — the executor routing lane key.
/// Stored as the raw identifier; codegen emits `Thread::Main` for `Main` and
/// `Thread::Worker("<ident>")` otherwise. FRB assigns no meaning to lanes here:
/// parsing is purely syntactic, and any valid-lane / required-lane policy is
/// enforced later against `lane_routing` config (see `validate_frb_threads`),
/// where the embedder's config is available.
#[derive(Clone, Serialize, Eq, PartialEq, Debug)]
pub(crate) struct FrbAttributeThread(pub(crate) String);

impl FrbAttributeThread {
const VALID: &'static [&'static str] = &["Main", "Loro", "Export"];
}

impl Parse for FrbAttributeThread {
fn parse(input: ParseStream) -> Result<Self> {
let ident: syn::Ident = input.parse()?;
let value = ident.to_string();
if !Self::VALID.contains(&value.as_str()) {
return Err(syn::Error::new(
ident.span(),
format!(
"unknown frb thread `{value}`; expected one of {:?}",
Self::VALID
),
));
}
Ok(Self(value))
Ok(Self(ident.to_string()))
}
}

Expand Down Expand Up @@ -828,9 +817,12 @@ mod tests {
}

#[test]
fn test_thread_invalid() {
let result = parse("#[frb(thread = Nonsense)]");
assert!(result.is_err());
fn test_thread_arbitrary_ident_accepted() -> anyhow::Result<()> {
// The parser is purely syntactic: any ident is a valid lane key here;
// lane-set validation happens later against `lane_routing` config.
let parsed = parse("#[frb(thread = AnyLane)]")?;
assert_eq!(parsed.thread(), Some("AnyLane".to_owned()));
Ok(())
}

#[test]
Expand Down
98 changes: 64 additions & 34 deletions frb_codegen/src/library/codegen/parser/mir/parser/function/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -30,47 +30,77 @@ pub(crate) fn parse(
]);
let (funcs, skips) = IrValueOrSkip::split(items);
let funcs = sort_and_add_func_id(funcs);
validate_frb_threads(&funcs)?;
validate_frb_threads(&funcs, config)?;
Ok((funcs, skips))
}

/// Hybrid enforcement for `#[frb(thread = ...)]` (the executor routing lane).
/// Optional enforcement for the `#[frb(thread = ...)]` executor-routing lane,
/// driven entirely by the embedder's `lane_routing` config — FRB assigns no
/// meaning to lanes itself.
///
/// A function that may touch live CRDT/session state — it runs on the main
/// thread via `sync`, OR carries a session-handle argument — MUST declare a
/// thread so the custom executor routes it to the right worker. Pure getters
/// may omit it and default to `Thread::Main`.
/// With config present it errors when an annotation names a lane outside the
/// configured set, and when a function matching `require_annotation_when`
/// carries no annotation. Without config it is a no-op (any lane key accepted,
/// nothing required).
///
/// This runs as a post-parse pass (not inside per-function parsing) because an
/// error raised during `Parser::parse_function` with `stop_on_error=false` is
/// swallowed into a silent function *skip*, which would drop the offending
/// functions instead of failing the build. Returning the error here propagates
/// it to the top level.
fn validate_frb_threads(funcs: &[MirFunc]) -> anyhow::Result<()> {
const SESSION_HANDLE_ARGS: &[&str] =
&["session_id", "drive_id", "browser_id", "wizard_id"];
/// Runs as a post-parse pass (not inside per-function parsing) because an error
/// raised with `stop_on_error=false` is swallowed into a silent function *skip*,
/// which would drop the offending functions instead of failing the build.
/// Returning the error here propagates it to the top level.
fn validate_frb_threads(
funcs: &[MirFunc],
config: &ParserMirInternalConfig,
) -> anyhow::Result<()> {
let Some(lane_routing) = &config.lane_routing else {
return Ok(());
};

let offenders = funcs
.iter()
.filter(|func| func.thread.is_none())
.filter(|func| {
let is_sync = matches!(func.mode, MirFuncMode::Sync);
let has_session_arg = func.inputs.iter().any(|input| {
SESSION_HANDLE_ARGS.contains(&input.inner.name.rust_style(true).as_str())
});
is_sync || has_session_arg
})
.map(|func| func.name.rust_style(true))
.collect_vec();
// 1. Lane-name validation: every annotation must name `Main` or a configured lane.
if let Some(lanes) = &lane_routing.lanes {
for func in funcs {
if let Some(lane) = &func.thread {
if lane != "Main" && !lanes.iter().any(|l| l == lane) {
bail!(
"function `{}` is annotated `#[frb(thread = {lane})]`, but `{lane}` \
is not a configured lane; valid lanes: Main, {}",
func.name.rust_style(true),
lanes.join(", "),
);
}
}
}
}

// 2. Required-annotation policy.
if let Some(require) = &lane_routing.require_annotation_when {
let require_sync = require.sync.unwrap_or(false);
let param_named = require.param_named.clone().unwrap_or_default();
let offenders = funcs
.iter()
.filter(|func| func.thread.is_none())
.filter(|func| {
let is_sync = require_sync && matches!(func.mode, MirFuncMode::Sync);
let has_named_param = func
.inputs
.iter()
.any(|input| param_named.contains(&input.inner.name.rust_style(true)));
is_sync || has_named_param
})
.map(|func| func.name.rust_style(true))
.collect_vec();

if !offenders.is_empty() {
bail!(
"the following functions may touch live CRDT/session state (they are \
`sync` or carry a session/drive/browser/wizard id) but have no \
`#[frb(thread = ...)]` annotation; add \
`#[frb(thread = Main|Loro|Export)]` to each so the custom \
executor routes it to the correct worker: {offenders:?}"
);
if !offenders.is_empty() {
let lanes_hint = lane_routing
.lanes
.as_ref()
.map(|lanes| format!("Main|{}", lanes.join("|")))
.unwrap_or_else(|| "Main".to_owned());
bail!(
"these functions match the configured `require_annotation_when` policy but have \
no `#[frb(thread = ...)]` annotation; add `#[frb(thread = {lanes_hint})]` to \
each so the custom executor routes it: {offenders:?}"
);
}
}

Ok(())
Expand Down
1 change: 1 addition & 0 deletions frb_codegen/src/library/codegen/parser/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,7 @@ mod tests {
enable_lifetime: false,
type_64bit_int: false,
default_dart_async: true,
lane_routing: None,
},
};

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,8 @@
"web": "{the-working-directory}/my_dart_folder/frb_generated.web.dart"
},
"dart_preamble": "",
"dart_type_rename": {}
"dart_type_rename": {},
"use_oxidized": false
},
"wire": {
"c": {
Expand Down Expand Up @@ -110,6 +111,7 @@
"dart2rust": "Pde",
"rust2dart": "Pde"
},
"lane_routing": null,
"rust_input_namespace_pack": {
"rust_input_namespace_prefixes": [
"crate::api"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,8 @@
"web": "{the-working-directory}/my_dart_folder/frb_generated.web.dart"
},
"dart_preamble": "",
"dart_type_rename": {}
"dart_type_rename": {},
"use_oxidized": false
},
"wire": {
"c": {
Expand Down Expand Up @@ -110,6 +111,7 @@
"dart2rust": "Pde",
"rust2dart": "Pde"
},
"lane_routing": null,
"rust_input_namespace_pack": {
"rust_input_namespace_prefixes": [
"crate::api"
Expand Down
2 changes: 2 additions & 0 deletions frb_rust/src/for_generated/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,8 @@ pub use crate::rust_auto_opaque::{inner::RustAutoOpaqueInner, RustAutoOpaqueBase
pub use crate::rust_opaque::{dart2rust::decode_rust_opaque_nom, RustOpaqueBase};
pub use crate::stream::stream_sink::StreamSinkBase;
pub use crate::thread_pool::{BaseThreadPool, SimpleThreadPool};
#[cfg(all(feature = "rust-async", not(target_family = "wasm")))]
pub use crate::thread_pool::SingleThreadAsyncLane;
#[cfg(target_family = "wasm")]
pub use crate::web_transfer::transfer_closure::TransferClosure;
#[cfg(feature = "anyhow")]
Expand Down
24 changes: 11 additions & 13 deletions frb_rust/src/handler/handler.rs
Original file line number Diff line number Diff line change
Expand Up @@ -76,31 +76,29 @@ pub struct TaskInfo {
pub debug_name: &'static str,
/// The call mode of this function.
pub mode: FfiCallMode,
/// Which executor thread/worker lane this call must run on.
/// A custom [`super::executor::Executor`] uses this as a typed routing key
/// Which executor lane this call must run on.
/// A custom [`super::executor::Executor`] uses this as a routing key
/// instead of matching on [`TaskInfo::debug_name`]. The default executor
/// ignores it. Stamped by codegen from `#[frb(thread = ...)]`; defaults to
/// [`Thread::Main`] when unset.
pub thread: Thread,
}

/// Typed routing key for a custom [`super::executor::Executor`].
/// Routing key for a custom [`super::executor::Executor`].
///
/// The default executor ignores this. A custom executor maps each call to a
/// thread/worker lane, so the routing decision is a typed enum match
/// rather than a string comparison on [`TaskInfo::debug_name`].
/// worker lane. FRB assigns no meaning to a lane beyond "not the caller
/// thread" — the embedder's `ThreadRouter` owns what each key denotes.
#[derive(Copy, Clone, PartialEq, Eq, Debug, Default)]
pub enum Thread {
/// Pure, side-effect-free getters; safe to run on the caller (main) thread.
/// Also config/global setters that touch no CRDT state, and `!Send`
/// main-thread browser APIs (e.g. the sync WebSocket), which cannot leave
/// the main thread.
/// Run inline on the caller (main thread) — pure getters and config setters
/// that need no dedicated worker lane. The default when `thread` is unset.
#[default]
Main,
/// Touches live CRDT/session state; must run on the single owner worker.
Loro,
/// Render-from-snapshot (pdf / thumbnail); no live-state handle.
Export,
/// Run on the embedder-defined worker lane identified by this opaque key.
/// Stamped from `#[frb(thread = <Ident>)]` (the ident becomes the key); the
/// embedder's `ThreadRouter` maps the key to a concrete pool.
Worker(&'static str),
}

/// The types of return values for a particular Rust function.
Expand Down
Loading
Loading