fix(thread_aware_macros): bound phantom-only generics by Send - #678
fix(thread_aware_macros): bound phantom-only generics by Send#678martinhavelka (wukchung) wants to merge 15 commits into
Conversation
#[derive(ThreadAware)] dropped any generic parameter that appeared only inside a PhantomData field, so the generated impl could not satisfy the ThreadAware: Send supertrait and failed to compile. Phantom-only parameters are now collected separately and bound by Send rather than ThreadAware, which would over-restrict: Arc<i32> is Send but deliberately not ThreadAware, and PhantomData<Arc<i32>> is a legitimate field type. Also adds impl<T: ?Sized + Send> ThreadAware for PhantomData<T>, so a PhantomData nested inside a tuple, array or reference is relocated as a no-op instead of failing the trait bound. The five updated snapshots recorded the buggy expansion. Because the derive was covered only by token-stream snapshots that are never compiled, the tests passed while the feature was unusable; the new compile-and-run cases in thread_aware close that gap. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 71d0a045-ba72-4cca-8e27-021121f8a755
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## main #678 +/- ##
=======================================
Coverage 100.0% 100.0%
=======================================
Files 543 543
Lines 60394 60431 +37
=======================================
+ Hits 60394 60431 +37
Flags with carried forward coverage won't be shown. Click here to find out more. ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
The coverage gate and mutation testing both flagged the new code. Six cases had no test at all: a phantom-only generic that already carries Send or ThreadAware (the deduplication check never ran, because the closure it lives in is only reached when the parameter has bounds), a declared-but-unused type parameter (neither relocated nor phantom, so both branches fall through), and a generic reachable only through a reference, tuple, array, parenthesized or grouped type inside PhantomData -- the five recursion arms, all of which mutation testing showed could be deleted with no test noticing. Also adds a lifetime and const-generic case, since only type parameters can carry bounds and nothing exercised the skip. Local run over the affected package set: thread_aware_macros_impl lib.rs at 100% lines, and 42 of 42 viable mutants caught. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 71d0a045-ba72-4cca-8e27-021121f8a755
martinhavelka (wukchung)
left a comment
There was a problem hiding this comment.
🤖 Automated multi-model review: five independent reviewers (claude-opus-5, gpt-5.6-sol, claude-opus-4.7, gpt-5.5, grok-4.5), with every finding verified by compiling the shape in a worktree before posting. Roughly 18 raw findings, 5 survived verification. Refuted claims are not listed.
The core direction of the change is right. Splitting "reachable through a relocated field" from "named only inside PhantomData" is the correct distinction, ThreadAware-over-Send precedence is right for the simple PhantomData<T> case, the new impl<T: ?Sized + Send> ThreadAware for PhantomData<T> is sound with no overlapping impl, and ::core::marker::Send is the correct emitted path for no_std. Both originally reported shapes do now work.
What does not hold up is the per-parameter inference used to derive the Send bound - see the inline comment on collect_phantom_generics. It is correct for PhantomData<T>, PhantomData<(T,)> and PhantomData<[T; N]>, and wrong for references, Arc, slices and projections, all of which still fail to compile. A structural where X: Send predicate fixes every case at once and removes the need for the recursive walk entirely.
One item could not be anchored inline, because the file is not part of the diff:
[Medium] The derive rustdoc is now false. crates/thread_aware/src/lib.rs:239-240 still says generic type parameters "appearing in non-skipped fields automatically receive a ::thread_aware::ThreadAware bound (occurrences only inside PhantomData<..> are ignored)". Phantom-only parameters now receive a Send bound, which is a user-visible API change - a caller's T must now be Send. This is the only place the bound rule is documented, and the wrapper crate has no equivalent text. The "non-skipped fields" clause is inaccurate as well, per the note on collect_generics_in_fields.
No vote cast.
There was a problem hiding this comment.
Pull request overview
Fixes #[derive(ThreadAware)] for generics that are only mentioned inside PhantomData, by ensuring the generated impl adds an appropriate bound (Send instead of ThreadAware) and by providing a ThreadAware impl for PhantomData<T> itself so nested occurrences relocate as a no-op.
Changes:
- Update derive bound selection to distinguish generics reached via relocated fields (
ThreadAware) vsPhantomData-only (Send). - Add
ThreadAware for PhantomData<T>(no-op relocate) withT: ?Sized + Send. - Expand tests (compile-and-run + snapshots) to cover phantom-only generics and nested
PhantomDatashapes.
Reviewed changes
Copilot reviewed 18 out of 18 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
| crates/thread_aware/tests/derive_compiles.rs | Adds compile-and-run regression tests covering phantom-only generics and nested PhantomData. |
| crates/thread_aware/src/impls.rs | Adds ThreadAware impl for PhantomData<T> as a relocation no-op with T: Send. |
| crates/thread_aware_macros_impl/src/lib.rs | Implements generic-usage classification and adds Send bounds for phantom-only parameters. |
| crates/thread_aware_macros_impl/tests/derive.rs | Adds snapshot-driven tests for phantom-only bound behavior and additional shapes. |
| crates/thread_aware_macros_impl/tests/snapshots/derive__phantom_tuple_generic_gets_send_bound.snap | New snapshot asserting Send bound for tuple-contained phantom generic. |
| crates/thread_aware_macros_impl/tests/snapshots/derive__phantom_ref_generic_gets_send_bound.snap | New snapshot asserting Send bound for ref-contained phantom generic. |
| crates/thread_aware_macros_impl/tests/snapshots/derive__phantom_prebound_thread_aware_no_send.snap | New snapshot asserting no redundant Send when already ThreadAware. |
| crates/thread_aware_macros_impl/tests/snapshots/derive__phantom_prebound_send_no_dup.snap | New snapshot asserting no duplicate Send bound. |
| crates/thread_aware_macros_impl/tests/snapshots/derive__phantom_paren_generic_gets_send_bound.snap | New snapshot asserting Send bound for paren-contained phantom generic. |
| crates/thread_aware_macros_impl/tests/snapshots/derive__phantom_group_generic_gets_send_bound.snap | New snapshot asserting Send bound for group-contained phantom generic. |
| crates/thread_aware_macros_impl/tests/snapshots/derive__phantom_data_unnamed_fields.snap | Updates snapshot to show Send bound added for phantom-only generic. |
| crates/thread_aware_macros_impl/tests/snapshots/derive__phantom_data_named_fields.snap | Updates snapshot to show Send bound added for phantom-only generic. |
| crates/thread_aware_macros_impl/tests/snapshots/derive__phantom_array_generic_gets_send_bound.snap | New snapshot asserting Send bound for array-contained phantom generic. |
| crates/thread_aware_macros_impl/tests/snapshots/derive__generics_unused_param_gets_no_bound.snap | New snapshot asserting unused generic params remain unbounded. |
| crates/thread_aware_macros_impl/tests/snapshots/derive__generics_lifetime_and_const_params_untouched.snap | New snapshot asserting lifetime/const generics are untouched while type param is bounded. |
| crates/thread_aware_macros_impl/tests/snapshots/derive__generics_add_bounds.snap | Updates snapshot to add Send bound for phantom-only generic. |
| crates/thread_aware_macros_impl/tests/snapshots/derive__enum_unnamed_phantom_data.snap | Updates snapshot to add Send bounds for phantom-only enum generics. |
| crates/thread_aware_macros_impl/tests/snapshots/derive__enum_named_phantom_data.snap | Updates snapshot to add Send bounds for phantom-only enum generics. |
💡 Configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
…urally
Addresses the review findings on the phantom bound selection.
Deriving a per-parameter `Send` bound from the parameters named inside
`PhantomData<X>` is not sound, because `X: Send` does not follow from
"every parameter named in X is Send":
PhantomData<&'a T> is Send only when T: Sync
PhantomData<Arc<T>> is Send only when T: Send + Sync
PhantomData<[T]> was reached through no Type::Slice arm, so
the parameter got no bound at all
PhantomData<T::Item> says nothing about T
Each of these produced an impl that failed the `ThreadAware: Send`
supertrait check. The classification was also mutually exclusive, so a
parameter that was both relocated and named inside `PhantomData` kept
only the `ThreadAware` bound and silently dropped the second obligation.
The `Send` obligation now becomes a where-predicate on the phantom
argument itself, emitted independently of the relocated classification.
The compiler reduces it correctly for every shape, so `Type::Slice`,
projections, raw pointers and bare functions all follow without
per-shape reasoning, and `collect_phantom_generics` is deleted.
The predicate binds the argument `X` rather than `PhantomData<X>`:
`X: Send` yields both `PhantomData<X>: Send` for the supertrait and
`PhantomData<X>: ThreadAware` when the marker is nested inside a
relocated field, whereas the reverse does not hold.
Two consequences fall out of the same change:
Skipped fields are no longer classified as relocated. A field annotated
`#[thread_aware(skip)]` is never relocated, so like a phantom field it
only needs to be `Send`; it previously forced `T: ThreadAware`, which
ruled out `Arc<i32>` and defeated the purpose of the attribute.
Bound de-duplication by name is gone. Matching the last path segment
against `Send` meant any unrelated trait so named suppressed the real
bound; a redundant predicate is legal, so the canonical one is now
always emitted.
Tests: the six shapes above are compile-and-run cases in
derive_compiles.rs, not snapshots. The snapshot suite never builds its
expansion, which is how the reference case was recorded as correct while
being unusable; its input was not even a legal struct definition. It is
replaced by a legal one, and snapshots now pin the argument-level
predicate for slices, projections, the both-obligations case, skipped
generics, an unrelated trait named `Send`, and a concrete argument that
must produce no predicate at all.
Docs: the derive's `# Generic Bounds` section described the old rule and
is updated to state where the `Send` obligation lands and why.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 25 out of 25 changed files in this pull request and generated 1 comment.
Suppressed comments (2)
crates/thread_aware_macros_impl/tests/derive.rs:370
- In test code, prefer
.unwrap()over.expect(...)so failures include the full backtrace without requiring a hand-written message (per repo guidelines). These new.expect(...)calls can be simplified.
// A parameter used directly and inside `PhantomData` carries both bounds; treating
// the two as mutually exclusive dropped the phantom one.
let input = quote! {
#[derive(ThreadAware)]
struct RelocatedAndPhantom<'a, T: 'a>(T, core::marker::PhantomData<&'a T>);
crates/thread_aware_macros_impl/src/lib.rs:172
collect_generics_in_fieldscurrently walks every field’s type without considering#[thread_aware(skip)], but the generated relocation body explicitly skips those fields (see struct_gen/enum_gen). This means a generic that appears only in a skipped field will still be treated as “relocated” and get aThreadAwarebound, even though it’s never relocated. To match the derive semantics (and the PR description’s “reachable through a relocated field”), skipped fields should be excluded fromusage.relocatedand handled as “non-relocated but still must satisfySendforThreadAware: Send.”
/// Reports whether `ty` names any of the type's own generic parameters.
///
/// Scans tokens rather than matching on [`Type`] variants so that every shape is covered,
/// including qualified paths, bare functions and const-generic expressions. Over-reporting
Self-review of the previous commit found one dead branch and three reachable paths with no test. phantom_data_argument took a Type and re-matched Type::Path, but its only caller has already matched that variant, so the else arm could never be reached and would have shown up as an uncoverable branch. It now takes the path directly. Adds cases for the paths that were reachable but untested: a PhantomData written with no type argument, the same phantom argument reached twice (which must yield a single predicate, not two), and a user-supplied where clause, which the generated predicate has to extend rather than replace.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 28 out of 28 changed files in this pull request and generated no new comments.
Suppressed comments (1)
crates/thread_aware/src/lib.rs:249
- The docs here say the derive emits a
Sendpredicate on the field type forPhantomData<..>markers, but the macro actually places theSendpredicate on thePhantomDataargument type (e.g.&'a T: Send/Arc<T>: Send) to preserve correct auto-trait semantics and to support nestedPhantomDatarelocation via the newThreadAware for PhantomData<_>impl. The text should be updated to match the generated bounds.
/// * A field that is never relocated - a `PhantomData<..>` marker, or one annotated with
/// `#[thread_aware(skip)]` - instead produces a `where` predicate requiring that field's
/// type to be [`Send`], which the `ThreadAware: Send` supertrait demands.
///
/// The `Send` obligation is placed on the field type itself rather than on the type
AGENTS.md requires .unwrap() rather than .expect() in tests, since the backtrace already identifies the failure. Applies to the three calls this branch added in phantom_group_generic_gets_send_bound; the pre-existing call at line 180 is left alone.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 28 out of 28 changed files in this pull request and generated no new comments.
Suppressed comments (1)
crates/thread_aware/src/lib.rs:249
- The docs say non-relocated fields (including
PhantomData<..>) add awherepredicate requiring the field type to beSend, but the derive actually adds a predicate on thePhantomDataargument type (e.g.&'a T: Send), not onPhantomData<&'a T>. This is a good implementation detail (it also enablesPhantomData<X>: ThreadAwarevia the newThreadAware for PhantomData<T>impl), but the documentation should match the generated bounds to avoid confusing users.
/// * A field that is never relocated - a `PhantomData<..>` marker, or one annotated with
/// `#[thread_aware(skip)]` - instead produces a `where` predicate requiring that field's
/// type to be [`Send`], which the `ThreadAware: Send` supertrait demands.
///
/// The `Send` obligation is placed on the field type itself rather than on the type
Closes the remaining review finding: a type merely named `PhantomData` was dropped from the generated body, so a look-alike carrying real data compiled into an impl that silently never relocated it. The body generators no longer test `is_phantom_data`. Every non-skipped field is relocated, and the genuine marker is handled by the `impl ThreadAware for PhantomData<T>` this branch added, which is a no-op. A look-alike is now relocated like any other field, so it either works correctly or fails loudly, instead of silently doing nothing. The name check stays in bound collection, where it is still required: without it a phantom-only parameter would be classified as relocated and forced to implement `ThreadAware`, which is the bug this branch set out to fix. That split matches the ecosystem - `#[derive(Debug)]` relies on `PhantomData`'s own impl rather than special-casing the name in codegen, and serde special-cases `PhantomData` only for bound inference. Behaviour change: a downstream type named `PhantomData` that carries data and currently compiles by being skipped will now fail to compile unless it implements `ThreadAware`. That is the intended direction - loud over silent - and the shape is pathological in practice. The generated impls gain a `relocate` call per marker field. It compiles to nothing, and it is what the standard derives already emit.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 30 out of 30 changed files in this pull request and generated no new comments.
Suppressed comments (1)
crates/thread_aware/src/lib.rs:244
- The docs say a non-relocated field (including
PhantomData<..>) “produces awherepredicate requiring that field's type to beSend”. ForPhantomData<X>, the derive actually adds theSendobligation on the argument type (e.g.X: Send/&'a T: Send/[T]: Send), not onPhantomData<X>itself. Tweaking this wording would make the docs match the generated bounds more precisely.
/// * A field that is never relocated - a `PhantomData<..>` marker, or one annotated with
/// `#[thread_aware(skip)]` - instead produces a `where` predicate requiring that field's
/// type to be [`Send`], which the `ThreadAware: Send` supertrait demands.
An independent six-model review of the previous commits found two high-severity defects, both traceable to the same over-reach: relocating `PhantomData` fields instead of skipping them by name. Reverts that codegen change. `struct_gen`/`enum_gen` test `is_phantom_data` again, and `collect_generics_in_fields` now applies exactly the same test, so the generated header and the generated body agree on which fields are relocated. They had diverged: the body demanded `ThreadAware` for any field named `PhantomData` while bound inference still treated it as a marker, so a look-alike carrying real data failed to compile. The test added for that case hid it by hand-writing the bound the derive was supposed to infer. Replaces the per-field `Send` predicates with a single `Self: Send`. Binding each unrelocated field type was strictly stronger than the real obligation and could not be satisfied at all by a type made `Send` through a manual `unsafe impl` - the standard idiom for raw-pointer and variance markers, and the reason `#[thread_aware(skip)]` exists. Both `PhantomData<*const T>` and a skipped `Rc<T>` produced predicates such as `where *const T: Send` that no instantiation can prove, so the impl compiled but nothing could use it. Both now work and are covered by compile-and-run tests. A marker nested inside a relocated field is the one case `Self: Send` cannot reach, since a `Send` bound on the whole type does not decompose backwards. Those get `where PhantomData<X>: ThreadAware`, which the compiler reduces through the marker's own impl. Fixes the surviving half of the name-matching defect: the `ThreadAware` de-duplication compared only the final path segment, so an unrelated `local::ThreadAware` suppressed the real bound. It now compares the whole path. Dropping the check instead would have made clippy report the resulting duplicate against the user's own source line. Known limitation, now documented and asserted: `PhantomData` is matched syntactically, so a distinct type whose name ends in `PhantomData` is also treated as a marker. A derive macro cannot resolve a path to the type it names, and the alternative caused the two regressions above. Coverage: deleting the argument-inspection helper removes the one uncovered line that failed the gate; an enum case covers the nested-marker merge path. `thread_aware_macros_impl/src/lib.rs` is back to 100% lines.
Three shapes from the independent review whose Send-ness is invisible to a scan over type-parameter identifiers: a marker-only type made Send by a manual unsafe impl, a marker whose Send-ness depends on a const parameter, and a generic hidden behind a type macro. All three already work, because the Self: Send obligation is emitted unconditionally rather than gated on the field type naming a generic. These pin that, so a future attempt to reintroduce the gate fails loudly.
The module carried two claims that a wrapper proc-macro crate reaches for is_phantom_data and FieldAttrCfg. Neither is true: thread_aware_macros references neither, and no other crate in the workspace consumes the module. The identically named helpers in internity_macros_impl are that crate's own. Describes what the module holds and why it is public - it is part of the published surface of a shared implementation crate - without asserting a consumer that does not exist.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 25 out of 25 changed files in this pull request and generated no new comments.
Suppressed comments (1)
crates/thread_aware_macros_impl/src/lib.rs:174
is_same_traittreats any single-segmentThreadAwarebound as equivalent to the emitted::thread_aware::ThreadAware. This is ambiguous: if a user has a different in-scope trait namedThreadAware(e.g. a local trait or a re-export under the same name), the derive may incorrectly suppress adding the required::thread_aware::ThreadAwarebound and the expansion will fail to type-check when it calls::thread_aware::ThreadAware::relocateon aTthat isn't bound by that trait. To avoid incorrect suppression, only treat paths whose segments match the emitted path as the same trait (accepting that this may introduce a redundant bound in the commonuse thread_aware::ThreadAware; T: ThreadAwarecase).
fn is_same_trait(candidate: &Path, emitted: &Path) -> bool {
let candidate_idents: Vec<_> = candidate.segments.iter().map(|s| s.ident.to_string()).collect();
let emitted_idents: Vec<_> = emitted.segments.iter().map(|s| s.ident.to_string()).collect();
candidate_idents == emitted_idents || candidate_idents == ["ThreadAware"]
}
CI spell-check flagged 'recognised' (British spelling; the dictionary is en_US) and 'unprefixed' (not a dictionary word). Both were introduced by this branch's doc comments. Reworded rather than added to .spelling, since neither is a term of art the wordlist should carry.
Two review-probe files were picked up by a 'git add -A' during the spellcheck fix. They were left in the worktree by a concurrent review agent and are explicitly marked as not part of this PR.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 27 out of 27 changed files in this pull request and generated 1 comment.
Suppressed comments (2)
crates/thread_aware/tests/zz_probe_bare.rs:3
- This file is labeled as a “Temporary review probe - not part of the PR”, but it is currently included as a new integration test. It should be removed from the PR to avoid shipping/maintaining ad-hoc probe coverage (and to avoid adding a test that doesn’t assert behavior).
// Temporary review probe - not part of the PR.
// Claim: a bare local trait named `ThreadAware` suppresses the generated bound.
use thread_aware_macros::ThreadAware;
crates/thread_aware/tests/zz_probe_mixed.rs:3
- This file is labeled as a “Temporary review probe - not part of the PR”, but it is currently included as a new integration test. It should be removed from the PR to avoid committing temporary probe code.
// Temporary review probe - not part of the PR.
// Claim: the `generics_lifetime_and_const_params_untouched` snapshot pins an
// expansion that does not compile.
…l trait A review probe surfaced the last of the name-matching defects. is_same_trait accepted a bare single-segment ThreadAware as the derive's own trait, so a user with an unrelated trait of that name in scope had the required bound silently dropped and the generated impl could not compile. A bare name is genuinely ambiguous and a macro cannot resolve it, so the two outcomes are not symmetric. Suppressing the bound breaks the user's-own- trait case outright. Emitting it costs a redundant but legal bound when the name did refer to the real trait, which compiles, warns only under clippy's nursery group, points at the derive attribute rather than the user's own line, and is avoided entirely by writing the qualified path. Correctness wins; only an exact path match now suppresses. This changes pre-existing behaviour, so generics_prebound_no_dup is renamed and re-commented rather than left asserting something no longer true. Its fully qualified sibling still suppresses the duplicate and is unchanged. A compile-and-run test covers the user's-own-trait shape, which was a hard error before this commit.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 26 out of 26 changed files in this pull request and generated no new comments.
Suppressed comments (2)
crates/thread_aware_macros_impl/src/lib.rs:242
- Same as above: this
coverage(off)exclusion/comment appears stale now that the PR adds many tests specifically exercisingcollect_generics_in_type. Consider removing it so coverage reflects this code path.
#[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<syn::Ident>, acc: &mut GenericUsage) -> syn::Result<()> {
crates/thread_aware_macros_impl/src/lib.rs:216
- The
coverage(off)exclusion and the accompanying comment are now misleading (and they also remove these lines from the coverage gate). If the new tests truly cover this logic, this attribute should be removed so coverage accurately reflects regressions.
This issue also appears on line 241 of the same file.
#[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) -> syn::Result<GenericUsage> {
martinhavelka (wukchung)
left a comment
There was a problem hiding this comment.
🤖 Review of af4bdaa1...66156f4a. Six independent reviewers; every finding was re-verified against the current head before posting, and refuted or out-of-scope claims were dropped rather than passed on.
[Low] The title and description describe a mechanism the branch abandoned.
The title says "bound phantom-only generics by Send", and the body says "named only inside PhantomData — bound by Send". The final diff never bounds a phantom-only parameter. It emits where Self: ::core::marker::Send once per type when any field is unrelocated, plus where PhantomData<X>: ThreadAware for markers nested in a relocated field. The doc comment this PR adds to crates/thread_aware/src/lib.rs states that binding the parameters named inside a marker "would be unsound" — so the description currently advertises the original defect as the fix.
Stale details in the body:
- "Five snapshots changed" — 5 modified and 13 added.
- "185 passed" —
cargo test -p thread_aware -p thread_aware_macros_implreports 199 at this head. - "a
PhantomDatanested in a tuple, array or reference relocates as a no-op" — arrays do not work at all;ThreadAwarehas no[T; N]impl (impls.rs:100).
Since the squashed title is what lands in the changelog, it is worth retitling around the actual mechanism — something like "state the Send obligation on Self".
Verified as correct, recorded because the design holds up well under attack: PhantomData<&'a T>, PhantomData<Arc<T>>, PhantomData<[T]>, PhantomData<T::Assoc>, PhantomData<fn(T)>, PhantomData<for<'a> fn(&'a T)>, PhantomData<PhantomData<T>>, PhantomData<dyn Send>, ?Sized parameters, default type parameters and recursive types all compile and instantiate. Stating the obligation on Self correctly defers it instead of over-binding, and the earlier per-parameter Send unsoundness is genuinely gone.
Out of scope — both reproduce identically at the merge base, so they are follow-ups rather than review comments: #[derive(ThreadAware)] on an empty enum fails with E0004 (match self {} is non-exhaustive for an inhabited &mut), and collect_generics_in_type treats the last segment of a qualified path such as concrete::T as a use of the generic T.
Test gap worth noting: impl ThreadAware for PhantomData<T> is the one impl in impls.rs with no unit test of its own, unlike every other impl in that file; it is exercised only transitively from thread_aware/tests/derive_compiles.rs.
…d test inputs Addresses four review comments, each reproduced before fixing. A named enum variant bound every field by name, but the arm emits no statement for a marker or a skipped field, so the generated code carried an unused variable. A downstream crate with deny(warnings) therefore failed to build on a shape this PR exists to enable - EnumNamedPhantom did not compile at the merge base at all. Non-relocated named fields now bind as ield: _. Unnamed variants were already unaffected because they bind _v0. A compile test under deny(warnings) covers both the marker and the skip form. generics_lifetime_and_const_params_untouched pinned an expansion that cannot compile: it relocated &'a T and [u8; N], and the crate implements ThreadAware for neither. The property it claims to check was only ever verified at the token level - the same blind spot this PR's reviewer notes describe. Switched to a field shape the crate can actually relocate. generics_unused_param_gets_no_bound pinned struct UnusedParam<T, U>(T);, which rustc rejects outright (E0392), so no user can write it. The only way to declare a parameter no field mentions is a PhantomData field, and that shape takes the Self: Send predicate rather than no bound - already covered by phantom_only_generic_gets_self_send_predicate. Dropped. Corrected the nested-marker comment, which claimed the reduction is 'correct for every shape of X'. It is correct but not always satisfiable: a nested PhantomData<*const T> reduces to *const T: Send, which nothing can prove, and unlike the top-level case a manual unsafe impl Send for Self cannot discharge it. Noted that such a field needs skip on the enclosing field, and dropped the claim about arrays, which have no ThreadAware impl. Renamed the raw-pointer test to say top_level, since that is the scope it verifies.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 26 out of 26 changed files in this pull request and generated no new comments.
Suppressed comments (1)
crates/thread_aware_macros_impl/tests/derive.rs:70
- The comment says this test ensures no duplicate
ThreadAwarebound when already present, but the expected expansion now does include bothT: ThreadAwareandT: ::thread_aware::ThreadAware(see updated snapshot). This is misleading and makes it harder to understand why the snapshot changed.
fn generics_prebound_bare_is_still_bound() {
// Ensures no duplicate ThreadAware bound when already present.
let input = quote! {
…l trait CI caught what local validation did not: rejecting a bare ThreadAware broke an unrelated crate. bytesbuf writes CallbackMemory<D: ThreadAware + Clone + Send + Sync> against the imported name, so the derive added a second bound and clippy::trait_duplication_in_bounds failed the build on all four platforms. The previous commit weighed a hypothetical user-defined trait named ThreadAware against a redundant bound and picked correctness. That reasoning used the wrong weights: the bare-import form is what real code writes, the duplicate is an error rather than a lint in this workspace, and it lands on the user's own declaration. Emitting the bound as a where-predicate instead was tried and only trades the lint for multiple_bound_locations. A bare name is irreducibly ambiguous - the same limitation as the syntactic PhantomData test, since a macro cannot resolve a path to the item it names - so it is now documented on the derive alongside that one, with qualifying either trait as the workaround. The genuine half of the fix stands: comparison is over every path segment, so an unrelated some_crate::ThreadAware is no longer mistaken for the real trait. That was the bot's original finding, and its compile test now uses a qualified path, which is the case the derive can actually distinguish. Validation covered every crate that derives ThreadAware - anyspawn, bytesbuf, cachet_memory, fetch - not just the two this PR edits, which is how the regression was missed. cachet_memory's test target fails identically without these changes (a tokio feature artifact); its lib is clean.
| // `Send` but deliberately not `ThreadAware`. | ||
| impl<T> ThreadAware for PhantomData<T> | ||
| where | ||
| T: ?Sized + Send, |
There was a problem hiding this comment.
I'm not sure if we care about Send bound here. What if we remove it completely?
| let cfg: FieldAttrCfg = parse_field_attrs(&f.attrs)?; | ||
| bindings.push(quote! { #ident }); | ||
| if !is_phantom_data(&f.ty) && !cfg.skip { | ||
| if is_phantom_data(&f.ty) || cfg.skip { |
There was a problem hiding this comment.
I assume that there is no need for any special handling of phantom data because now it implements ThreadAware
Ralf Biedert (ralfbiedert)
left a comment
There was a problem hiding this comment.
🤖 Requesting changes for the PhantomData look-alike case: the derive can compile while silently omitting required relocation.
| value.relocate(source, destination); | ||
|
|
||
| assert_eq!( | ||
| value.0.value.relocations, 0, |
There was a problem hiding this comment.
🤖 This assertion preserves a derive-contract violation: a qualified data-carrying type whose final segment is PhantomData is treated as the marker and silently receives zero relocations. The new Self: Send bound makes this compile while retaining cross-thread contention. Please restrict the special case to canonical core::marker::PhantomData / std::marker::PhantomData paths.
🤖 This description was written by an AI agent.
Problem
#[derive(ThreadAware)]failed to compile for any type whose generic parameterappears only inside a
PhantomDatafield:Bound collection returned early on
PhantomData, soUgained no bound at alland the generated impl could not satisfy the
ThreadAware: Sendsupertrait.Skipping the field in the relocation body is correct — a marker holds no value —
but skipping it also dropped the only bound that would have made the type
Send.The sole workaround was to widen the user's own public API to satisfy the derive:
A second shape failed differently:
PhantomDatais recognized syntactically onthe field type, so a nested occurrence such as
(PhantomData<T>,)was relocatedlike any other field, and no
ThreadAwareimpl forPhantomDataexisted tosatisfy it.
Change
The
ThreadAware: Sendobligation for fields that are never relocated is nowstated once, on
Self, rather than derived per parameter:ThreadAware, as before;PhantomDatamarker or a#[thread_aware(skip)]field — the impl gainswhere Self: Send;where PhantomData<X>: ThreadAware, which the compiler reduces through thatmarker's own impl.
Stating the obligation structurally rather than inferring it is what makes this
correct for every shape. Two narrower formulations were tried and are unsound:
PhantomData<X>bySend&'a TneedsT: Sync;Arc<T>needsT: Send + Sync;[T]and<T as Tr>::Assocreachable by no traversal at allSendSendby a manualunsafe impl—where *const T: Sendis unprovable, so the impl compiles but no caller can use itSelf: Sendis exactly the obligation the supertrait imposes, and it isdischarged either structurally or by a manual
unsafe impl. Both of the shapesin the table now work, including the raw-pointer variance marker and a skipped
Rc<T>.Adds
impl<T: ?Sized + Send> ThreadAware for PhantomData<T>, so a marker nestedin a tuple, array or reference relocates as a no-op. This also serves
hand-written generic code, not only derived impls.
Also removes bound suppression that matched only the final path segment: an
unrelated
some_crate::ThreadAwarewas treated as this crate's trait, dropping abound the generated body needs. Comparison is now over every segment.
Compatibility
No API change, and no change for any existing consumer. Every crate that derives
ThreadAware—anyspawn,bytesbuf,cachet_memory,fetch— builds andlints unchanged. Types that previously compiled continue to; types that
previously could not now do.
Two limitations are inherent and are documented on the derive. A macro cannot
resolve a path to the item it names, so both
PhantomDataandThreadAwarearematched syntactically: a distinct type whose name ends in
PhantomDataistreated as a marker, and a distinct trait named
ThreadAware, referred to bythat bare name, is assumed to be this crate's. Qualifying either path
disambiguates. Relocating look-alikes unconditionally instead was tried and
reverted — it left bound inference and body generation disagreeing about which
fields are relocated.
Validation
The derive was previously covered only by
instasnapshots, which compare tokenstreams and never compile them. A snapshot can confirm the output matches what
was recorded, but here the recorded output was itself the defect, so the suite
passed while the feature was unusable.
derive__generics_add_bounds.snapheldexactly the
impl<T: ThreadAware, U>shape that fails to compile.The compile-and-run tests in
crates/thread_aware/tests/derive_compiles.rslinkthe real crate and execute the expansion, which is why they catch this class of
bug. They cover phantom-only generics, nested
PhantomData,PhantomDataof ashared reference, of
Arc<T>, of an unsized slice and of an associated-typeprojection, a parameter that is both relocated and phantom, a raw-pointer marker
and a skipped
Rc<T>under manualunsafe impl Send, a skipped generic field,an enum carrying a phantom-only parameter, and named enum variants under
deny(warnings). Two assert behaviour rather than compilation: thatrelocatereaches every non-skipped field exactly once and no skipped one.
To confirm these are genuine regression tests, the source fixes were stashed and
the suite re-run. It reproduced the reported errors verbatim and compiled again
once the fixes were restored.
Snapshots remain, and now pin the bound-selection logic: pre-bound parameters,
lifetime and const parameters, an unrelated trait named
ThreadAware, aconcrete marker argument that must produce no predicate, a repeated argument
that must produce only one, and an existing
whereclause the generatedpredicate has to extend rather than replace.
Reviewer notes
Three snapshots were removed or rewritten because they pinned expansions that
cannot compile —
&'a Tand[u8; N]have noThreadAwareimpl — or inputsrustc rejects outright (
struct UnusedParam<T, U>(T);isE0392). Those are thesame defect class as the bug this PR fixes, one level up.
Named enum variants bound every field by name while emitting no statement for
markers and skipped fields, so generated code carried an unused variable and
broke any consumer using
deny(warnings)— on a shape this PR is what makescompile in the first place. Such fields now bind as
field: _.Out of scope, worth a follow-up:
trybuildcases pinning the diagnostics forshapes that must keep failing, such as unions; and an audit of the other derive
macros in the workspace for the same snapshot-only coverage pattern.
AB#7745298