From 1293f2406054038d7085468f8dacf16e3d34aee7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pato=20Sanda=C3=B1a?= <1194304+psandana@users.noreply.github.com> Date: Mon, 7 Sep 2026 22:34:30 -0300 Subject: [PATCH 01/10] fix(thread_aware_macros): express derive bounds on the relocated field type `#[derive(ThreadAware)]` inferred its bounds by collecting every generic parameter a relocated field's type reaches and bounding each by `ThreadAware`. That predicate is on the parameter, not on the obligation the generated body has: the body relocates the whole field, so what it needs is `: ThreadAware`. Bounding the parameter is too strong for a wrapper that implements `ThreadAware` unconditionally. `Outer(Wrapper>)` with an unconditional `impl ThreadAware for Wrapper` was rejected for `Outer>`, because the derive emitted `T: ThreadAware` (which `Rc<()>` cannot meet) rather than `Wrapper>: ThreadAware` (which the wrapper's own impl satisfies for every `T`). `add_bounds` now emits, for each relocated field whose type reaches a generic parameter, a `where : ThreadAware` predicate - the exact obligation the body discharges - replacing the per-parameter predicates. The traversal shape-set still decides which fields reach a parameter, so a marker payload behind a function pointer (`PhantomData`) owes no bound and the raw-pointer variance idiom stays bound-free. The `is_same_trait` de-duplication survives, so a field that is exactly a parameter the author already bounded by `ThreadAware` emits nothing. - Adds a compile regression for the unconditional-wrapper case. - Updates the derive's "Generic Bounds" documentation to the field-type model. - Rewrites the 16 affected macro snapshots; derive_compiles.rs passes unchanged. Refs AB#7783612. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- crates/thread_aware/src/lib.rs | 22 +- crates/thread_aware/tests/derive_compiles.rs | 28 +++ crates/thread_aware_macros_impl/src/lib.rs | 219 ++++++++++++------ .../thread_aware_macros_impl/tests/derive.rs | 32 +-- .../derive__enum_named_phantom_data.snap | 9 +- .../derive__enum_unnamed_phantom_data.snap | 9 +- .../derive__generics_add_bounds.snap | 7 +- ...rive__generics_array_usage_adds_bound.snap | 5 +- ...rive__generics_group_usage_adds_bound.snap | 5 +- ...s_lifetime_and_const_params_untouched.snap | 9 +- .../derive__generics_paren_adds_bound.snap | 6 +- ...derive__generics_ref_usage_adds_bound.snap | 5 +- ...rive__generics_tuple_usage_adds_bound.snap | 5 +- ...ed_field_means_no_self_send_predicate.snap | 5 +- .../derive__phantom_data_named_fields.snap | 5 +- .../derive__phantom_data_unnamed_fields.snap | 6 +- ..._only_generic_gets_thread_aware_bound.snap | 7 +- ...ed_and_phantom_param_shares_one_bound.snap | 6 +- ...ead_aware_does_not_suppress_the_bound.snap | 6 +- ...erive__user_where_clause_is_preserved.snap | 7 +- 20 files changed, 266 insertions(+), 137 deletions(-) diff --git a/crates/thread_aware/src/lib.rs b/crates/thread_aware/src/lib.rs index 682a33183..09cb71d70 100644 --- a/crates/thread_aware/src/lib.rs +++ b/crates/thread_aware/src/lib.rs @@ -161,18 +161,22 @@ mod thread; /// * `#[thread_aware(skip)]`: Prevents a field from being recursively transferred. /// /// # Generic Bounds -/// Every field except a `#[thread_aware(skip)]` one is relocated, and the bounds follow from -/// that: -/// * a generic type parameter the traversal reaches through a relocated field receives a -/// `::thread_aware::ThreadAware` bound; +/// Every field except a `#[thread_aware(skip)]` one is relocated by calling its own +/// `ThreadAware::relocate`, and the bounds follow from that: +/// * each relocated field whose type reaches a generic parameter contributes a +/// `where : ::thread_aware::ThreadAware` predicate - the obligation the generated +/// body actually has, discharged by that field type's own impl; /// * if any field carries `#[thread_aware(skip)]`, a `where Self: Send` predicate is added, /// since the `ThreadAware: Send` supertrait still has to hold. /// -/// A `PhantomData<..>` field is no exception: it relocates through the no-op -/// `impl ThreadAware for PhantomData`, and a parameter the traversal -/// reaches inside it - the `U` of `PhantomData` - is bound like any other. The traversal -/// does not enter a function pointer, so writing the payload as one carries the parameter for -/// variance, stays `Send` for every argument, and emits no bound at all: +/// The predicate lands on the field type as written, not on the parameters inside it. A +/// `PhantomData` field yields `where PhantomData: ThreadAware` (which reduces to `U: Send` +/// through the no-op `impl ThreadAware for PhantomData where Self: Send`), and a +/// wrapper `W` yields `where W: ThreadAware`, governed by `W`'s own impl rather than by a +/// bound on `U` - so a wrapper that implements the trait unconditionally keeps deriving for +/// arguments no bound on `U` would admit. The traversal does not enter a function pointer, so +/// writing a marker payload as one carries the parameter for variance, stays `Send` for every +/// argument, and emits no bound at all: /// /// ```rust /// # use core::marker::PhantomData; diff --git a/crates/thread_aware/tests/derive_compiles.rs b/crates/thread_aware/tests/derive_compiles.rs index 168f309a1..e7dd56f0f 100644 --- a/crates/thread_aware/tests/derive_compiles.rs +++ b/crates/thread_aware/tests/derive_compiles.rs @@ -638,6 +638,34 @@ fn payloads_invisible_to_a_syntactic_scan_need_no_escape_hatch() { assert_eq!(hidden.tracked.relocations, 1); } +// A field whose type implements `ThreadAware` unconditionally must keep deriving for parameters +// that type ignores. Bounding a parameter reached inside the field - rather than the field type +// itself - was a compile regression against the merge base of PR #678, which rejected +// `OuterWrapper>` even though the wrapper's own impl asks nothing of the parameter. See +// AB#7783612. + +/// A wrapper that is `ThreadAware` for every `T`, delegating to nothing. +struct UnconditionalWrapper(PhantomData T>); + +impl thread_aware::ThreadAware for UnconditionalWrapper { + fn relocate(&mut self, _source: Option<&Thread>, _destination: &Thread) {} +} + +/// The derive owes `UnconditionalWrapper>: ThreadAware`, which that impl satisfies +/// for every `T` - not `T: ThreadAware`, which `Rc<()>` cannot meet. +#[derive(ThreadAware)] +struct OuterWrapper(UnconditionalWrapper>); + +#[test] +fn field_type_bound_lets_an_unconditional_wrapper_derive_for_any_argument() { + // `Rc<()>` is neither `Send` nor `ThreadAware`; the field-type bound still holds. + assert_thread_aware::>>(); + + let (source, destination) = thread_pair(); + let mut value = OuterWrapper::>(UnconditionalWrapper(PhantomData)); + value.relocate(source.as_ref(), &destination); +} + /// A trait of the user's own that happens to be called `ThreadAware`, named by a qualified /// path. /// diff --git a/crates/thread_aware_macros_impl/src/lib.rs b/crates/thread_aware_macros_impl/src/lib.rs index 892cb72fc..957aa9a0b 100644 --- a/crates/thread_aware_macros_impl/src/lib.rs +++ b/crates/thread_aware_macros_impl/src/lib.rs @@ -20,7 +20,7 @@ use std::collections::HashSet; use proc_macro2::TokenStream as TokenStream2; -use quote::quote; +use quote::{ToTokens, quote}; use syn::{Data, DeriveInput, Fields, GenericParam, Path, PathArguments, Type, TypePath, parse_quote}; mod enum_gen; @@ -104,33 +104,64 @@ pub(crate) fn param_idents() -> (syn::Ident, syn::Ident) { fn add_bounds(input: &DeriveInput, root_path: &Path) -> syn::Result { let mut generics = input.generics.clone(); - let mut usage = GenericUsage::default(); - match &input.data { - Data::Struct(s) => collect_generics_in_fields(&s.fields, &generics, &mut usage)?, - Data::Enum(e) => { - for v in &e.variants { - collect_generics_in_fields(&v.fields, &generics, &mut usage)?; - } - } - Data::Union(_) => {} - } + + let generic_idents: HashSet = generics + .params + .iter() + .filter_map(|gp| match gp { + GenericParam::Type(t) => Some(t.ident.clone()), + _ => None, + }) + .collect(); let mut thread_aware_path = root_path.clone(); thread_aware_path.segments.push(parse_quote!(ThreadAware)); - for param in &mut generics.params { - let GenericParam::Type(ty_param) = param else { + // Gather the relocated (non-skipped) field types in declaration order, and note whether any + // field is skipped. + let mut relocated_fields: Vec = Vec::new(); + let mut has_skipped_field = false; + collect_relocated_fields(&input.data, &mut relocated_fields, &mut has_skipped_field)?; + + // The generated body relocates each field by calling `::relocate`, so the impl + // owes `: ThreadAware` for every field whose relocation depends on a generic + // parameter. Bounding the field type - rather than the parameters inside it - lets a type + // with an unconditional impl, such as a wrapper that ignores its parameter, satisfy the + // predicate for arguments no per-parameter bound could admit. A field type that reaches no + // parameter is `ThreadAware` (or not) at the definition site and needs no predicate. + let mut emitted_keys: Vec = Vec::new(); + let mut predicates: Vec = Vec::new(); + for field_ty in &relocated_fields { + if !type_reaches_param(field_ty, &generic_idents) { continue; - }; - - if usage.relocated.contains(&ty_param.ident) { - let already = ty_param - .bounds - .iter() - .any(|b| matches!(b, syn::TypeParamBound::Trait(t) if is_same_trait(&t.path, &thread_aware_path))); - if !already { - ty_param.bounds.push(parse_quote!(#thread_aware_path)); - } + } + + let bound_ty = strip_group_paren(field_ty); + + // Two fields of the same type owe a single predicate; repeating it trips + // `clippy::trait_duplication_in_bounds`. + let key = bound_ty.to_token_stream().to_string(); + if emitted_keys.iter().any(|seen| seen == &key) { + continue; + } + emitted_keys.push(key); + + // A field that is exactly a parameter the author already bounded by `ThreadAware` needs + // no generated predicate: emitting one would duplicate the author's own bound and trip + // `clippy::trait_duplication_in_bounds` at their declaration. + if let Some(param) = as_bare_param(bound_ty, &generic_idents) + && param_has_thread_aware_bound(&generics, param, &thread_aware_path) + { + continue; + } + + predicates.push(parse_quote!(#bound_ty: #thread_aware_path)); + } + + if !predicates.is_empty() { + let where_clause = generics.make_where_clause(); + for predicate in predicates { + where_clause.predicates.push(predicate); } } @@ -142,7 +173,7 @@ fn add_bounds(input: &DeriveInput, root_path: &Path) -> syn::Result bool { candidate_idents == emitted_idents || candidate_idents == ["ThreadAware"] } -/// How the fields of a type contribute to the bounds of the generated impl. -#[derive(Default)] -struct GenericUsage { - /// Type parameters the traversal reaches through a relocated field; each is bound by - /// `ThreadAware`. - relocated: HashSet, - - /// Whether any field carries `#[thread_aware(skip)]`, which is what makes the - /// `Self: Send` predicate necessary. - has_skipped_field: bool, +/// Collects the relocated (non-skipped) field types of a struct or enum, in declaration order, +/// and records whether any field carries `#[thread_aware(skip)]`. +/// +/// Mirrors exactly what the body generators relocate: a skipped field is absent from the +/// generated body, so it owes no `ThreadAware` predicate - only the `Self: Send` one. Keeping +/// this in step with `struct_gen`/`enum_gen` is what stops the header and the body disagreeing +/// about which fields are relocated. +fn collect_relocated_fields(data: &Data, out: &mut Vec, has_skipped_field: &mut bool) -> syn::Result<()> { + match data { + Data::Struct(s) => collect_relocated_from_fields(&s.fields, out, has_skipped_field)?, + Data::Enum(e) => { + for v in &e.variants { + collect_relocated_from_fields(&v.fields, out, has_skipped_field)?; + } + } + Data::Union(_) => {} + } + Ok(()) } -#[cfg_attr(coverage_nightly, coverage(off))] // can't figure out how to get to 100% coverage of this function -fn collect_generics_in_fields(fields: &Fields, generics: &syn::Generics, usage: &mut GenericUsage) -> syn::Result<()> { - let generic_idents: HashSet<_> = generics - .params - .iter() - .filter_map(|gp| match gp { - syn::GenericParam::Type(t) => Some(t.ident.clone()), - _ => None, - }) - .collect(); +fn collect_relocated_from_fields(fields: &Fields, out: &mut Vec, has_skipped_field: &mut bool) -> syn::Result<()> { for field in fields { - // Mirror exactly what the body generators skip. A skipped field is absent from the - // generated body, so it needs no `ThreadAware` bound; the `Self: Send` predicate - // covers it instead. Keeping this test identical to the one in `struct_gen`/`enum_gen` - // is what stops the header and the body disagreeing about which fields are relocated. if parse_field_attrs(&field.attrs)?.skip { - usage.has_skipped_field = true; + *has_skipped_field = true; continue; } - collect_generics_in_type(&field.ty, &generic_idents, usage)?; + out.push(field.ty.clone()); } Ok(()) } +/// Strips any outer `Type::Group` and `Type::Paren` layers. +/// +/// A `Type::Group` is the invisible wrapper macro expansion leaves around a captured type; a +/// `Type::Paren` is an explicit `(T)`. Neither changes the type, so the emitted predicate reads +/// more naturally written on what they wrap. +fn strip_group_paren(ty: &Type) -> &Type { + let mut current = ty; + loop { + match current { + Type::Group(g) => current = &g.elem, + Type::Paren(p) => current = &p.elem, + other => return other, + } + } +} + +/// Returns the parameter a field type names directly, when the field type is exactly one of the +/// generic parameters. The caller has already stripped any `Group`/`Paren` layers. +fn as_bare_param<'a>(ty: &'a Type, generic_idents: &HashSet) -> Option<&'a syn::Ident> { + if let Type::Path(TypePath { qself: None, path, .. }) = ty + && let Some(ident) = path.get_ident() + && generic_idents.contains(ident) + { + return Some(ident); + } + None +} + +/// Reports whether the parameter's own declaration already carries a `ThreadAware` bound. +/// +/// Only the inline bounds on the parameter are inspected - where an author most naturally writes +/// such a bound, and the case the snapshots pin. A bound expressed in a `where` clause is left to +/// the author's judgment, exactly as it was before the derive emitted field-type predicates. +fn param_has_thread_aware_bound(generics: &syn::Generics, ident: &syn::Ident, thread_aware_path: &Path) -> bool { + generics.params.iter().any(|param| { + matches!(param, GenericParam::Type(ty_param) + if &ty_param.ident == ident + && ty_param + .bounds + .iter() + .any(|b| matches!(b, syn::TypeParamBound::Trait(t) if is_same_trait(&t.path, thread_aware_path)))) + }) +} + +/// Reports whether `ty` reaches a generic parameter through a shape the generated body relocates +/// through: a path's type arguments, a reference, a tuple, an array, or a `Group`/`Paren` wrapper. +/// +/// When it does, the field's `ThreadAware`-ness depends on that parameter and the impl owes +/// `: ThreadAware`. The shapes deliberately left out - `Slice`, `Ptr`, `BareFn`, +/// `TraitObject`, `ImplTrait` - are the ones a parameter cannot make the field conditionally +/// `ThreadAware` through: a safe `fn` pointer implements `ThreadAware` unconditionally, so a +/// parameter carried only for variance inside one (`PhantomData`) owes no bound, and +/// the rest have no impl at all. This keeps the marker-payload idiom bound-free, exactly as the +/// per-parameter collector did before. #[cfg_attr(coverage_nightly, coverage(off))] // can't figure out how to get to 100% coverage of this function -fn collect_generics_in_type(ty: &Type, generic_idents: &HashSet, acc: &mut GenericUsage) -> syn::Result<()> { +fn type_reaches_param(ty: &Type, generic_idents: &HashSet) -> bool { match ty { Type::Path(TypePath { path, .. }) => { for segment in &path.segments { if generic_idents.contains(&segment.ident) { - acc.relocated.insert(segment.ident.clone()); + return true; } if let PathArguments::AngleBracketed(ab) = &segment.arguments { for arg in &ab.args { - if let syn::GenericArgument::Type(t) = arg { - collect_generics_in_type(t, generic_idents, acc)?; + if let syn::GenericArgument::Type(t) = arg + && type_reaches_param(t, generic_idents) + { + return true; } } } } + false } - Type::Reference(r) => collect_generics_in_type(&r.elem, generic_idents, acc)?, - Type::Tuple(t) => { - for elem in &t.elems { - collect_generics_in_type(elem, generic_idents, acc)?; - } - } - Type::Array(a) => collect_generics_in_type(&a.elem, generic_idents, acc)?, - Type::Group(g) => collect_generics_in_type(&g.elem, generic_idents, acc)?, - Type::Paren(p) => collect_generics_in_type(&p.elem, generic_idents, acc)?, - // Not traversed: `Type::Slice`, `Type::Ptr`, `Type::BareFn`, `Type::TraitObject` and - // `Type::ImplTrait`. A bare `fn` pointer has `ThreadAware` impls in `impls.rs`, but - // they are unconditional - no bound on the argument or return types - so descending - // would emit a bound nothing requires. The rest have no impl, so an enclosing field - // cannot be relocated through one and no bound is owed. - // - // This list is not a mirror of `impls.rs` and should not be read as one: `Array` and - // `Reference` are traversed here although `impls.rs` implements neither, so those emit - // a bound for a field that cannot be relocated at all. The maintenance rule runs one - // way only - adding a *conditional* impl in `impls.rs` for any shape listed above - // means adding the matching arm here, or the header will under-constrain the body. - _ => {} + Type::Reference(r) => type_reaches_param(&r.elem, generic_idents), + Type::Tuple(t) => t.elems.iter().any(|elem| type_reaches_param(elem, generic_idents)), + Type::Array(a) => type_reaches_param(&a.elem, generic_idents), + Type::Group(g) => type_reaches_param(&g.elem, generic_idents), + Type::Paren(p) => type_reaches_param(&p.elem, generic_idents), + _ => false, } - Ok(()) } diff --git a/crates/thread_aware_macros_impl/tests/derive.rs b/crates/thread_aware_macros_impl/tests/derive.rs index 9f89d1cd7..a9a2e1f8f 100644 --- a/crates/thread_aware_macros_impl/tests/derive.rs +++ b/crates/thread_aware_macros_impl/tests/derive.rs @@ -54,8 +54,9 @@ fn tuple_struct_and_enum() { #[test] #[cfg_attr(miri, ignore)] fn generics_add_bounds() { - // Both parameters gain a ThreadAware bound: the traversal reaches U through the marker's - // type argument exactly as it reaches T directly. + // Each relocated field owes a bound on its own type: the `T` field a bound on `T`, and the + // `PhantomData` field a bound on `PhantomData` (which reduces to `U: Send`), rather than + // a bound on `U` itself. let input = quote! { #[derive(ThreadAware)] struct Gen(T, core::marker::PhantomData); @@ -128,8 +129,8 @@ fn error_unknown_attr() { #[test] #[cfg_attr(miri, ignore)] fn phantom_data_named_fields() { - // PhantomData in named fields is relocated through its own no-op impl, and the parameter - // inside it takes the ordinary bound. + // The `PhantomData` field is relocated through its own no-op impl and owes + // `PhantomData: ThreadAware` (which reduces to `T: Send`). let input = quote! { #[derive(ThreadAware)] struct WithPhantom { @@ -143,8 +144,8 @@ fn phantom_data_named_fields() { #[test] #[cfg_attr(miri, ignore)] fn phantom_data_unnamed_fields() { - // PhantomData in tuple fields is relocated through its own no-op impl, and the parameter - // inside it takes the ordinary bound. + // The `PhantomData` field is relocated through its own no-op impl and owes + // `PhantomData: ThreadAware` (which reduces to `T: Send`). let input = quote! { #[derive(ThreadAware)] struct TupleWithPhantom(Vec, core::marker::PhantomData); @@ -262,8 +263,9 @@ fn generics_paren_adds_bound() { #[test] #[cfg_attr(miri, ignore)] fn phantom_only_generic_gets_thread_aware_bound() { - // A parameter named only inside `PhantomData` takes the ordinary bound, like one reached - // anywhere else. Without it the impl cannot satisfy the `ThreadAware: Send` supertrait. + // The `PhantomData` field owes `PhantomData: ThreadAware`, which reduces to `U: Send` - + // the exact obligation the generated body needs, and enough for the `ThreadAware: Send` + // supertrait. let input = quote! { #[derive(ThreadAware)] struct DirectPhantom(T, core::marker::PhantomData); @@ -302,8 +304,9 @@ fn skipped_generic_field_gets_self_send_predicate() { #[test] #[cfg_attr(miri, ignore)] fn relocated_and_phantom_param_shares_one_bound() { - // A parameter reached both directly and through a marker's type argument takes a single - // `ThreadAware` bound - the two traversal paths converge on the same parameter. + // The direct `T` field owes `T: ThreadAware`; the `PhantomData` marker reaches the + // parameter only through a function pointer, which is `ThreadAware` unconditionally, so it owes + // no bound. The parameter is bound exactly once. let input = quote! { #[derive(ThreadAware)] struct RelocatedAndPhantom<'a, T: 'a>(T, core::marker::PhantomData); @@ -338,7 +341,7 @@ fn prebound_bare_thread_aware_assumed_real() { #[test] #[cfg_attr(miri, ignore)] fn no_skipped_field_means_no_self_send_predicate() { - // Every field is relocated, so `Self: Send` follows from the per-parameter bounds. + // Every field is relocated, so `Self: Send` follows from the field-type bounds. let input = quote! { #[derive(ThreadAware)] struct AllRelocated(T, U); @@ -367,9 +370,10 @@ fn user_where_clause_is_preserved() { #[test] #[cfg_attr(miri, ignore)] fn generics_lifetime_and_const_params_untouched() { - // Only type parameters can carry bounds; lifetimes and const generics are skipped. - // The field shape is one the crate can actually relocate, so the pinned expansion is one - // that compiles - a snapshot of an uncompilable expansion proves nothing. + // The generated predicate lands on the field type, leaving the lifetime and const parameters + // untouched in the impl header. The field shape is one the crate can actually relocate, so the + // pinned expansion is one that compiles - a snapshot of an uncompilable expansion proves + // nothing. let input = quote! { #[derive(ThreadAware)] struct Mixed<'a, const N: usize, T: Sync>(Tracker, core::marker::PhantomData<(&'a T, [u8; N])>); diff --git a/crates/thread_aware_macros_impl/tests/snapshots/derive__enum_named_phantom_data.snap b/crates/thread_aware_macros_impl/tests/snapshots/derive__enum_named_phantom_data.snap index 5e0d5bc06..fe9fb07dd 100644 --- a/crates/thread_aware_macros_impl/tests/snapshots/derive__enum_named_phantom_data.snap +++ b/crates/thread_aware_macros_impl/tests/snapshots/derive__enum_named_phantom_data.snap @@ -2,10 +2,11 @@ source: crates/thread_aware_macros_impl/tests/derive.rs expression: expand(input) --- -impl< - T: ::thread_aware::ThreadAware, - U: ::thread_aware::ThreadAware, -> ::thread_aware::ThreadAware for EnumNamedPhantom { +impl ::thread_aware::ThreadAware for EnumNamedPhantom +where + core::marker::PhantomData: ::thread_aware::ThreadAware, + core::marker::PhantomData: ::thread_aware::ThreadAware, +{ fn relocate( &mut self, __thread_aware_source: ::core::option::Option<&::thread_aware::Thread>, diff --git a/crates/thread_aware_macros_impl/tests/snapshots/derive__enum_unnamed_phantom_data.snap b/crates/thread_aware_macros_impl/tests/snapshots/derive__enum_unnamed_phantom_data.snap index 637f1f91b..faaddf65c 100644 --- a/crates/thread_aware_macros_impl/tests/snapshots/derive__enum_unnamed_phantom_data.snap +++ b/crates/thread_aware_macros_impl/tests/snapshots/derive__enum_unnamed_phantom_data.snap @@ -2,10 +2,11 @@ source: crates/thread_aware_macros_impl/tests/derive.rs expression: expand(input) --- -impl< - T: ::thread_aware::ThreadAware, - U: ::thread_aware::ThreadAware, -> ::thread_aware::ThreadAware for EnumUnnamedPhantom { +impl ::thread_aware::ThreadAware for EnumUnnamedPhantom +where + core::marker::PhantomData: ::thread_aware::ThreadAware, + core::marker::PhantomData: ::thread_aware::ThreadAware, +{ fn relocate( &mut self, __thread_aware_source: ::core::option::Option<&::thread_aware::Thread>, diff --git a/crates/thread_aware_macros_impl/tests/snapshots/derive__generics_add_bounds.snap b/crates/thread_aware_macros_impl/tests/snapshots/derive__generics_add_bounds.snap index b95bc540f..a7e20f97a 100644 --- a/crates/thread_aware_macros_impl/tests/snapshots/derive__generics_add_bounds.snap +++ b/crates/thread_aware_macros_impl/tests/snapshots/derive__generics_add_bounds.snap @@ -2,10 +2,11 @@ source: crates/thread_aware_macros_impl/tests/derive.rs expression: expand(input) --- -impl< +impl ::thread_aware::ThreadAware for Gen +where T: ::thread_aware::ThreadAware, - U: ::thread_aware::ThreadAware, -> ::thread_aware::ThreadAware for Gen { + core::marker::PhantomData: ::thread_aware::ThreadAware, +{ fn relocate( &mut self, __thread_aware_source: ::core::option::Option<&::thread_aware::Thread>, diff --git a/crates/thread_aware_macros_impl/tests/snapshots/derive__generics_array_usage_adds_bound.snap b/crates/thread_aware_macros_impl/tests/snapshots/derive__generics_array_usage_adds_bound.snap index 8a53c4510..9a8e601de 100644 --- a/crates/thread_aware_macros_impl/tests/snapshots/derive__generics_array_usage_adds_bound.snap +++ b/crates/thread_aware_macros_impl/tests/snapshots/derive__generics_array_usage_adds_bound.snap @@ -2,7 +2,10 @@ source: crates/thread_aware_macros_impl/tests/derive.rs expression: expand(input) --- -impl ::thread_aware::ThreadAware for ArrUse { +impl ::thread_aware::ThreadAware for ArrUse +where + [T; 2]: ::thread_aware::ThreadAware, +{ fn relocate( &mut self, __thread_aware_source: ::core::option::Option<&::thread_aware::Thread>, diff --git a/crates/thread_aware_macros_impl/tests/snapshots/derive__generics_group_usage_adds_bound.snap b/crates/thread_aware_macros_impl/tests/snapshots/derive__generics_group_usage_adds_bound.snap index 1328ba68c..02abd7dea 100644 --- a/crates/thread_aware_macros_impl/tests/snapshots/derive__generics_group_usage_adds_bound.snap +++ b/crates/thread_aware_macros_impl/tests/snapshots/derive__generics_group_usage_adds_bound.snap @@ -2,7 +2,10 @@ source: crates/thread_aware_macros_impl/tests/derive.rs expression: rendered --- -impl ::thread_aware::ThreadAware for GroupUse { +impl ::thread_aware::ThreadAware for GroupUse +where + T: ::thread_aware::ThreadAware, +{ fn relocate( &mut self, __thread_aware_source: ::core::option::Option<&::thread_aware::Thread>, diff --git a/crates/thread_aware_macros_impl/tests/snapshots/derive__generics_lifetime_and_const_params_untouched.snap b/crates/thread_aware_macros_impl/tests/snapshots/derive__generics_lifetime_and_const_params_untouched.snap index 423698b51..8def843be 100644 --- a/crates/thread_aware_macros_impl/tests/snapshots/derive__generics_lifetime_and_const_params_untouched.snap +++ b/crates/thread_aware_macros_impl/tests/snapshots/derive__generics_lifetime_and_const_params_untouched.snap @@ -2,11 +2,10 @@ source: crates/thread_aware_macros_impl/tests/derive.rs expression: expand(input) --- -impl< - 'a, - const N: usize, - T: Sync + ::thread_aware::ThreadAware, -> ::thread_aware::ThreadAware for Mixed<'a, N, T> { +impl<'a, const N: usize, T: Sync> ::thread_aware::ThreadAware for Mixed<'a, N, T> +where + core::marker::PhantomData<(&'a T, [u8; N])>: ::thread_aware::ThreadAware, +{ fn relocate( &mut self, __thread_aware_source: ::core::option::Option<&::thread_aware::Thread>, diff --git a/crates/thread_aware_macros_impl/tests/snapshots/derive__generics_paren_adds_bound.snap b/crates/thread_aware_macros_impl/tests/snapshots/derive__generics_paren_adds_bound.snap index 8d1d63774..77a999de6 100644 --- a/crates/thread_aware_macros_impl/tests/snapshots/derive__generics_paren_adds_bound.snap +++ b/crates/thread_aware_macros_impl/tests/snapshots/derive__generics_paren_adds_bound.snap @@ -2,8 +2,10 @@ source: crates/thread_aware_macros_impl/tests/derive.rs expression: expand(input) --- -impl ::thread_aware::ThreadAware -for ParenthesizedType { +impl ::thread_aware::ThreadAware for ParenthesizedType +where + T: ::thread_aware::ThreadAware, +{ fn relocate( &mut self, __thread_aware_source: ::core::option::Option<&::thread_aware::Thread>, diff --git a/crates/thread_aware_macros_impl/tests/snapshots/derive__generics_ref_usage_adds_bound.snap b/crates/thread_aware_macros_impl/tests/snapshots/derive__generics_ref_usage_adds_bound.snap index 34db83024..91c198f98 100644 --- a/crates/thread_aware_macros_impl/tests/snapshots/derive__generics_ref_usage_adds_bound.snap +++ b/crates/thread_aware_macros_impl/tests/snapshots/derive__generics_ref_usage_adds_bound.snap @@ -2,7 +2,10 @@ source: crates/thread_aware_macros_impl/tests/derive.rs expression: expand(input) --- -impl ::thread_aware::ThreadAware for RefUse { +impl ::thread_aware::ThreadAware for RefUse +where + &'static T: ::thread_aware::ThreadAware, +{ fn relocate( &mut self, __thread_aware_source: ::core::option::Option<&::thread_aware::Thread>, diff --git a/crates/thread_aware_macros_impl/tests/snapshots/derive__generics_tuple_usage_adds_bound.snap b/crates/thread_aware_macros_impl/tests/snapshots/derive__generics_tuple_usage_adds_bound.snap index c99daae24..2761e39ba 100644 --- a/crates/thread_aware_macros_impl/tests/snapshots/derive__generics_tuple_usage_adds_bound.snap +++ b/crates/thread_aware_macros_impl/tests/snapshots/derive__generics_tuple_usage_adds_bound.snap @@ -2,7 +2,10 @@ source: crates/thread_aware_macros_impl/tests/derive.rs expression: expand(input) --- -impl ::thread_aware::ThreadAware for TupUse { +impl ::thread_aware::ThreadAware for TupUse +where + (T,): ::thread_aware::ThreadAware, +{ fn relocate( &mut self, __thread_aware_source: ::core::option::Option<&::thread_aware::Thread>, diff --git a/crates/thread_aware_macros_impl/tests/snapshots/derive__no_skipped_field_means_no_self_send_predicate.snap b/crates/thread_aware_macros_impl/tests/snapshots/derive__no_skipped_field_means_no_self_send_predicate.snap index dc13760ec..a42cdbc64 100644 --- a/crates/thread_aware_macros_impl/tests/snapshots/derive__no_skipped_field_means_no_self_send_predicate.snap +++ b/crates/thread_aware_macros_impl/tests/snapshots/derive__no_skipped_field_means_no_self_send_predicate.snap @@ -2,10 +2,11 @@ source: crates/thread_aware_macros_impl/tests/derive.rs expression: expand(input) --- -impl< +impl ::thread_aware::ThreadAware for AllRelocated +where T: ::thread_aware::ThreadAware, U: ::thread_aware::ThreadAware, -> ::thread_aware::ThreadAware for AllRelocated { +{ fn relocate( &mut self, __thread_aware_source: ::core::option::Option<&::thread_aware::Thread>, diff --git a/crates/thread_aware_macros_impl/tests/snapshots/derive__phantom_data_named_fields.snap b/crates/thread_aware_macros_impl/tests/snapshots/derive__phantom_data_named_fields.snap index 8677db0ad..15200af79 100644 --- a/crates/thread_aware_macros_impl/tests/snapshots/derive__phantom_data_named_fields.snap +++ b/crates/thread_aware_macros_impl/tests/snapshots/derive__phantom_data_named_fields.snap @@ -2,7 +2,10 @@ source: crates/thread_aware_macros_impl/tests/derive.rs expression: expand(input) --- -impl ::thread_aware::ThreadAware for WithPhantom { +impl ::thread_aware::ThreadAware for WithPhantom +where + core::marker::PhantomData: ::thread_aware::ThreadAware, +{ fn relocate( &mut self, __thread_aware_source: ::core::option::Option<&::thread_aware::Thread>, diff --git a/crates/thread_aware_macros_impl/tests/snapshots/derive__phantom_data_unnamed_fields.snap b/crates/thread_aware_macros_impl/tests/snapshots/derive__phantom_data_unnamed_fields.snap index febf2678e..51b32e44e 100644 --- a/crates/thread_aware_macros_impl/tests/snapshots/derive__phantom_data_unnamed_fields.snap +++ b/crates/thread_aware_macros_impl/tests/snapshots/derive__phantom_data_unnamed_fields.snap @@ -2,8 +2,10 @@ source: crates/thread_aware_macros_impl/tests/derive.rs expression: expand(input) --- -impl ::thread_aware::ThreadAware -for TupleWithPhantom { +impl ::thread_aware::ThreadAware for TupleWithPhantom +where + core::marker::PhantomData: ::thread_aware::ThreadAware, +{ fn relocate( &mut self, __thread_aware_source: ::core::option::Option<&::thread_aware::Thread>, diff --git a/crates/thread_aware_macros_impl/tests/snapshots/derive__phantom_only_generic_gets_thread_aware_bound.snap b/crates/thread_aware_macros_impl/tests/snapshots/derive__phantom_only_generic_gets_thread_aware_bound.snap index 35d54aa2b..cc1791e05 100644 --- a/crates/thread_aware_macros_impl/tests/snapshots/derive__phantom_only_generic_gets_thread_aware_bound.snap +++ b/crates/thread_aware_macros_impl/tests/snapshots/derive__phantom_only_generic_gets_thread_aware_bound.snap @@ -2,10 +2,11 @@ source: crates/thread_aware_macros_impl/tests/derive.rs expression: expand(input) --- -impl< +impl ::thread_aware::ThreadAware for DirectPhantom +where T: ::thread_aware::ThreadAware, - U: ::thread_aware::ThreadAware, -> ::thread_aware::ThreadAware for DirectPhantom { + core::marker::PhantomData: ::thread_aware::ThreadAware, +{ fn relocate( &mut self, __thread_aware_source: ::core::option::Option<&::thread_aware::Thread>, diff --git a/crates/thread_aware_macros_impl/tests/snapshots/derive__relocated_and_phantom_param_shares_one_bound.snap b/crates/thread_aware_macros_impl/tests/snapshots/derive__relocated_and_phantom_param_shares_one_bound.snap index e7241d230..83505e33a 100644 --- a/crates/thread_aware_macros_impl/tests/snapshots/derive__relocated_and_phantom_param_shares_one_bound.snap +++ b/crates/thread_aware_macros_impl/tests/snapshots/derive__relocated_and_phantom_param_shares_one_bound.snap @@ -2,8 +2,10 @@ source: crates/thread_aware_macros_impl/tests/derive.rs expression: expand(input) --- -impl<'a, T: 'a + ::thread_aware::ThreadAware> ::thread_aware::ThreadAware -for RelocatedAndPhantom<'a, T> { +impl<'a, T: 'a> ::thread_aware::ThreadAware for RelocatedAndPhantom<'a, T> +where + T: ::thread_aware::ThreadAware, +{ fn relocate( &mut self, __thread_aware_source: ::core::option::Option<&::thread_aware::Thread>, diff --git a/crates/thread_aware_macros_impl/tests/snapshots/derive__unrelated_trait_named_thread_aware_does_not_suppress_the_bound.snap b/crates/thread_aware_macros_impl/tests/snapshots/derive__unrelated_trait_named_thread_aware_does_not_suppress_the_bound.snap index 1eb84a6f3..63735b3f5 100644 --- a/crates/thread_aware_macros_impl/tests/snapshots/derive__unrelated_trait_named_thread_aware_does_not_suppress_the_bound.snap +++ b/crates/thread_aware_macros_impl/tests/snapshots/derive__unrelated_trait_named_thread_aware_does_not_suppress_the_bound.snap @@ -2,8 +2,10 @@ source: crates/thread_aware_macros_impl/tests/derive.rs expression: expand(input) --- -impl ::thread_aware::ThreadAware -for CustomTa { +impl ::thread_aware::ThreadAware for CustomTa +where + T: ::thread_aware::ThreadAware, +{ fn relocate( &mut self, __thread_aware_source: ::core::option::Option<&::thread_aware::Thread>, diff --git a/crates/thread_aware_macros_impl/tests/snapshots/derive__user_where_clause_is_preserved.snap b/crates/thread_aware_macros_impl/tests/snapshots/derive__user_where_clause_is_preserved.snap index d2d152cf3..62902de27 100644 --- a/crates/thread_aware_macros_impl/tests/snapshots/derive__user_where_clause_is_preserved.snap +++ b/crates/thread_aware_macros_impl/tests/snapshots/derive__user_where_clause_is_preserved.snap @@ -2,12 +2,11 @@ source: crates/thread_aware_macros_impl/tests/derive.rs expression: expand(input) --- -impl< - T: ::thread_aware::ThreadAware, - U: ::thread_aware::ThreadAware, -> ::thread_aware::ThreadAware for WithWhere +impl ::thread_aware::ThreadAware for WithWhere where T: Clone, + T: ::thread_aware::ThreadAware, + core::marker::PhantomData: ::thread_aware::ThreadAware, { fn relocate( &mut self, From 38da071ea632b0e7f3ff8130292287a985da5000 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pato=20Sanda=C3=B1a?= <1194304+psandana@users.noreply.github.com> Date: Tue, 8 Sep 2026 09:36:05 -0300 Subject: [PATCH 02/10] test(thread_aware_macros): cover repeated field-type deduplication Adds a snapshot for a struct with two fields of the same parameter-reaching type (`TwoVecs(Vec, Vec)`), exercising the `where`-predicate de-duplication so the derive emits `where Vec: ThreadAware` exactly once. Restores 100% patch coverage. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../thread_aware_macros_impl/tests/derive.rs | 12 +++++++++ ...__repeated_field_type_is_bounded_once.snap | 25 +++++++++++++++++++ 2 files changed, 37 insertions(+) create mode 100644 crates/thread_aware_macros_impl/tests/snapshots/derive__repeated_field_type_is_bounded_once.snap diff --git a/crates/thread_aware_macros_impl/tests/derive.rs b/crates/thread_aware_macros_impl/tests/derive.rs index a9a2e1f8f..21d66a75a 100644 --- a/crates/thread_aware_macros_impl/tests/derive.rs +++ b/crates/thread_aware_macros_impl/tests/derive.rs @@ -64,6 +64,18 @@ fn generics_add_bounds() { assert_snapshot!(expand(input)); } +#[test] +#[cfg_attr(miri, ignore)] +fn repeated_field_type_is_bounded_once() { + // Two fields of the same type owe a single predicate; the derive must not repeat it, which + // would trip `clippy::trait_duplication_in_bounds`. + let input = quote! { + #[derive(ThreadAware)] + struct TwoVecs(Vec, Vec); + }; + assert_snapshot!(expand(input)); +} + #[test] #[cfg_attr(miri, ignore)] fn generics_prebound_bare_no_dup() { diff --git a/crates/thread_aware_macros_impl/tests/snapshots/derive__repeated_field_type_is_bounded_once.snap b/crates/thread_aware_macros_impl/tests/snapshots/derive__repeated_field_type_is_bounded_once.snap new file mode 100644 index 000000000..dbf9464f3 --- /dev/null +++ b/crates/thread_aware_macros_impl/tests/snapshots/derive__repeated_field_type_is_bounded_once.snap @@ -0,0 +1,25 @@ +--- +source: crates/thread_aware_macros_impl/tests/derive.rs +expression: expand(input) +--- +impl ::thread_aware::ThreadAware for TwoVecs +where + Vec: ::thread_aware::ThreadAware, +{ + fn relocate( + &mut self, + __thread_aware_source: ::core::option::Option<&::thread_aware::Thread>, + __thread_aware_destination: &::thread_aware::Thread, + ) { + ::thread_aware::ThreadAware::relocate( + &mut self.0, + __thread_aware_source, + __thread_aware_destination, + ); + ::thread_aware::ThreadAware::relocate( + &mut self.1, + __thread_aware_source, + __thread_aware_destination, + ); + } +} From 31a99d033b597e5ac34bc1c84926ef88884dac88 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pato=20Sanda=C3=B1a?= <1194304+psandana@users.noreply.github.com> Date: Tue, 8 Sep 2026 21:49:56 -0300 Subject: [PATCH 03/10] fix(thread_aware_macros): dedup generated bounds against the author's where clause Addresses review feedback on PR #740: `add_bounds` appended a generated `where : ThreadAware` predicate even when the author had already written an identical predicate in their own `where` clause (e.g. a field `Vec` with `where Vec: ThreadAware`), producing a redundant bound that trips `clippy::trait_duplication_in_bounds`. Seed the emitted-predicate set from the author's `where` clause so a duplicate is suppressed, and add a snapshot pinning it. The inline-bound path is unchanged. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- crates/thread_aware_macros_impl/src/lib.rs | 25 ++++++++++++++++--- .../thread_aware_macros_impl/tests/derive.rs | 12 +++++++++ ..._thread_aware_bound_is_not_duplicated.snap | 20 +++++++++++++++ 3 files changed, 53 insertions(+), 4 deletions(-) create mode 100644 crates/thread_aware_macros_impl/tests/snapshots/derive__user_where_clause_thread_aware_bound_is_not_duplicated.snap diff --git a/crates/thread_aware_macros_impl/src/lib.rs b/crates/thread_aware_macros_impl/src/lib.rs index 957aa9a0b..1e385f09e 100644 --- a/crates/thread_aware_macros_impl/src/lib.rs +++ b/crates/thread_aware_macros_impl/src/lib.rs @@ -130,6 +130,23 @@ fn add_bounds(input: &DeriveInput, root_path: &Path) -> syn::Result = Vec::new(); + + // Seed with the field-type predicates the author already wrote in a `where` clause, so a + // generated one that duplicates it is suppressed - a redundant predicate trips + // `clippy::trait_duplication_in_bounds` at the author's own declaration. + if let Some(where_clause) = &input.generics.where_clause { + for predicate in &where_clause.predicates { + if let syn::WherePredicate::Type(pt) = predicate + && pt + .bounds + .iter() + .any(|b| matches!(b, syn::TypeParamBound::Trait(t) if is_same_trait(&t.path, &thread_aware_path))) + { + emitted_keys.push(strip_group_paren(&pt.bounded_ty).to_token_stream().to_string()); + } + } + } + let mut predicates: Vec = Vec::new(); for field_ty in &relocated_fields { if !type_reaches_param(field_ty, &generic_idents) { @@ -262,11 +279,11 @@ fn as_bare_param<'a>(ty: &'a Type, generic_idents: &HashSet) -> Opti None } -/// Reports whether the parameter's own declaration already carries a `ThreadAware` bound. +/// Reports whether the parameter's own declaration already carries an inline `ThreadAware` bound. /// -/// Only the inline bounds on the parameter are inspected - where an author most naturally writes -/// such a bound, and the case the snapshots pin. A bound expressed in a `where` clause is left to -/// the author's judgment, exactly as it was before the derive emitted field-type predicates. +/// Only the inline bounds on the parameter are inspected. An equivalent predicate the author wrote +/// in a `where` clause is suppressed separately, by seeding the emitted-predicate set from that +/// `where` clause in `add_bounds`. fn param_has_thread_aware_bound(generics: &syn::Generics, ident: &syn::Ident, thread_aware_path: &Path) -> bool { generics.params.iter().any(|param| { matches!(param, GenericParam::Type(ty_param) diff --git a/crates/thread_aware_macros_impl/tests/derive.rs b/crates/thread_aware_macros_impl/tests/derive.rs index 21d66a75a..f9fcc307c 100644 --- a/crates/thread_aware_macros_impl/tests/derive.rs +++ b/crates/thread_aware_macros_impl/tests/derive.rs @@ -76,6 +76,18 @@ fn repeated_field_type_is_bounded_once() { assert_snapshot!(expand(input)); } +#[test] +#[cfg_attr(miri, ignore)] +fn user_where_clause_thread_aware_bound_is_not_duplicated() { + // A field-type predicate the author already wrote in a `where` clause must not be emitted a + // second time, which would trip `clippy::trait_duplication_in_bounds`. + let input = quote! { + #[derive(ThreadAware)] + struct Foo(Vec) where Vec: ThreadAware; + }; + assert_snapshot!(expand(input)); +} + #[test] #[cfg_attr(miri, ignore)] fn generics_prebound_bare_no_dup() { diff --git a/crates/thread_aware_macros_impl/tests/snapshots/derive__user_where_clause_thread_aware_bound_is_not_duplicated.snap b/crates/thread_aware_macros_impl/tests/snapshots/derive__user_where_clause_thread_aware_bound_is_not_duplicated.snap new file mode 100644 index 000000000..a2db350a7 --- /dev/null +++ b/crates/thread_aware_macros_impl/tests/snapshots/derive__user_where_clause_thread_aware_bound_is_not_duplicated.snap @@ -0,0 +1,20 @@ +--- +source: crates/thread_aware_macros_impl/tests/derive.rs +expression: expand(input) +--- +impl ::thread_aware::ThreadAware for Foo +where + Vec: ThreadAware, +{ + fn relocate( + &mut self, + __thread_aware_source: ::core::option::Option<&::thread_aware::Thread>, + __thread_aware_destination: &::thread_aware::Thread, + ) { + ::thread_aware::ThreadAware::relocate( + &mut self.0, + __thread_aware_source, + __thread_aware_destination, + ); + } +} From 02d4e3c16a5e4cdbed9858ca21134c90f3a293b5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pato=20Sanda=C3=B1a?= <1194304+psandana@users.noreply.github.com> Date: Wed, 9 Sep 2026 11:40:54 -0300 Subject: [PATCH 04/10] fix(thread_aware_macros): keep recursive and slice-bearing generics deriving Addresses two regressions found by @wukchung in review of PR #740: 1. Recursive generics overflowed. A field whose type names the type being derived (e.g. `children: Vec>`) produced `where Vec>: ThreadAware`, a bound on the impl under construction, so trait selection cycled (`E0275`). Now such a field is bounded by the parameters it reaches (`T`) instead, which bottom out at the concrete argument and prove the recursive impl inductively. `add_bounds` chooses per field: the field type normally, the reached parameters when the field type is self-referential. 2. A slice-bearing sibling lost its bound. `type_reaches_param` did not traverse `Type::Slice`, so `Box<[T]>` owed no predicate; under the old per-parameter model a sibling `Vec` field's `T: ThreadAware` covered it, but a field-type predicate does not. `[T]` has a conditional impl in `thread_aware_core`, so `Slice` is now traversed and `Box<[T]>` emits `where Box<[T]>: ThreadAware`. Corrected the stale comment that claimed the omitted shapes have no impl. Adds snapshots for both shapes and a `derive_compiles.rs` regression covering a recursive struct, a recursive enum, and a slice sibling. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- crates/thread_aware/tests/derive_compiles.rs | 51 +++++++ crates/thread_aware_macros_impl/src/lib.rs | 133 ++++++++++++++---- .../thread_aware_macros_impl/tests/derive.rs | 27 ++++ ..._generic_bounds_the_reached_parameter.snap | 25 ++++ ...ve__slice_field_reaches_its_parameter.snap | 26 ++++ 5 files changed, 238 insertions(+), 24 deletions(-) create mode 100644 crates/thread_aware_macros_impl/tests/snapshots/derive__recursive_generic_bounds_the_reached_parameter.snap create mode 100644 crates/thread_aware_macros_impl/tests/snapshots/derive__slice_field_reaches_its_parameter.snap diff --git a/crates/thread_aware/tests/derive_compiles.rs b/crates/thread_aware/tests/derive_compiles.rs index e7dd56f0f..9b3b45375 100644 --- a/crates/thread_aware/tests/derive_compiles.rs +++ b/crates/thread_aware/tests/derive_compiles.rs @@ -666,6 +666,57 @@ fn field_type_bound_lets_an_unconditional_wrapper_derive_for_any_argument() { value.relocate(source.as_ref(), &destination); } +/// A recursive generic type: the `children: Vec>` field names the type being +/// derived, so the derive bounds the parameter it reaches (`T`) rather than the whole field type - +/// a `where Vec>: ThreadAware` predicate would be self-referential and overflow. +#[expect( + clippy::use_self, + reason = "the explicit self-referential spelling is what this regression exercises" +)] +#[derive(ThreadAware)] +struct RecursiveNode { + value: T, + children: Vec>, +} + +/// A recursive generic enum, reached through `Box`. +#[expect( + clippy::use_self, + reason = "the explicit self-referential spelling is what this regression exercises" +)] +#[derive(ThreadAware)] +enum RecursiveList { + Nil, + Cons(T, Box>), +} + +/// A slice-bearing field whose sibling used to cover its bound under the per-parameter model. +/// `Box<[T]>` reaches `T` through the conditional `[T]` impl, so it owes `Box<[T]>: ThreadAware`. +#[derive(ThreadAware)] +struct SliceSibling { + values: Vec, + rest: Box<[T]>, +} + +#[test] +fn recursive_and_slice_generics_compile_and_relocate() { + assert_thread_aware::>(); + assert_thread_aware::>(); + assert_thread_aware::>(); + + let (source, destination) = thread_pair(); + let mut node = RecursiveNode { + value: Tracker::default(), + children: vec![RecursiveNode { + value: Tracker::default(), + children: Vec::new(), + }], + }; + node.relocate(source.as_ref(), &destination); + assert_eq!(node.value.relocations, 1); + assert_eq!(node.children[0].value.relocations, 1, "relocation reaches recursive children"); +} + /// A trait of the user's own that happens to be called `ThreadAware`, named by a qualified /// path. /// diff --git a/crates/thread_aware_macros_impl/src/lib.rs b/crates/thread_aware_macros_impl/src/lib.rs index 1e385f09e..31797abc7 100644 --- a/crates/thread_aware_macros_impl/src/lib.rs +++ b/crates/thread_aware_macros_impl/src/lib.rs @@ -153,26 +153,39 @@ fn add_bounds(input: &DeriveInput, root_path: &Path) -> syn::Result: ThreadAware` would be a bound on the impl + // under construction and the trait solver overflows on it. Bound the parameters it reaches + // instead - those bottom out at the concrete argument, proving the recursive impl + // inductively. Every other field is bounded by its own type. + let targets: Vec = if type_names_deriving_type(field_ty, &input.ident) { + let mut reached = Vec::new(); + collect_params_reached(field_ty, &generic_idents, &mut reached); + reached.into_iter().map(|param| parse_quote!(#param)).collect() + } else { + vec![strip_group_paren(field_ty).clone()] + }; + + for target in targets { + // Two fields owing the same predicate contribute it once; repeating it trips + // `clippy::trait_duplication_in_bounds`. + let key = target.to_token_stream().to_string(); + if emitted_keys.iter().any(|seen| seen == &key) { + continue; + } + emitted_keys.push(key); - // Two fields of the same type owe a single predicate; repeating it trips - // `clippy::trait_duplication_in_bounds`. - let key = bound_ty.to_token_stream().to_string(); - if emitted_keys.iter().any(|seen| seen == &key) { - continue; - } - emitted_keys.push(key); - - // A field that is exactly a parameter the author already bounded by `ThreadAware` needs - // no generated predicate: emitting one would duplicate the author's own bound and trip - // `clippy::trait_duplication_in_bounds` at their declaration. - if let Some(param) = as_bare_param(bound_ty, &generic_idents) - && param_has_thread_aware_bound(&generics, param, &thread_aware_path) - { - continue; - } + // A target that is exactly a parameter the author already bounded by `ThreadAware` needs + // no generated predicate: emitting one would duplicate the author's own bound and trip + // `clippy::trait_duplication_in_bounds` at their declaration. + if let Some(param) = as_bare_param(&target, &generic_idents) + && param_has_thread_aware_bound(&generics, param, &thread_aware_path) + { + continue; + } - predicates.push(parse_quote!(#bound_ty: #thread_aware_path)); + predicates.push(parse_quote!(#target: #thread_aware_path)); + } } if !predicates.is_empty() { @@ -296,15 +309,16 @@ fn param_has_thread_aware_bound(generics: &syn::Generics, ident: &syn::Ident, th } /// Reports whether `ty` reaches a generic parameter through a shape the generated body relocates -/// through: a path's type arguments, a reference, a tuple, an array, or a `Group`/`Paren` wrapper. +/// through: a path's type arguments, a reference, a tuple, an array, a slice, or a `Group`/`Paren` +/// wrapper. /// /// When it does, the field's `ThreadAware`-ness depends on that parameter and the impl owes -/// `: ThreadAware`. The shapes deliberately left out - `Slice`, `Ptr`, `BareFn`, -/// `TraitObject`, `ImplTrait` - are the ones a parameter cannot make the field conditionally -/// `ThreadAware` through: a safe `fn` pointer implements `ThreadAware` unconditionally, so a +/// `: ThreadAware`. `Slice` is traversed because `thread_aware_core` implements +/// `ThreadAware` for `[T]` conditionally on `T`, so a `[T]` (or `Box<[T]>`) field's obligation does +/// reduce to one on the parameter. The shapes left out - `Ptr`, `BareFn`, `TraitObject`, +/// `ImplTrait` - cannot: a safe `fn` pointer implements `ThreadAware` unconditionally, so a /// parameter carried only for variance inside one (`PhantomData`) owes no bound, and -/// the rest have no impl at all. This keeps the marker-payload idiom bound-free, exactly as the -/// per-parameter collector did before. +/// the rest have no impl at all. This keeps the marker-payload idiom bound-free. #[cfg_attr(coverage_nightly, coverage(off))] // can't figure out how to get to 100% coverage of this function fn type_reaches_param(ty: &Type, generic_idents: &HashSet) -> bool { match ty { @@ -328,8 +342,79 @@ fn type_reaches_param(ty: &Type, generic_idents: &HashSet) -> bool { Type::Reference(r) => type_reaches_param(&r.elem, generic_idents), Type::Tuple(t) => t.elems.iter().any(|elem| type_reaches_param(elem, generic_idents)), Type::Array(a) => type_reaches_param(&a.elem, generic_idents), + Type::Slice(s) => type_reaches_param(&s.elem, generic_idents), Type::Group(g) => type_reaches_param(&g.elem, generic_idents), Type::Paren(p) => type_reaches_param(&p.elem, generic_idents), _ => false, } } + +/// Reports whether `ty` names the type being derived (`self_ident`) or `Self` anywhere within it. +/// +/// Such a field type turns `where : ThreadAware` into a bound on the impl under +/// construction, which the trait solver cannot discharge; `add_bounds` bounds the parameters the +/// field reaches instead. +#[cfg_attr(coverage_nightly, coverage(off))] // structural walk; same coverage caveat as the others +fn type_names_deriving_type(ty: &Type, self_ident: &syn::Ident) -> bool { + match ty { + Type::Path(TypePath { path, .. }) => { + for segment in &path.segments { + if &segment.ident == self_ident || segment.ident == "Self" { + return true; + } + if let PathArguments::AngleBracketed(ab) = &segment.arguments { + for arg in &ab.args { + if let syn::GenericArgument::Type(t) = arg + && type_names_deriving_type(t, self_ident) + { + return true; + } + } + } + } + false + } + Type::Reference(r) => type_names_deriving_type(&r.elem, self_ident), + Type::Tuple(t) => t.elems.iter().any(|e| type_names_deriving_type(e, self_ident)), + Type::Array(a) => type_names_deriving_type(&a.elem, self_ident), + Type::Slice(s) => type_names_deriving_type(&s.elem, self_ident), + Type::Group(g) => type_names_deriving_type(&g.elem, self_ident), + Type::Paren(p) => type_names_deriving_type(&p.elem, self_ident), + _ => false, + } +} + +/// Collects the generic parameters `ty` reaches, through the same shapes as `type_reaches_param`. +/// +/// Used for the self-referential fallback: a field whose type names the deriving type is bounded by +/// the parameters it reaches rather than by the field type itself. +#[cfg_attr(coverage_nightly, coverage(off))] // mirrors type_reaches_param; same coverage caveat +fn collect_params_reached(ty: &Type, generic_idents: &HashSet, out: &mut Vec) { + match ty { + Type::Path(TypePath { path, .. }) => { + for segment in &path.segments { + if generic_idents.contains(&segment.ident) && !out.contains(&segment.ident) { + out.push(segment.ident.clone()); + } + if let PathArguments::AngleBracketed(ab) = &segment.arguments { + for arg in &ab.args { + if let syn::GenericArgument::Type(t) = arg { + collect_params_reached(t, generic_idents, out); + } + } + } + } + } + Type::Reference(r) => collect_params_reached(&r.elem, generic_idents, out), + Type::Tuple(t) => { + for elem in &t.elems { + collect_params_reached(elem, generic_idents, out); + } + } + Type::Array(a) => collect_params_reached(&a.elem, generic_idents, out), + Type::Slice(s) => collect_params_reached(&s.elem, generic_idents, out), + Type::Group(g) => collect_params_reached(&g.elem, generic_idents, out), + Type::Paren(p) => collect_params_reached(&p.elem, generic_idents, out), + _ => {} + } +} diff --git a/crates/thread_aware_macros_impl/tests/derive.rs b/crates/thread_aware_macros_impl/tests/derive.rs index f9fcc307c..6aa609e8c 100644 --- a/crates/thread_aware_macros_impl/tests/derive.rs +++ b/crates/thread_aware_macros_impl/tests/derive.rs @@ -88,6 +88,33 @@ fn user_where_clause_thread_aware_bound_is_not_duplicated() { assert_snapshot!(expand(input)); } +#[test] +#[cfg_attr(miri, ignore)] +fn recursive_generic_bounds_the_reached_parameter() { + // A field naming the type being derived would make `where : ThreadAware` + // self-referential and overflow; the derive bounds the parameter the field reaches instead. + let input = quote! { + #[derive(ThreadAware)] + struct Node { + value: T, + children: Vec>, + } + }; + assert_snapshot!(expand(input)); +} + +#[test] +#[cfg_attr(miri, ignore)] +fn slice_field_reaches_its_parameter() { + // `[T]` has a conditional `ThreadAware` impl, so a `Box<[T]>` field owes a field-type bound + // rather than silently relying on a sibling to carry it. + let input = quote! { + #[derive(ThreadAware)] + struct SliceHolder(Vec, Box<[T]>); + }; + assert_snapshot!(expand(input)); +} + #[test] #[cfg_attr(miri, ignore)] fn generics_prebound_bare_no_dup() { diff --git a/crates/thread_aware_macros_impl/tests/snapshots/derive__recursive_generic_bounds_the_reached_parameter.snap b/crates/thread_aware_macros_impl/tests/snapshots/derive__recursive_generic_bounds_the_reached_parameter.snap new file mode 100644 index 000000000..1b4fad80b --- /dev/null +++ b/crates/thread_aware_macros_impl/tests/snapshots/derive__recursive_generic_bounds_the_reached_parameter.snap @@ -0,0 +1,25 @@ +--- +source: crates/thread_aware_macros_impl/tests/derive.rs +expression: expand(input) +--- +impl ::thread_aware::ThreadAware for Node +where + T: ::thread_aware::ThreadAware, +{ + fn relocate( + &mut self, + __thread_aware_source: ::core::option::Option<&::thread_aware::Thread>, + __thread_aware_destination: &::thread_aware::Thread, + ) { + ::thread_aware::ThreadAware::relocate( + &mut self.value, + __thread_aware_source, + __thread_aware_destination, + ); + ::thread_aware::ThreadAware::relocate( + &mut self.children, + __thread_aware_source, + __thread_aware_destination, + ); + } +} diff --git a/crates/thread_aware_macros_impl/tests/snapshots/derive__slice_field_reaches_its_parameter.snap b/crates/thread_aware_macros_impl/tests/snapshots/derive__slice_field_reaches_its_parameter.snap new file mode 100644 index 000000000..25b1c3a31 --- /dev/null +++ b/crates/thread_aware_macros_impl/tests/snapshots/derive__slice_field_reaches_its_parameter.snap @@ -0,0 +1,26 @@ +--- +source: crates/thread_aware_macros_impl/tests/derive.rs +expression: expand(input) +--- +impl ::thread_aware::ThreadAware for SliceHolder +where + Vec: ::thread_aware::ThreadAware, + Box<[T]>: ::thread_aware::ThreadAware, +{ + fn relocate( + &mut self, + __thread_aware_source: ::core::option::Option<&::thread_aware::Thread>, + __thread_aware_destination: &::thread_aware::Thread, + ) { + ::thread_aware::ThreadAware::relocate( + &mut self.0, + __thread_aware_source, + __thread_aware_destination, + ); + ::thread_aware::ThreadAware::relocate( + &mut self.1, + __thread_aware_source, + __thread_aware_destination, + ); + } +} From 71089a5f1c88317e30fd77b85dec94e5b65c4572 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pato=20Sanda=C3=B1a?= <1194304+psandana@users.noreply.github.com> Date: Wed, 9 Sep 2026 14:10:09 -0300 Subject: [PATCH 05/10] refactor(thread_aware_macros): reuse one traversal for reach and self-reference checks Fixes the mutation-testing gate on PR #740. The recursive-generics fix added two new structural-traversal helpers (`type_names_deriving_type`, `collect_params_reached`) whose match arms were not exercised by tests, so `cargo mutants` reported them as missed. Generalize the existing (already mutation-tested) traversal to `type_reaches_ident`, which takes a target ident set, and use it three ways: with the generic parameters (does the field owe a bound?), with the derived type name plus `Self` (is the bound self-referential?), and per parameter (which parameters does a self-referential field reach?). The two duplicate helpers are deleted. Adds snapshots pinning that a recursive field bounds only the parameters it reaches (not every parameter) and that `Self` is detected like the type's own name. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- crates/thread_aware_macros_impl/src/lib.rs | 130 +++++------------- .../thread_aware_macros_impl/tests/derive.rs | 24 ++++ ...bounds_only_the_parameters_it_reaches.snap | 26 ++++ ...self_keyword_falls_back_to_parameters.snap | 20 +++ 4 files changed, 107 insertions(+), 93 deletions(-) create mode 100644 crates/thread_aware_macros_impl/tests/snapshots/derive__recursive_field_bounds_only_the_parameters_it_reaches.snap create mode 100644 crates/thread_aware_macros_impl/tests/snapshots/derive__self_reference_through_the_self_keyword_falls_back_to_parameters.snap diff --git a/crates/thread_aware_macros_impl/src/lib.rs b/crates/thread_aware_macros_impl/src/lib.rs index 31797abc7..140902bf7 100644 --- a/crates/thread_aware_macros_impl/src/lib.rs +++ b/crates/thread_aware_macros_impl/src/lib.rs @@ -105,7 +105,9 @@ pub(crate) fn param_idents() -> (syn::Ident, syn::Ident) { fn add_bounds(input: &DeriveInput, root_path: &Path) -> syn::Result { let mut generics = input.generics.clone(); - let generic_idents: HashSet = generics + // Type parameters in declaration order (for deterministic output) and as a set (for fast + // membership tests). + let generic_param_idents: Vec = generics .params .iter() .filter_map(|gp| match gp { @@ -113,6 +115,12 @@ fn add_bounds(input: &DeriveInput, root_path: &Path) -> syn::Result None, }) .collect(); + let generic_idents: HashSet = generic_param_idents.iter().cloned().collect(); + + // The idents that make a field type self-referential: the type being derived, and `Self`. + let self_idents: HashSet = [input.ident.clone(), syn::Ident::new("Self", proc_macro2::Span::call_site())] + .into_iter() + .collect(); let mut thread_aware_path = root_path.clone(); thread_aware_path.segments.push(parse_quote!(ThreadAware)); @@ -149,7 +157,7 @@ fn add_bounds(input: &DeriveInput, root_path: &Path) -> syn::Result = Vec::new(); for field_ty in &relocated_fields { - if !type_reaches_param(field_ty, &generic_idents) { + if !type_reaches_ident(field_ty, &generic_idents) { continue; } @@ -158,10 +166,15 @@ fn add_bounds(input: &DeriveInput, root_path: &Path) -> syn::Result = if type_names_deriving_type(field_ty, &input.ident) { - let mut reached = Vec::new(); - collect_params_reached(field_ty, &generic_idents, &mut reached); - reached.into_iter().map(|param| parse_quote!(#param)).collect() + let targets: Vec = if type_reaches_ident(field_ty, &self_idents) { + generic_param_idents + .iter() + .filter(|¶m| { + let single: HashSet = std::iter::once(param.clone()).collect(); + type_reaches_ident(field_ty, &single) + }) + .map(|param| parse_quote!(#param)) + .collect() } else { vec![strip_group_paren(field_ty).clone()] }; @@ -308,64 +321,30 @@ fn param_has_thread_aware_bound(generics: &syn::Generics, ident: &syn::Ident, th }) } -/// Reports whether `ty` reaches a generic parameter through a shape the generated body relocates +/// Reports whether `ty` reaches one of `targets` through a shape the generated body relocates /// through: a path's type arguments, a reference, a tuple, an array, a slice, or a `Group`/`Paren` /// wrapper. /// -/// When it does, the field's `ThreadAware`-ness depends on that parameter and the impl owes -/// `: ThreadAware`. `Slice` is traversed because `thread_aware_core` implements -/// `ThreadAware` for `[T]` conditionally on `T`, so a `[T]` (or `Box<[T]>`) field's obligation does -/// reduce to one on the parameter. The shapes left out - `Ptr`, `BareFn`, `TraitObject`, -/// `ImplTrait` - cannot: a safe `fn` pointer implements `ThreadAware` unconditionally, so a -/// parameter carried only for variance inside one (`PhantomData`) owes no bound, and -/// the rest have no impl at all. This keeps the marker-payload idiom bound-free. +/// Used two ways: with the generic parameters, to decide whether a field owes a bound at all; and +/// with the type being derived (plus `Self`), to decide whether that bound would be self-referential +/// and must fall back to the reached parameters. `Slice` is traversed because `thread_aware_core` +/// implements `ThreadAware` for `[T]` conditionally on `T`, so a `[T]` (or `Box<[T]>`) field's +/// obligation does reduce to one on the parameter. The shapes left out - `Ptr`, `BareFn`, +/// `TraitObject`, `ImplTrait` - cannot: a safe `fn` pointer implements `ThreadAware` unconditionally, +/// so a parameter carried only for variance inside one (`PhantomData`) owes no bound, +/// and the rest have no impl at all. This keeps the marker-payload idiom bound-free. #[cfg_attr(coverage_nightly, coverage(off))] // can't figure out how to get to 100% coverage of this function -fn type_reaches_param(ty: &Type, generic_idents: &HashSet) -> bool { - match ty { - Type::Path(TypePath { path, .. }) => { - for segment in &path.segments { - if generic_idents.contains(&segment.ident) { - return true; - } - if let PathArguments::AngleBracketed(ab) = &segment.arguments { - for arg in &ab.args { - if let syn::GenericArgument::Type(t) = arg - && type_reaches_param(t, generic_idents) - { - return true; - } - } - } - } - false - } - Type::Reference(r) => type_reaches_param(&r.elem, generic_idents), - Type::Tuple(t) => t.elems.iter().any(|elem| type_reaches_param(elem, generic_idents)), - Type::Array(a) => type_reaches_param(&a.elem, generic_idents), - Type::Slice(s) => type_reaches_param(&s.elem, generic_idents), - Type::Group(g) => type_reaches_param(&g.elem, generic_idents), - Type::Paren(p) => type_reaches_param(&p.elem, generic_idents), - _ => false, - } -} - -/// Reports whether `ty` names the type being derived (`self_ident`) or `Self` anywhere within it. -/// -/// Such a field type turns `where : ThreadAware` into a bound on the impl under -/// construction, which the trait solver cannot discharge; `add_bounds` bounds the parameters the -/// field reaches instead. -#[cfg_attr(coverage_nightly, coverage(off))] // structural walk; same coverage caveat as the others -fn type_names_deriving_type(ty: &Type, self_ident: &syn::Ident) -> bool { +fn type_reaches_ident(ty: &Type, targets: &HashSet) -> bool { match ty { Type::Path(TypePath { path, .. }) => { for segment in &path.segments { - if &segment.ident == self_ident || segment.ident == "Self" { + if targets.contains(&segment.ident) { return true; } if let PathArguments::AngleBracketed(ab) = &segment.arguments { for arg in &ab.args { if let syn::GenericArgument::Type(t) = arg - && type_names_deriving_type(t, self_ident) + && type_reaches_ident(t, targets) { return true; } @@ -374,47 +353,12 @@ fn type_names_deriving_type(ty: &Type, self_ident: &syn::Ident) -> bool { } false } - Type::Reference(r) => type_names_deriving_type(&r.elem, self_ident), - Type::Tuple(t) => t.elems.iter().any(|e| type_names_deriving_type(e, self_ident)), - Type::Array(a) => type_names_deriving_type(&a.elem, self_ident), - Type::Slice(s) => type_names_deriving_type(&s.elem, self_ident), - Type::Group(g) => type_names_deriving_type(&g.elem, self_ident), - Type::Paren(p) => type_names_deriving_type(&p.elem, self_ident), + Type::Reference(r) => type_reaches_ident(&r.elem, targets), + Type::Tuple(t) => t.elems.iter().any(|elem| type_reaches_ident(elem, targets)), + Type::Array(a) => type_reaches_ident(&a.elem, targets), + Type::Slice(s) => type_reaches_ident(&s.elem, targets), + Type::Group(g) => type_reaches_ident(&g.elem, targets), + Type::Paren(p) => type_reaches_ident(&p.elem, targets), _ => false, } } - -/// Collects the generic parameters `ty` reaches, through the same shapes as `type_reaches_param`. -/// -/// Used for the self-referential fallback: a field whose type names the deriving type is bounded by -/// the parameters it reaches rather than by the field type itself. -#[cfg_attr(coverage_nightly, coverage(off))] // mirrors type_reaches_param; same coverage caveat -fn collect_params_reached(ty: &Type, generic_idents: &HashSet, out: &mut Vec) { - match ty { - Type::Path(TypePath { path, .. }) => { - for segment in &path.segments { - if generic_idents.contains(&segment.ident) && !out.contains(&segment.ident) { - out.push(segment.ident.clone()); - } - if let PathArguments::AngleBracketed(ab) = &segment.arguments { - for arg in &ab.args { - if let syn::GenericArgument::Type(t) = arg { - collect_params_reached(t, generic_idents, out); - } - } - } - } - } - Type::Reference(r) => collect_params_reached(&r.elem, generic_idents, out), - Type::Tuple(t) => { - for elem in &t.elems { - collect_params_reached(elem, generic_idents, out); - } - } - Type::Array(a) => collect_params_reached(&a.elem, generic_idents, out), - Type::Slice(s) => collect_params_reached(&s.elem, generic_idents, out), - Type::Group(g) => collect_params_reached(&g.elem, generic_idents, out), - Type::Paren(p) => collect_params_reached(&p.elem, generic_idents, out), - _ => {} - } -} diff --git a/crates/thread_aware_macros_impl/tests/derive.rs b/crates/thread_aware_macros_impl/tests/derive.rs index 6aa609e8c..7e1d1be48 100644 --- a/crates/thread_aware_macros_impl/tests/derive.rs +++ b/crates/thread_aware_macros_impl/tests/derive.rs @@ -115,6 +115,30 @@ fn slice_field_reaches_its_parameter() { assert_snapshot!(expand(input)); } +#[test] +#[cfg_attr(miri, ignore)] +fn recursive_field_bounds_only_the_parameters_it_reaches() { + // The recursive field reaches `T` but not `U`, so only `T` is bounded (`U` comes from the + // marker field). Bounding every parameter rather than only the reached ones would be wrong. + let input = quote! { + #[derive(ThreadAware)] + struct Tree(core::marker::PhantomData, Vec>); + }; + assert_snapshot!(expand(input)); +} + +#[test] +#[cfg_attr(miri, ignore)] +fn self_reference_through_the_self_keyword_falls_back_to_parameters() { + // `Self` inside a parameter-reaching field is self-referential just like the type's own name, + // so the field must fall back to bounding the parameter it reaches. + let input = quote! { + #[derive(ThreadAware)] + struct SelfRef((T, Vec)); + }; + assert_snapshot!(expand(input)); +} + #[test] #[cfg_attr(miri, ignore)] fn generics_prebound_bare_no_dup() { diff --git a/crates/thread_aware_macros_impl/tests/snapshots/derive__recursive_field_bounds_only_the_parameters_it_reaches.snap b/crates/thread_aware_macros_impl/tests/snapshots/derive__recursive_field_bounds_only_the_parameters_it_reaches.snap new file mode 100644 index 000000000..3b5d0a2dc --- /dev/null +++ b/crates/thread_aware_macros_impl/tests/snapshots/derive__recursive_field_bounds_only_the_parameters_it_reaches.snap @@ -0,0 +1,26 @@ +--- +source: crates/thread_aware_macros_impl/tests/derive.rs +expression: expand(input) +--- +impl ::thread_aware::ThreadAware for Tree +where + core::marker::PhantomData: ::thread_aware::ThreadAware, + T: ::thread_aware::ThreadAware, +{ + fn relocate( + &mut self, + __thread_aware_source: ::core::option::Option<&::thread_aware::Thread>, + __thread_aware_destination: &::thread_aware::Thread, + ) { + ::thread_aware::ThreadAware::relocate( + &mut self.0, + __thread_aware_source, + __thread_aware_destination, + ); + ::thread_aware::ThreadAware::relocate( + &mut self.1, + __thread_aware_source, + __thread_aware_destination, + ); + } +} diff --git a/crates/thread_aware_macros_impl/tests/snapshots/derive__self_reference_through_the_self_keyword_falls_back_to_parameters.snap b/crates/thread_aware_macros_impl/tests/snapshots/derive__self_reference_through_the_self_keyword_falls_back_to_parameters.snap new file mode 100644 index 000000000..6db4aac29 --- /dev/null +++ b/crates/thread_aware_macros_impl/tests/snapshots/derive__self_reference_through_the_self_keyword_falls_back_to_parameters.snap @@ -0,0 +1,20 @@ +--- +source: crates/thread_aware_macros_impl/tests/derive.rs +expression: expand(input) +--- +impl ::thread_aware::ThreadAware for SelfRef +where + T: ::thread_aware::ThreadAware, +{ + fn relocate( + &mut self, + __thread_aware_source: ::core::option::Option<&::thread_aware::Thread>, + __thread_aware_destination: &::thread_aware::Thread, + ) { + ::thread_aware::ThreadAware::relocate( + &mut self.0, + __thread_aware_source, + __thread_aware_destination, + ); + } +} From 82c6589d46fe374f6a284bcdbe17b4f74f0ecd19 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pato=20Sanda=C3=B1a?= <1194304+psandana@users.noreply.github.com> Date: Thu, 10 Sep 2026 13:15:23 -0300 Subject: [PATCH 06/10] docs(thread_aware): tighten the derive's Generic Bounds section Addresses martintmk's review comment on PR #740: make the "Generic Bounds" docs concise and readable. Cuts the mechanism-heavy prose (the no-op PhantomData impl walk-through, "the obligation the generated body actually has", the per-parameter reduction) down to the three facts a user needs: one bound per relocated field on the field type, `where Self: Send` for skipped fields, and the function-pointer marker escape hatch. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- crates/thread_aware/src/lib.rs | 37 ++++++++++++++++++++-------------- 1 file changed, 22 insertions(+), 15 deletions(-) diff --git a/crates/thread_aware/src/lib.rs b/crates/thread_aware/src/lib.rs index 09cb71d70..8b553ebc7 100644 --- a/crates/thread_aware/src/lib.rs +++ b/crates/thread_aware/src/lib.rs @@ -161,22 +161,29 @@ mod thread; /// * `#[thread_aware(skip)]`: Prevents a field from being recursively transferred. /// /// # Generic Bounds -/// Every field except a `#[thread_aware(skip)]` one is relocated by calling its own -/// `ThreadAware::relocate`, and the bounds follow from that: -/// * each relocated field whose type reaches a generic parameter contributes a -/// `where : ::thread_aware::ThreadAware` predicate - the obligation the generated -/// body actually has, discharged by that field type's own impl; -/// * if any field carries `#[thread_aware(skip)]`, a `where Self: Send` predicate is added, -/// since the `ThreadAware: Send` supertrait still has to hold. +/// The derive adds one bound per relocated field: `where : ThreadAware`. A +/// `#[thread_aware(skip)]` field isn't relocated, so it contributes `where Self: Send` instead, +/// which is all the `ThreadAware: Send` supertrait needs. /// -/// The predicate lands on the field type as written, not on the parameters inside it. A -/// `PhantomData` field yields `where PhantomData: ThreadAware` (which reduces to `U: Send` -/// through the no-op `impl ThreadAware for PhantomData where Self: Send`), and a -/// wrapper `W` yields `where W: ThreadAware`, governed by `W`'s own impl rather than by a -/// bound on `U` - so a wrapper that implements the trait unconditionally keeps deriving for -/// arguments no bound on `U` would admit. The traversal does not enter a function pointer, so -/// writing a marker payload as one carries the parameter for variance, stays `Send` for every -/// argument, and emits no bound at all: +/// Bounds are on the whole field type, not the parameters inside it. A `Wrapper` field bounds +/// `Wrapper: ThreadAware` and defers to that wrapper's own impl, so a wrapper that is +/// `ThreadAware` for every `T` keeps deriving even where `T` isn't. +/// +/// The derive doesn't look inside function pointers, so a variance marker written as +/// `PhantomData` adds no bound at all: +/// +/// ```rust +/// # use core::marker::PhantomData; +/// # use thread_aware::ThreadAware; +/// #[derive(ThreadAware)] +/// struct Marked { +/// // `PhantomData<*const T>` would make `Marked` `!Send`; this does not. +/// marker: PhantomData, +/// } +/// ``` +/// +/// If you already wrote a bare `ThreadAware` bound yourself, the derive assumes it's this trait +/// and skips the duplicate; qualify the path to disambiguate. /// /// ```rust /// # use core::marker::PhantomData; From 3fbdceb23c98457d52b9be5817f0e5a608b0ac78 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pato=20Sanda=C3=B1a?= <1194304+psandana@users.noreply.github.com> Date: Thu, 10 Sep 2026 17:02:06 -0300 Subject: [PATCH 07/10] docs(thread_aware): fix misleading wording and remove duplication in Generic Bounds Addresses the follow-up Copilot review on PR #740: - "one bound per relocated field" was misleading, since the derive de-duplicates identical field-type predicates and falls back to bounding reached parameters for a field that names the type being derived. Reworded to state the field-type rule and note the recursive-type exception in plain language. - Remove the duplicated `Marked` example and the duplicated "bare ThreadAware bound" sentence that the previous edit left behind - one of each now. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- crates/thread_aware/src/lib.rs | 29 ++++++++--------------------- 1 file changed, 8 insertions(+), 21 deletions(-) diff --git a/crates/thread_aware/src/lib.rs b/crates/thread_aware/src/lib.rs index 8b553ebc7..4dfc17151 100644 --- a/crates/thread_aware/src/lib.rs +++ b/crates/thread_aware/src/lib.rs @@ -161,15 +161,15 @@ mod thread; /// * `#[thread_aware(skip)]`: Prevents a field from being recursively transferred. /// /// # Generic Bounds -/// The derive adds one bound per relocated field: `where : ThreadAware`. A +/// The derive bounds each relocated field by its own type - `where : ThreadAware` - +/// not by the type parameters inside it. A field that refers back to the type being derived is the +/// exception: it bounds the parameters it reaches instead, so recursive types still compile. A /// `#[thread_aware(skip)]` field isn't relocated, so it contributes `where Self: Send` instead, /// which is all the `ThreadAware: Send` supertrait needs. /// -/// Bounds are on the whole field type, not the parameters inside it. A `Wrapper` field bounds -/// `Wrapper: ThreadAware` and defers to that wrapper's own impl, so a wrapper that is -/// `ThreadAware` for every `T` keeps deriving even where `T` isn't. -/// -/// The derive doesn't look inside function pointers, so a variance marker written as +/// Bounding the field type means a `Wrapper` field defers to that wrapper's own impl, so a +/// wrapper that is `ThreadAware` for every `T` keeps deriving even where `T` isn't. The derive +/// doesn't look inside function pointers, so a variance marker written as /// `PhantomData` adds no bound at all: /// /// ```rust @@ -182,21 +182,8 @@ mod thread; /// } /// ``` /// -/// If you already wrote a bare `ThreadAware` bound yourself, the derive assumes it's this trait -/// and skips the duplicate; qualify the path to disambiguate. -/// -/// ```rust -/// # use core::marker::PhantomData; -/// # use thread_aware::ThreadAware; -/// #[derive(ThreadAware)] -/// struct Marked { -/// // `PhantomData<*const T>` would make `Marked` `!Send`; this does not. -/// marker: PhantomData, -/// } -/// ``` -/// -/// A trait referred to by the bare name `ThreadAware` in your own bounds is assumed to be this -/// crate's and suppresses the generated bound; qualify the path to disambiguate. +/// If you already wrote a bare `ThreadAware` bound yourself, the derive assumes it's this trait and +/// skips the duplicate; qualify the path to disambiguate. /// /// # Example /// ```rust From 082f93f7a8d7860ec722fccbc58681ab7e68eb91 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pato=20Sanda=C3=B1a?= <1194304+psandana@users.noreply.github.com> Date: Fri, 11 Sep 2026 09:28:52 -0300 Subject: [PATCH 08/10] feat(thread_aware_macros): add #[thread_aware(bound = "...")] escape hatch Addresses review feedback on PR #740 (Vaiz / Clawpilot): a type alias can hide a recursive self-reference from the derive. With `type Children = Vec>` and `struct Node { value: T, children: Children }`, the derive only sees the syntactic field type `Children`, not that it expands to `Vec>`, so its self-reference guard doesn't fire and it emits the circular `where Children: ThreadAware`, which overflows. A proc-macro can't resolve aliases, so it can't detect this case on its own. Add a container attribute `#[thread_aware(bound = "...")]` (the serde-style escape hatch) that replaces the inferred `where` predicates with the author's own - `#[thread_aware(bound = "T: ThreadAware")]` restores the parameter bound the body needs, without skipping relocation. - New `parse_container_attrs` with unit tests for the bound/duplicate/unknown/ invalid-predicate cases. - Snapshot pinning the override expansion, and a `derive_compiles.rs` regression that compiles and relocates the alias-recursion case. - Documents the attribute in the derive's `# Attributes` and `# Generic Bounds`. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- crates/thread_aware/src/lib.rs | 14 +++- crates/thread_aware/tests/derive_compiles.rs | 37 +++++++++ .../src/field_attrs.rs | 75 +++++++++++++++++++ crates/thread_aware_macros_impl/src/lib.rs | 17 ++++- .../thread_aware_macros_impl/tests/derive.rs | 16 ++++ ...und_override_replaces_inferred_bounds.snap | 25 +++++++ 6 files changed, 179 insertions(+), 5 deletions(-) create mode 100644 crates/thread_aware_macros_impl/tests/snapshots/derive__container_bound_override_replaces_inferred_bounds.snap diff --git a/crates/thread_aware/src/lib.rs b/crates/thread_aware/src/lib.rs index 4dfc17151..b22fba470 100644 --- a/crates/thread_aware/src/lib.rs +++ b/crates/thread_aware/src/lib.rs @@ -158,14 +158,20 @@ mod thread; /// Unions are not supported and will produce a compile error. /// /// # Attributes -/// * `#[thread_aware(skip)]`: Prevents a field from being recursively transferred. +/// * `#[thread_aware(skip)]` (on a field): moves the field without calling `relocate` on it. +/// * `#[thread_aware(bound = "...")]` (on the type): replaces the derive's inferred `where` +/// predicates with your own. An escape hatch for a field whose bound the derive can't infer - +/// most often a type alias that hides a recursive self-reference, where the inferred field-type +/// bound would be circular. Write `#[thread_aware(bound = "T: ThreadAware")]` to bound the +/// parameter instead. /// /// # Generic Bounds /// The derive bounds each relocated field by its own type - `where : ThreadAware` - /// not by the type parameters inside it. A field that refers back to the type being derived is the -/// exception: it bounds the parameters it reaches instead, so recursive types still compile. A -/// `#[thread_aware(skip)]` field isn't relocated, so it contributes `where Self: Send` instead, -/// which is all the `ThreadAware: Send` supertrait needs. +/// exception: it bounds the parameters it reaches instead, so recursive types still compile (a +/// recursion hidden behind a type alias is invisible to the derive - use `#[thread_aware(bound = +/// "...")]` there). A `#[thread_aware(skip)]` field isn't relocated, so it contributes +/// `where Self: Send` instead, which is all the `ThreadAware: Send` supertrait needs. /// /// Bounding the field type means a `Wrapper` field defers to that wrapper's own impl, so a /// wrapper that is `ThreadAware` for every `T` keeps deriving even where `T` isn't. The derive diff --git a/crates/thread_aware/tests/derive_compiles.rs b/crates/thread_aware/tests/derive_compiles.rs index 9b3b45375..f26e523d1 100644 --- a/crates/thread_aware/tests/derive_compiles.rs +++ b/crates/thread_aware/tests/derive_compiles.rs @@ -717,6 +717,43 @@ fn recursive_and_slice_generics_compile_and_relocate() { assert_eq!(node.children[0].value.relocations, 1, "relocation reaches recursive children"); } +/// A recursive self-reference hidden behind a type alias. +/// +/// The derive only sees the syntactic field type `AliasChildren`, not that it expands to +/// `Vec>`, so it can't tell the field is a recursive self-reference. Left to infer, it +/// would emit the circular `where AliasChildren: ThreadAware`. The `#[thread_aware(bound = ...)]` +/// escape hatch replaces the inferred bounds with the parameter bound the body actually needs. +type AliasChildren = Vec>; + +#[derive(ThreadAware)] +#[thread_aware(bound = "T: thread_aware::ThreadAware")] +struct AliasNode { + value: T, + children: AliasChildren, +} + +#[test] +fn alias_hidden_recursion_compiles_with_bound_override() { + // `Rc<()>` is not `ThreadAware`, but the field is a `Vec` of the type itself, so the override + // bound `T: ThreadAware` is what the body needs - and it must relocate, not skip. + assert_thread_aware::>(); + + let (source, destination) = thread_pair(); + let mut node = AliasNode { + value: Tracker::default(), + children: vec![AliasNode { + value: Tracker::default(), + children: Vec::new(), + }], + }; + node.relocate(source.as_ref(), &destination); + assert_eq!(node.value.relocations, 1); + assert_eq!( + node.children[0].value.relocations, 1, + "relocation reaches aliased recursive children" + ); +} + /// A trait of the user's own that happens to be called `ThreadAware`, named by a qualified /// path. /// diff --git a/crates/thread_aware_macros_impl/src/field_attrs.rs b/crates/thread_aware_macros_impl/src/field_attrs.rs index 1708e89b0..f6cca3e1b 100644 --- a/crates/thread_aware_macros_impl/src/field_attrs.rs +++ b/crates/thread_aware_macros_impl/src/field_attrs.rs @@ -10,6 +10,39 @@ pub(crate) struct FieldAttrCfg { pub(crate) skip: bool, } +/// Configuration parsed from `#[thread_aware(...)]` on the derive input itself (the struct or enum). +#[derive(Default, Debug)] +pub(crate) struct ContainerAttrCfg { + /// An explicit replacement for the derive's inferred `where` predicates, from + /// `#[thread_aware(bound = "...")]`. When set, the derive emits these predicates verbatim + /// instead of inferring bounds from the fields - the escape hatch for a field type whose + /// obligation the derive can't infer correctly (for example a type alias that hides a + /// recursive self-reference). + pub(crate) bound: Option>, +} + +/// Parses the `thread_aware` attributes on the derive input (the struct or enum). +pub(crate) fn parse_container_attrs(attrs: &[Attribute]) -> syn::Result { + let mut cfg = ContainerAttrCfg::default(); + for attr in attrs.iter().filter(|a| a.path().is_ident("thread_aware")) { + attr.parse_nested_meta(|meta| { + if meta.path.is_ident("bound") { + if cfg.bound.is_some() { + return Err(meta.error("duplicate 'bound'")); + } + let lit: syn::LitStr = meta.value()?.parse()?; + let parsed: syn::punctuated::Punctuated = + lit.parse_with(syn::punctuated::Punctuated::parse_terminated)?; + cfg.bound = Some(parsed.into_iter().collect()); + Ok(()) + } else { + Err(meta.error("unknown thread_aware attribute (only 'bound' is supported on the type)")) + } + })?; + } + Ok(cfg) +} + /// Parses the `thread_aware` attributes on a field. pub(crate) fn parse_field_attrs(attrs: &[Attribute]) -> syn::Result { let mut cfg = FieldAttrCfg::default(); @@ -135,4 +168,46 @@ mod tests { let err_msg = result.unwrap_err().to_string(); assert!(err_msg.contains("duplicate")); } + + #[test] + fn test_parse_container_attrs_none() { + // No container attribute leaves the override unset. + let attrs: Vec = vec![]; + let cfg = parse_container_attrs(&attrs).unwrap(); + assert!(cfg.bound.is_none()); + } + + #[test] + fn test_parse_container_attrs_bound() { + // A `bound = "..."` parses into the listed where-predicates. + let attrs: Vec = vec![parse_quote! { #[thread_aware(bound = "T: ThreadAware, U: Send")] }]; + let cfg = parse_container_attrs(&attrs).unwrap(); + let bounds = cfg.bound.expect("bound should be set"); + assert_eq!(bounds.len(), 2); + } + + #[test] + fn test_parse_container_attrs_duplicate_bound() { + // Two `bound` values are rejected. + let attrs: Vec = vec![parse_quote! { #[thread_aware(bound = "T: ThreadAware", bound = "U: Send")] }]; + let result = parse_container_attrs(&attrs); + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("duplicate 'bound'")); + } + + #[test] + fn test_parse_container_attrs_unknown() { + // An unknown container key is rejected. + let attrs: Vec = vec![parse_quote! { #[thread_aware(nonsense = "x")] }]; + let result = parse_container_attrs(&attrs); + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("only 'bound' is supported")); + } + + #[test] + fn test_parse_container_attrs_invalid_predicate() { + // A `bound` string that isn't a valid where-predicate list surfaces a parse error. + let attrs: Vec = vec![parse_quote! { #[thread_aware(bound = "!!!")] }]; + parse_container_attrs(&attrs).unwrap_err(); + } } diff --git a/crates/thread_aware_macros_impl/src/lib.rs b/crates/thread_aware_macros_impl/src/lib.rs index 140902bf7..a40552117 100644 --- a/crates/thread_aware_macros_impl/src/lib.rs +++ b/crates/thread_aware_macros_impl/src/lib.rs @@ -34,7 +34,7 @@ mod field_attrs; mod struct_gen; use enum_gen::build_enum_body; -use field_attrs::parse_field_attrs; +use field_attrs::{parse_container_attrs, parse_field_attrs}; use struct_gen::build_struct_body; /// Core implementation used by both `thread_aware_macros` and `oxidizer_macros`. @@ -105,6 +105,21 @@ pub(crate) fn param_idents() -> (syn::Ident, syn::Ident) { fn add_bounds(input: &DeriveInput, root_path: &Path) -> syn::Result { let mut generics = input.generics.clone(); + // Escape hatch: an explicit `#[thread_aware(bound = "...")]` on the type replaces the derive's + // inferred `where` predicates wholesale. Use it when the derive can't infer the right bound for + // a field - for example a type alias like `type Children = Vec>` hides that the field + // is a recursive self-reference, so the inferred `Children: ThreadAware` becomes circular. + // Writing `#[thread_aware(bound = "T: ThreadAware")]` restores the parameter bound instead. + if let Some(bounds) = parse_container_attrs(&input.attrs)?.bound { + if !bounds.is_empty() { + let where_clause = generics.make_where_clause(); + for predicate in bounds { + where_clause.predicates.push(predicate); + } + } + return Ok(generics); + } + // Type parameters in declaration order (for deterministic output) and as a set (for fast // membership tests). let generic_param_idents: Vec = generics diff --git a/crates/thread_aware_macros_impl/tests/derive.rs b/crates/thread_aware_macros_impl/tests/derive.rs index 7e1d1be48..e96327f8f 100644 --- a/crates/thread_aware_macros_impl/tests/derive.rs +++ b/crates/thread_aware_macros_impl/tests/derive.rs @@ -139,6 +139,22 @@ fn self_reference_through_the_self_keyword_falls_back_to_parameters() { assert_snapshot!(expand(input)); } +#[test] +#[cfg_attr(miri, ignore)] +fn container_bound_override_replaces_inferred_bounds() { + // `#[thread_aware(bound = "...")]` replaces the inferred field-type predicates - the escape + // hatch for a field whose bound the derive can't infer, such as a type alias hiding recursion. + let input = quote! { + #[derive(ThreadAware)] + #[thread_aware(bound = "T: ThreadAware")] + struct Node { + value: T, + children: Children, + } + }; + assert_snapshot!(expand(input)); +} + #[test] #[cfg_attr(miri, ignore)] fn generics_prebound_bare_no_dup() { diff --git a/crates/thread_aware_macros_impl/tests/snapshots/derive__container_bound_override_replaces_inferred_bounds.snap b/crates/thread_aware_macros_impl/tests/snapshots/derive__container_bound_override_replaces_inferred_bounds.snap new file mode 100644 index 000000000..ecd8e1522 --- /dev/null +++ b/crates/thread_aware_macros_impl/tests/snapshots/derive__container_bound_override_replaces_inferred_bounds.snap @@ -0,0 +1,25 @@ +--- +source: crates/thread_aware_macros_impl/tests/derive.rs +expression: expand(input) +--- +impl ::thread_aware::ThreadAware for Node +where + T: ThreadAware, +{ + fn relocate( + &mut self, + __thread_aware_source: ::core::option::Option<&::thread_aware::Thread>, + __thread_aware_destination: &::thread_aware::Thread, + ) { + ::thread_aware::ThreadAware::relocate( + &mut self.value, + __thread_aware_source, + __thread_aware_destination, + ); + ::thread_aware::ThreadAware::relocate( + &mut self.children, + __thread_aware_source, + __thread_aware_destination, + ); + } +} From 4308583230026d9ae8b566bcd9c788d45ddcb9e2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pato=20Sanda=C3=B1a?= <1194304+psandana@users.noreply.github.com> Date: Fri, 11 Sep 2026 13:15:33 -0300 Subject: [PATCH 09/10] test(thread_aware_macros): close the coverage gap on the bound escape hatch The `bound` escape hatch added one uncovered region (the `if !bounds.is_empty()` skip path, never exercised) and a derived `Debug` that no passing test invokes - together the single miss that dropped project coverage to 99.9%. - `parse_container_attrs` now rejects an empty `bound = ""` instead of silently emitting no predicates, so `add_bounds` can drop the `is_empty` guard (bounds are always non-empty when present). Covered by a new unit test. - Add a test that formats `ContainerAttrCfg` via its derived `Debug`. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../src/field_attrs.rs | 26 ++++++++++++++++--- crates/thread_aware_macros_impl/src/lib.rs | 9 +++---- 2 files changed, 26 insertions(+), 9 deletions(-) diff --git a/crates/thread_aware_macros_impl/src/field_attrs.rs b/crates/thread_aware_macros_impl/src/field_attrs.rs index f6cca3e1b..809b0a8d2 100644 --- a/crates/thread_aware_macros_impl/src/field_attrs.rs +++ b/crates/thread_aware_macros_impl/src/field_attrs.rs @@ -14,10 +14,10 @@ pub(crate) struct FieldAttrCfg { #[derive(Default, Debug)] pub(crate) struct ContainerAttrCfg { /// An explicit replacement for the derive's inferred `where` predicates, from - /// `#[thread_aware(bound = "...")]`. When set, the derive emits these predicates verbatim - /// instead of inferring bounds from the fields - the escape hatch for a field type whose - /// obligation the derive can't infer correctly (for example a type alias that hides a - /// recursive self-reference). + /// `#[thread_aware(bound = "...")]`. When set it is non-empty, and the derive emits these + /// predicates verbatim instead of inferring bounds from the fields - the escape hatch for a + /// field type whose obligation the derive can't infer correctly (for example a type alias that + /// hides a recursive self-reference). pub(crate) bound: Option>, } @@ -33,6 +33,9 @@ pub(crate) fn parse_container_attrs(attrs: &[Attribute]) -> syn::Result = lit.parse_with(syn::punctuated::Punctuated::parse_terminated)?; + if parsed.is_empty() { + return Err(meta.error("empty 'bound' - list at least one predicate, or omit the attribute to infer bounds")); + } cfg.bound = Some(parsed.into_iter().collect()); Ok(()) } else { @@ -210,4 +213,19 @@ mod tests { let attrs: Vec = vec![parse_quote! { #[thread_aware(bound = "!!!")] }]; parse_container_attrs(&attrs).unwrap_err(); } + + #[test] + fn test_parse_container_attrs_empty_bound() { + // An empty `bound = ""` is rejected rather than silently emitting no predicates. + let attrs: Vec = vec![parse_quote! { #[thread_aware(bound = "")] }]; + let err = parse_container_attrs(&attrs).unwrap_err(); + assert!(err.to_string().contains("empty 'bound'")); + } + + #[test] + fn test_container_attr_cfg_debug() { + // Exercise the derived `Debug` (the error-path tests need the bound but never format it). + let cfg = ContainerAttrCfg::default(); + assert!(format!("{cfg:?}").contains("ContainerAttrCfg")); + } } diff --git a/crates/thread_aware_macros_impl/src/lib.rs b/crates/thread_aware_macros_impl/src/lib.rs index a40552117..bfd249eee 100644 --- a/crates/thread_aware_macros_impl/src/lib.rs +++ b/crates/thread_aware_macros_impl/src/lib.rs @@ -111,11 +111,10 @@ fn add_bounds(input: &DeriveInput, root_path: &Path) -> syn::Result: ThreadAware` becomes circular. // Writing `#[thread_aware(bound = "T: ThreadAware")]` restores the parameter bound instead. if let Some(bounds) = parse_container_attrs(&input.attrs)?.bound { - if !bounds.is_empty() { - let where_clause = generics.make_where_clause(); - for predicate in bounds { - where_clause.predicates.push(predicate); - } + // `parse_container_attrs` rejects an empty list, so there is at least one predicate here. + let where_clause = generics.make_where_clause(); + for predicate in bounds { + where_clause.predicates.push(predicate); } return Ok(generics); } From 04554f5f3f1accfbba1ecb3328278fc2d60bdb6c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pato=20Sanda=C3=B1a?= <1194304+psandana@users.noreply.github.com> Date: Fri, 11 Sep 2026 15:07:41 -0300 Subject: [PATCH 10/10] fix(thread_aware_macros): traverse qualified-self types for bound inference Addresses a Copilot review comment on PR #740: `type_reaches_ident` walked only a path's segments and their angle-bracketed arguments, never `TypePath::qself`. A relocated field written as `::Item` therefore looked like it reached no parameter, so no `::Item: ThreadAware` predicate was emitted even though the generated body relocates it - leaving an impl that fails to compile. Traverse the qself type for reachability. The self-reference check benefits too: a `::Item` field is now correctly seen as self-referential. Adds a snapshot and a `derive_compiles.rs` regression that relocates a `::Item` field. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- crates/thread_aware/tests/derive_compiles.rs | 26 +++++++++++++++++++ crates/thread_aware_macros_impl/src/lib.rs | 9 ++++++- .../thread_aware_macros_impl/tests/derive.rs | 11 ++++++++ ...fied_self_field_reaches_its_parameter.snap | 20 ++++++++++++++ 4 files changed, 65 insertions(+), 1 deletion(-) create mode 100644 crates/thread_aware_macros_impl/tests/snapshots/derive__qualified_self_field_reaches_its_parameter.snap diff --git a/crates/thread_aware/tests/derive_compiles.rs b/crates/thread_aware/tests/derive_compiles.rs index f26e523d1..fe306eadc 100644 --- a/crates/thread_aware/tests/derive_compiles.rs +++ b/crates/thread_aware/tests/derive_compiles.rs @@ -717,6 +717,32 @@ fn recursive_and_slice_generics_compile_and_relocate() { assert_eq!(node.children[0].value.relocations, 1, "relocation reaches recursive children"); } +/// A field written as a qualified-self projection, `::Item`. +/// +/// The field reaches `T` only through the `qself` type, not the path segments +/// (`ItemProvider::Item`), so the derive has to look at the `qself` to know the field owes a bound +/// - otherwise the body relocates a field the impl never bounded and fails to compile. +trait ItemProvider { + type Item; +} + +struct ConcreteProvider; + +impl ItemProvider for ConcreteProvider { + type Item = Tracker; +} + +#[derive(ThreadAware)] +struct ProjectedField(::Item); + +#[test] +fn qualified_self_projection_field_is_bounded_and_relocates() { + let (source, destination) = thread_pair(); + let mut value = ProjectedField::(Tracker::default()); + value.relocate(source.as_ref(), &destination); + assert_eq!(value.0.relocations, 1); +} + /// A recursive self-reference hidden behind a type alias. /// /// The derive only sees the syntactic field type `AliasChildren`, not that it expands to diff --git a/crates/thread_aware_macros_impl/src/lib.rs b/crates/thread_aware_macros_impl/src/lib.rs index bfd249eee..0b5002e58 100644 --- a/crates/thread_aware_macros_impl/src/lib.rs +++ b/crates/thread_aware_macros_impl/src/lib.rs @@ -350,7 +350,14 @@ fn param_has_thread_aware_bound(generics: &syn::Generics, ident: &syn::Ident, th #[cfg_attr(coverage_nightly, coverage(off))] // can't figure out how to get to 100% coverage of this function fn type_reaches_ident(ty: &Type, targets: &HashSet) -> bool { match ty { - Type::Path(TypePath { path, .. }) => { + Type::Path(TypePath { qself, path, .. }) => { + // A qualified-self type like `::Item` reaches whatever its self type + // (`T`) reaches; the path after `as` names the trait/assoc-item, not the parameter. + if let Some(qself) = qself + && type_reaches_ident(&qself.ty, targets) + { + return true; + } for segment in &path.segments { if targets.contains(&segment.ident) { return true; diff --git a/crates/thread_aware_macros_impl/tests/derive.rs b/crates/thread_aware_macros_impl/tests/derive.rs index e96327f8f..95afe63b3 100644 --- a/crates/thread_aware_macros_impl/tests/derive.rs +++ b/crates/thread_aware_macros_impl/tests/derive.rs @@ -155,6 +155,17 @@ fn container_bound_override_replaces_inferred_bounds() { assert_snapshot!(expand(input)); } +#[test] +#[cfg_attr(miri, ignore)] +fn qualified_self_field_reaches_its_parameter() { + // `::Item` reaches `T` through the qself type, so the field owes a bound. + let input = quote! { + #[derive(ThreadAware)] + struct Projected(::Item); + }; + assert_snapshot!(expand(input)); +} + #[test] #[cfg_attr(miri, ignore)] fn generics_prebound_bare_no_dup() { diff --git a/crates/thread_aware_macros_impl/tests/snapshots/derive__qualified_self_field_reaches_its_parameter.snap b/crates/thread_aware_macros_impl/tests/snapshots/derive__qualified_self_field_reaches_its_parameter.snap new file mode 100644 index 000000000..60fef15a3 --- /dev/null +++ b/crates/thread_aware_macros_impl/tests/snapshots/derive__qualified_self_field_reaches_its_parameter.snap @@ -0,0 +1,20 @@ +--- +source: crates/thread_aware_macros_impl/tests/derive.rs +expression: expand(input) +--- +impl ::thread_aware::ThreadAware for Projected +where + ::Item: ::thread_aware::ThreadAware, +{ + fn relocate( + &mut self, + __thread_aware_source: ::core::option::Option<&::thread_aware::Thread>, + __thread_aware_destination: &::thread_aware::Thread, + ) { + ::thread_aware::ThreadAware::relocate( + &mut self.0, + __thread_aware_source, + __thread_aware_destination, + ); + } +}