diff --git a/node-graph/node-macro/src/codegen.rs b/node-graph/node-macro/src/codegen.rs index 48c1068e15..01ebc0f523 100644 --- a/node-graph/node-macro/src/codegen.rs +++ b/node-graph/node-macro/src/codegen.rs @@ -275,7 +275,7 @@ pub(crate) fn generate_node_code(crate_ident: &CrateIdent, parsed: &ParsedNodeFn })); let async_source = parsed.injects_async_source_fields(); - let slot_value_type = slot_value_type(output_type); + let slot_value_type = crate::codegen::classify::substitute_lifetimes(&slot_value_type(output_type), "'static"); let slot_field = async_source .then(|| quote! { pub(super) slot: std::sync::Arc>>>> }) .into_iter(); @@ -886,6 +886,12 @@ pub(crate) fn generate_node_impl(crate_ident: &CrateIdent, parsed: &ParsedNodeFn .iter() .filter(|param| match param { GenericParam::Type(type_param) => Some(&type_param.ident) != routing_generic.as_ref() && Some(&type_param.ident) != record_token.as_ref(), + // A serving lifetime stays only while the ctx bound constrains it + // (`ExtractArena<'e>`); wire types substitute its erased + // projection, which would leave it unconstrained. A flipped + // kernel's serving lifetime rebinds to the record lifetime, so + // the impl drops it entirely. + GenericParam::Lifetime(lifetime_param) => !flip && ctx_param.is_some_and(|ctx| quote!(#ctx).to_string().contains(&lifetime_param.lifetime.to_string())), _ => true, }) .map(&generic_tokens) @@ -897,6 +903,21 @@ pub(crate) fn generate_node_impl(crate_ident: &CrateIdent, parsed: &ParsedNodeFn if routing_generic.is_some() || record_io || flip { impl_generics.insert(0, quote!('__record)); } + // A flipped kernel's serving lifetime is the record lifetime at the impl: + // the ctx bound rebinds under the impl's own name. + if flip { + let serving_names: Vec = parsed + .fn_generics + .iter() + .filter_map(|param| match param { + GenericParam::Lifetime(lifetime_param) => Some(lifetime_param.lifetime.ident.to_string()), + _ => None, + }) + .collect(); + if !serving_names.is_empty() { + impl_generics = impl_generics.into_iter().map(|tokens| crate::codegen::classify::rename_lifetimes_to_record(tokens, &serving_names)).collect(); + } + } let lazy_carrier = record_io && carrier_present && matches!(parsed.fields.iter().find(|field| !field.is_data_field).map(|field| &field.ty), Some(ParsedFieldType::Node(_))); if derive_routing || (lazy_carrier && derives) { generics.insert(0, quote!('__record)); @@ -1027,10 +1048,30 @@ pub(crate) fn generate_node_impl(crate_ident: &CrateIdent, parsed: &ParsedNodeFn match &field.ty { ParsedFieldType::Regular(RegularParsedField { ty, .. }) if ir::materialized_levels(&node, index) > 0 => { // The gathered lane borrows this list, so both take the kernel's - // own subject lifetime. - match ir::gathered_subject(&node) == Some(index) { - true => quote!(#pat: #core_types::node::List<'__lane, #ty>), - false => quote!(#pat: #core_types::node::List<'_, #ty>), + // own subject lifetime. An element type naming a serving + // lifetime ties the view to the same region; a fn-declared + // serving lifetime binds a generic subject's view, so the + // output can borrow the materialized level. + let declared = || { + let mut lifetimes = parsed.fn_generics.iter().filter_map(|param| match param { + GenericParam::Lifetime(lifetime_param) => Some(lifetime_param.lifetime.clone()), + _ => None, + }); + lifetimes + .next() + .filter(|_| lifetimes.next().is_none()) + .filter(|lifetime| match &node.output.shape.element { + ir::Element::Concrete(element) => crate::codegen::classify::named_serving_lifetime(element).as_ref() == Some(lifetime), + _ => false, + }) + // An arena-bound lifetime serves the output from the + // arena, not from the subject's batch view. + .filter(|lifetime| !ctx_param.is_some_and(|ctx| quote!(#ctx).to_string().contains(&lifetime.to_string()))) + }; + match (crate::codegen::classify::named_serving_lifetime(ty).or_else(declared), ir::gathered_subject(&node) == Some(index)) { + (Some(lifetime), _) => quote!(#pat: #core_types::node::List<#lifetime, #ty>), + (None, true) => quote!(#pat: #core_types::node::List<'__lane, #ty>), + (None, false) => quote!(#pat: #core_types::node::List<'_, #ty>), } } ParsedFieldType::Regular(RegularParsedField { ty, lend: Some(_), .. }) => quote!(#pat: &#ty), @@ -1214,6 +1255,7 @@ pub(crate) fn generate_node_impl(crate_ident: &CrateIdent, parsed: &ParsedNodeFn ValueBinding::Carrier => quote!(), ValueBinding::Materialized => { let fn_name = &parsed.fn_name; + let ty = &crate::codegen::classify::substitute_lifetimes(ty, "'_"); let cache_slot = format_ident!("__mat_cache_{index}"); let non_exact = fail(quote!(#core_types::gpoll::GraphError::new(::std::concat!("reduce over a non-exact extent in ", ::std::stringify!(#fn_name))))); let batch_error = fail(quote!(__error)); @@ -1479,6 +1521,7 @@ pub(crate) fn generate_node_impl(crate_ident: &CrateIdent, parsed: &ParsedNodeFn // span, as in eval), so a data-dependent extent can walk // its lanes. ValueBinding::Materialized => { + let ty = &crate::codegen::classify::substitute_lifetimes(ty, "'_"); quote! { let #query = || { let __arena = #core_types::context::ExtractArena::arena(__input); @@ -1875,6 +1918,7 @@ pub(crate) fn generate_node_impl(crate_ident: &CrateIdent, parsed: &ParsedNodeFn }; // A droppable element parks in the arena and rides as a reference. let element_store = element_write.map(|ty| { + let ty = &crate::codegen::classify::substitute_lifetimes(ty, "'_"); quote! { if unsafe { #core_types::record::write_element::<#ty>(__dst, __element, #core_types::context::ExtractArena::arena(__input)) }.is_none() { return #core_types::gpoll::Interrupt::from(#core_types::gpoll::GraphError { @@ -2044,6 +2088,13 @@ pub(crate) fn generate_node_impl(crate_ident: &CrateIdent, parsed: &ParsedNodeFn Tail::Forward if routing_generic.is_some() => Some(quote!(let __poll = #lift;)), _ => None, }; + // A serving-lifetime element rides the per-lane fill loop: the hoisted + // batch fill cannot yet carry an arena-lifetimed element through the + // caller's scratch. + let hoisted_lane_poll = match &node.output.shape.element { + ir::Element::Concrete(element) if crate::codegen::classify::named_serving_lifetime(element).is_some() => None, + _ => hoisted_lane_poll, + }; let hoisted_batch = parsed.attributes.batch.is_none() && produces_records && hoisted_lane_poll.is_some(); let batch_impl = match (&parsed.attributes.batch, produces_records, hoisted_lane_poll) { (Some(path), ..) => quote! { @@ -2256,22 +2307,22 @@ pub(crate) fn generate_node_impl(crate_ident: &CrateIdent, parsed: &ParsedNodeFn reading_secondary_indices(®ular_fields, skips_carrier) .into_iter() .filter_map(|index| match ®ular_fields[index].ty { - ParsedFieldType::Regular(RegularParsedField { ty, .. }) => Some(quote!(#ty: ::core::clone::Clone)), + ParsedFieldType::Regular(RegularParsedField { ty, .. }) => Some({ let ty = &crate::codegen::classify::substitute_lifetimes(ty, "'static"); quote!(#ty: ::core::clone::Clone) }), _ => None, }), ); if let Some(ty) = carrier_read_ty { - bounds.push(quote!(#ty: ::core::clone::Clone)); + bounds.push({ let ty = &crate::codegen::classify::substitute_lifetimes(ty, "'static"); quote!(#ty: ::core::clone::Clone) }); } // The element store parks droppable elements in the arena. if let Some(ty) = element_write { - bounds.push(quote!(#ty: ::core::marker::Send + ::core::marker::Sync + 'static)); + bounds.push({ let ty = &crate::codegen::classify::substitute_lifetimes(ty, "'static"); quote!(#ty: ::core::marker::Send + ::core::marker::Sync + 'static) }); } } // A routing node's value elements copy out of their records. if let Some(generic) = &routing_generic { bounds.extend(routing_value_indices(®ular_fields, generic).into_iter().filter_map(|index| match ®ular_fields[index].ty { - ParsedFieldType::Regular(RegularParsedField { ty, .. }) => Some(quote!(#ty: ::core::clone::Clone)), + ParsedFieldType::Regular(RegularParsedField { ty, .. }) => Some({ let ty = &crate::codegen::classify::substitute_lifetimes(ty, "'static"); quote!(#ty: ::core::clone::Clone) }), _ => None, })); } @@ -2281,7 +2332,7 @@ pub(crate) fn generate_node_impl(crate_ident: &CrateIdent, parsed: &ParsedNodeFn ParsedFieldType::Regular(RegularParsedField { ty, .. }) if !node.inputs[index].subject && matches!(ir::value_binding(&node, index), ValueBinding::Plain | ValueBinding::ReadingSecondary | ValueBinding::RecordElement) => { - Some(quote!(#ty: ::core::clone::Clone)) + Some({ let ty = &crate::codegen::classify::substitute_lifetimes(ty, "'static"); quote!(#ty: ::core::clone::Clone) }) } _ => None, })); @@ -2301,12 +2352,12 @@ pub(crate) fn generate_node_impl(crate_ident: &CrateIdent, parsed: &ParsedNodeFn .filter(|(index, _)| ir::materialized_levels(&node, *index) == 0) .filter_map(|(_, field)| match &field.ty { // The conditional arena-park moves a lend element once. - ParsedFieldType::Regular(RegularParsedField { ty, lend: Some(_), .. }) => Some(quote!(#ty: ::core::marker::Send + ::core::marker::Sync + 'static)), - ParsedFieldType::Regular(RegularParsedField { ty, .. }) => Some(quote!(#ty: ::core::clone::Clone)), - ParsedFieldType::Node(NodeParsedField { output_type, .. }) => Some(quote!(#output_type: ::core::clone::Clone)), + ParsedFieldType::Regular(RegularParsedField { ty, lend: Some(_), .. }) => Some({ let ty = &crate::codegen::classify::substitute_lifetimes(ty, "'static"); quote!(#ty: ::core::marker::Send + ::core::marker::Sync + 'static) }), + ParsedFieldType::Regular(RegularParsedField { ty, .. }) => Some({ let ty = &crate::codegen::classify::substitute_lifetimes(ty, "'static"); quote!(#ty: ::core::clone::Clone) }), + ParsedFieldType::Node(NodeParsedField { output_type, .. }) => Some({ let output_type = &crate::codegen::classify::substitute_lifetimes(output_type, "'static"); quote!(#output_type: ::core::clone::Clone) }), }) .collect(); - let out = slot_value_type(&parsed.output_type); + let out = crate::codegen::classify::substitute_lifetimes(&slot_value_type(&parsed.output_type), "'static"); bounds.push(quote!(#out: ::core::marker::Send + ::core::marker::Sync + 'static)); bounds } @@ -2363,7 +2414,10 @@ pub(crate) fn generate_node_impl(crate_ident: &CrateIdent, parsed: &ParsedNodeFn let flip_layout_meta_fn = flip_meta_concrete.then(|| { let layout_meta_fn = format_ident!("{}_layout_meta", fn_name); let element_spec = match element_write { - Some(ty) => quote!(#core_types::record::ElementSpec::Concrete({ use #core_types::record::{ElementWritePickHashed as _, ElementWritePickPlain as _}; (&#core_types::record::ElementWritePick::<#ty>(::core::marker::PhantomData)).element_write() })), + Some(ty) => { + let ty = &crate::codegen::classify::substitute_lifetimes(ty, "'static"); + quote!(#core_types::record::ElementSpec::Concrete({ use #core_types::record::{ElementWritePickHashed as _, ElementWritePickPlain as _}; (&#core_types::record::ElementWritePick::<#ty>(::core::marker::PhantomData)).element_write() })) + } None => quote!(#core_types::record::ElementSpec::Carried), }; let layout_meta = crate::codegen::ir::layout_meta_tokens(&node, element_spec, core_types); @@ -2392,7 +2446,10 @@ pub(crate) fn generate_node_impl(crate_ident: &CrateIdent, parsed: &ParsedNodeFn .collect(); let subtraction = (!remove_pairs.is_empty()).then(|| quote!(.without(&[#(#remove_pairs),*]))); let element = match element_write { - Some(ty) => quote!({ use #core_types::record::{ElementWritePickHashed as _, ElementWritePickPlain as _}; (&#core_types::record::ElementWritePick::<#ty>(::core::marker::PhantomData)).element_write() }), + Some(ty) => { + let ty = &crate::codegen::classify::substitute_lifetimes(ty, "'static"); + quote!({ use #core_types::record::{ElementWritePickHashed as _, ElementWritePickPlain as _}; (&#core_types::record::ElementWritePick::<#ty>(::core::marker::PhantomData)).element_write() }) + } None => quote!(__carrier.element), }; // A gather carrier's base is the gathered subject's layout, so its free @@ -2411,7 +2468,10 @@ pub(crate) fn generate_node_impl(crate_ident: &CrateIdent, parsed: &ParsedNodeFn }; let layout_meta_fn = format_ident!("{}_layout_meta", fn_name); let element_spec = match element_write { - Some(ty) => quote!(#core_types::record::ElementSpec::Concrete({ use #core_types::record::{ElementWritePickHashed as _, ElementWritePickPlain as _}; (&#core_types::record::ElementWritePick::<#ty>(::core::marker::PhantomData)).element_write() })), + Some(ty) => { + let ty = &crate::codegen::classify::substitute_lifetimes(ty, "'static"); + quote!(#core_types::record::ElementSpec::Concrete({ use #core_types::record::{ElementWritePickHashed as _, ElementWritePickPlain as _}; (&#core_types::record::ElementWritePick::<#ty>(::core::marker::PhantomData)).element_write() })) + } None => quote!(#core_types::record::ElementSpec::Carried), }; let layout_meta = crate::codegen::ir::layout_meta_tokens(&node, element_spec, core_types); diff --git a/node-graph/node-macro/src/codegen/classify.rs b/node-graph/node-macro/src/codegen/classify.rs index e231a02a44..e2d9f55f8a 100644 --- a/node-graph/node-macro/src/codegen/classify.rs +++ b/node-graph/node-macro/src/codegen/classify.rs @@ -425,7 +425,7 @@ pub(crate) fn has_materialized_input(parsed: &ParsedNodeFn) -> bool { } pub(crate) fn record_flip(parsed: &ParsedNodeFn) -> bool { - if record_shape(parsed).is_some() || has_record_io(parsed) || routing_io(parsed).is_some() { + if record_shape(parsed).is_some() || has_record_io(parsed) || routing_io(parsed).is_some() || record_opaque(parsed) { return false; } // Shader nodes flip like any value node: the kernel doubles as the @@ -457,7 +457,10 @@ pub(crate) fn record_flip(parsed: &ParsedNodeFn) -> bool { return false; } } - GenericParam::Lifetime(_) | GenericParam::Const(_) => return false, + // A named lifetime is the serving lifetime: wire types substitute + // its erased projection in registry and layout contexts. + GenericParam::Lifetime(_) => {} + GenericParam::Const(_) => return false, } } true @@ -683,10 +686,6 @@ pub(crate) fn type_disqualifies(ty: &Type) -> bool { fn visit_type_impl_trait(&mut self, _: &'ast syn::TypeImplTrait) { self.found = true; } - - fn visit_lifetime(&mut self, _: &'ast Lifetime) { - self.found = true; - } } let mut visitor = Disqualifier { found: false }; @@ -694,7 +693,91 @@ pub(crate) fn type_disqualifies(ty: &Type) -> bool { visitor.found } +/// The wire type with every named serving lifetime replaced: `'static` for +/// registry, layout, and declaration contexts (the erased projection shares +/// its type id and layout), `'_` for eval bindings, where inference recovers +/// the serving lifetime. +pub(crate) fn substitute_lifetimes(ty: &Type, replacement: &str) -> Type { + struct Subst { + replacement: &'static str, + } + + impl VisitMut for Subst { + fn visit_lifetime_mut(&mut self, lifetime: &mut Lifetime) { + if lifetime.ident != "static" { + *lifetime = Lifetime::new(self.replacement, lifetime.span()); + } + } + } + + let replacement = match replacement { + "'static" => "'static", + _ => "'_", + }; + let mut ty = ty.clone(); + Subst { replacement }.visit_type_mut(&mut ty); + ty +} + +/// The serving lifetime a wire type names, so a materialized binding can tie +/// the list view to the element's own region. +pub(crate) fn named_serving_lifetime(ty: &Type) -> Option { + struct Find { + found: Option, + } + + impl<'ast> Visit<'ast> for Find { + fn visit_lifetime(&mut self, lifetime: &'ast Lifetime) { + if self.found.is_none() && lifetime.ident != "static" { + self.found = Some(lifetime.clone()); + } + } + } + + let mut visitor = Find { found: None }; + visitor.visit_type(ty); + visitor.found +} + pub(crate) fn desugar_extract_lifetime(bound: &TypeParamBound, core_types: &TokenStream2) -> TokenStream2 { + desugar_extract_lifetime_at(bound, core_types, None) +} + +/// Renames every occurrence of the named lifetimes to `'__record` in a token +/// stream: a flipped kernel's serving lifetime is the record lifetime at the +/// impl, under whichever name the author picked. +pub(crate) fn rename_lifetimes_to_record(stream: TokenStream2, names: &[String]) -> TokenStream2 { + use proc_macro2::{Group, TokenTree}; + let mut out = Vec::new(); + let mut tokens = stream.into_iter().peekable(); + while let Some(token) = tokens.next() { + match token { + TokenTree::Group(group) => { + let renamed = rename_lifetimes_to_record(group.stream(), names); + let mut fresh = Group::new(group.delimiter(), renamed); + fresh.set_span(group.span()); + out.push(TokenTree::Group(fresh)); + } + TokenTree::Punct(punct) if punct.as_char() == '\'' => { + match tokens.peek() { + Some(TokenTree::Ident(ident)) if names.iter().any(|name| ident == name) => { + let span = ident.span(); + tokens.next(); + out.push(TokenTree::Punct(punct)); + out.push(TokenTree::Ident(proc_macro2::Ident::new("__record", span))); + } + _ => out.push(TokenTree::Punct(punct)), + } + } + token => out.push(token), + } + } + out.into_iter().collect() +} + +/// As [`desugar_extract_lifetime`], with the arena lifetime overridden: a +/// flipped kernel's serving lifetime is the record lifetime at the impl. +pub(crate) fn desugar_extract_lifetime_at(bound: &TypeParamBound, core_types: &TokenStream2, at: Option) -> TokenStream2 { let TypeParamBound::Trait(trait_bound) = bound else { return quote!(#bound); }; @@ -713,5 +796,18 @@ pub(crate) fn desugar_extract_lifetime(bound: &TypeParamBound, core_types: &Toke let Some(GenericArgument::Lifetime(lifetime)) = args.args.first() else { return quote!(#bound); }; + let lifetime = at.unwrap_or_else(|| quote!(#lifetime)); quote!(#core_types::context::ExtractArena) } + +#[cfg(test)] +mod lifetime_subst_tests { + use super::*; + + #[test] + fn named_lifetimes_erase_to_static() { + let ty: Type = syn::parse_quote!(Graphic<'e>); + let erased = substitute_lifetimes(&ty, "'static"); + assert_eq!(quote::quote!(#erased).to_string(), "Graphic < 'static >"); + } +} diff --git a/node-graph/node-macro/src/codegen/entries.rs b/node-graph/node-macro/src/codegen/entries.rs index 1825c97285..5a30613e19 100644 --- a/node-graph/node-macro/src/codegen/entries.rs +++ b/node-graph/node-macro/src/codegen/entries.rs @@ -24,7 +24,7 @@ fn flip_entries_tokens(parsed: &ParsedNodeFn, struct_name: &Ident, regular_field if rows.is_empty() { return quote!(); } - let output = slot_value_type(&parsed.output_type); + let output = substitute_lifetimes(&slot_value_type(&parsed.output_type), "'static"); let field_type = |field: &ParsedField| match &field.ty { ParsedFieldType::Regular(RegularParsedField { ty, .. }) => ty.clone(), @@ -111,6 +111,8 @@ fn flip_entries_tokens(parsed: &ParsedNodeFn, struct_name: &Ident, regular_field .iter() .map(|(generic, index)| generic_assignment(&field_type(regular_fields[*index]), &row[*index], generic).map(|assigned| (generic.clone(), assigned))) .collect::>()?; + let row: Vec = row.iter().map(|ty| substitute_lifetimes(ty, "'static")).collect(); + let assignments: Vec<(Ident, Type)> = assignments.into_iter().map(|(generic, ty)| (generic, substitute_lifetimes(&ty, "'static"))).collect(); if type_disqualifies(&substitute_ident_types(&output, &assignments)) { return None; } @@ -313,12 +315,12 @@ fn single_row_entries(parsed: &ParsedNodeFn, struct_name: &Ident, regular_fields .iter() .map(|slot| match slot { SlotKind::BaseGeneric(name) => SlotKind::BaseGeneric(name.clone()), - SlotKind::BaseConcrete(ty) => SlotKind::BaseConcrete(substitute_ident_types(ty, assignments)), - SlotKind::Value(ty) => SlotKind::Value(substitute_ident_types(ty, assignments)), - SlotKind::Extracted(ty) => SlotKind::Extracted(substitute_ident_types(ty, assignments)), - SlotKind::Ranked(ty) => SlotKind::Ranked(substitute_ident_types(ty, assignments)), - SlotKind::Plain(ty) => SlotKind::Plain(substitute_ident_types(ty, assignments)), - SlotKind::Lazy(ty) => SlotKind::Lazy(substitute_ident_types(ty, assignments)), + SlotKind::BaseConcrete(ty) => SlotKind::BaseConcrete(substitute_lifetimes(&substitute_ident_types(ty, assignments), "'static")), + SlotKind::Value(ty) => SlotKind::Value(substitute_lifetimes(&substitute_ident_types(ty, assignments), "'static")), + SlotKind::Extracted(ty) => SlotKind::Extracted(substitute_lifetimes(&substitute_ident_types(ty, assignments), "'static")), + SlotKind::Ranked(ty) => SlotKind::Ranked(substitute_lifetimes(&substitute_ident_types(ty, assignments), "'static")), + SlotKind::Plain(ty) => SlotKind::Plain(substitute_lifetimes(&substitute_ident_types(ty, assignments), "'static")), + SlotKind::Lazy(ty) => SlotKind::Lazy(substitute_lifetimes(&substitute_ident_types(ty, assignments), "'static")), }) .collect(); @@ -390,6 +392,7 @@ fn single_row_entries(parsed: &ParsedNodeFn, struct_name: &Ident, regular_fields ir::Element::Generic(ident) => assignments.iter().find(|(generic, _)| generic == ident).map(|(_, ty)| ty.clone()), ir::Element::Opaque => None, }; + let output_element = output_element.map(|element| substitute_lifetimes(&element, "'static")); let (io_output, wrap) = match &output_element { Some(element) => ( quote!(gcore::registry::record_type::<#element>()), diff --git a/node-graph/node-macro/src/codegen/metadata.rs b/node-graph/node-macro/src/codegen/metadata.rs index 01e331b6af..5e2396e2c8 100644 --- a/node-graph/node-macro/src/codegen/metadata.rs +++ b/node-graph/node-macro/src/codegen/metadata.rs @@ -21,6 +21,7 @@ pub(crate) fn generate_node_input_references( ParsedFieldType::Regular(RegularParsedField { ty, .. }) => ty.clone(), ParsedFieldType::Node(NodeParsedField { output_type, .. }) => crate::codegen::ir::strip_ilist(output_type).0, }; + let mut ty = crate::codegen::classify::substitute_lifetimes(&ty, "'static"); // We only want the necessary generics. let used = generic_collector.filter_unnecessary_generics(&mut modified, &mut ty);