Skip to content

Conversation

@cpubot
Copy link
Collaborator

@cpubot cpubot commented Oct 9, 2025

Problem

The SchemaRead trait is generic over the lifetime 'de, to which the Reader parameter is also bound in the trait's read function.

pub trait SchemaRead<'de> {
    type Dst;

    fn read(reader: &mut Reader<'de>, dst: &mut MaybeUninit<Self::Dst>) -> ReadResult<()>;
}

This ensures that, when performing zero-copy deserialization (i.e., returning a reference into the Reader's byte buffer), the Reader's buffer must live at least as long as the destination's (dst) lifetime.

This can present a challenge in the implementation of the SchemaRead derive macro.

For example:

#[derive(SchemaRead)]
struct HasRef<'a> {
    name: &'a str
}

The macro must introduce 'de and ensure it outlives 'a.

let lifetimes = generics.lifetimes();
// Extend `'de` to all lifetimes in the generics.
generics
.params
.push(GenericParam::Lifetime(parse_quote!('de: #(#lifetimes)+*)));

Now where this can get tricky is when read impls are generated.

// Generated while iterating through the struct's declared fields.
<&'a str as SchemaRead<'de>>::read(
    reader,
    unsafe { &mut *(&raw mut (*dst_ptr).name).cast::<MaybeUninit<_>>() },
)?;

'de extends 'a, but 'a may not necessarily extend 'de (i.e., 'a may not live as long as 'de).

The way that the macro currently gets around this is by rebinding a type's field lifetime to 'de during code generation, which ensures that the bytes read in the read portion are done so as 'de (and as such satisfies the requirement that the read bytes live for 'de).

/// Ensure target reference types are tied to the `'de` lifetime.
///
/// This ensures that rather than generating:
/// ```ignore
/// <&'a str as SchemaRead>::read(reader, dst)
/// ```
///
/// We generate:
/// ```ignore
/// <&'de str as SchemaRead>::read(reader, dst)
/// ```
///
/// We ensure that `'de` extends to all type parameters via [`append_de_lifetime`].
fn override_ref_lifetime(target: &Type) -> Type {
let mut target = target.clone();
if let Type::Reference(reference) = &mut target {
if let Some(lifetime) = &mut reference.lifetime {
lifetime.ident = Ident::new("de", lifetime.span());
}
}
target
}

This is brittle because it only works for reference types (e.g., &'a T). It does not work for types with generic lifetime bounds (e.g., OtherType<'a>) because they are represented as distinct AST nodes. This is not ideal, because in principle, there's no reason we shouldn't be able to zero-copy deserialize nested types containing references.

Summary of changes

This removes the brittle lifetime rebinding done by matching on a single level Type, and instead replaces that functionality with syn's VisitMut, which provides recursive mutable access to the AST nodes that comprise a Type.

@cpubot cpubot force-pushed the schema-read-comprehensive-lifetime branch from efbe695 to 13bb399 Compare October 10, 2025 00:31
@cpubot cpubot requested a review from alessandrod October 10, 2025 00:40
Copy link
Collaborator

@alessandrod alessandrod left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this makes sense to me, at some point we should really add codegen tests for the macros so that I don't need to expand them in my little brain 😅

@cpubot cpubot merged commit 4e3acd1 into master Oct 10, 2025
2 checks passed
@cpubot cpubot deleted the schema-read-comprehensive-lifetime branch October 10, 2025 01:12
@cpubot cpubot mentioned this pull request Oct 10, 2025
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants