Skip to content

Commit 33a4d09

Browse files
committed
Fix recursive constraint violations with paths over list and map shapes
There is a widespread assumption throughout the generation of constraint violations that does not hold true all the time, namely, that a recursive constraint violation graph has the same requirements with regards to boxing as the regular shape graph. Some types corresponding to recursive shapes are boxed to introduce indirection and thus not generate an infinitely recursive type. The algorithm however does not superfluously introduce boxes when the cycle goes through a list shape or a map shape. Why list shapes and map shapes? List shapes and map shapes get rendered in Rust as `Vec<T>` and `HashMap<K, V>`, respectively, they're the only Smithy shapes that "organically" introduce indirection (via a pointer to the heap) in the recursive path. For other recursive paths, we thus have to introduce the indirection artificially ourselves using `Box`. This is done in the `RecursiveShapeBoxer` model transform. However, the constraint violation graph needs to box types in recursive paths more often. Since we don't collect constraint violations (yet, see #2040), the constraint violation graph never holds `Vec<T>`s or `HashMap<K, V>`s, only simple types. Indeed, the following simple recursive model: ```smithy union Recursive { list: List } @Length(min: 69) list List { member: Recursive } ``` has a cycle that goes through a list shape, so no shapes in it need boxing in the regular shape graph. However, the constraint violation graph is infinitely recursive if we don't introduce boxing somewhere: ```rust pub mod model { pub mod list { pub enum ConstraintViolation { Length(usize), Member( usize, crate::model::recursive::ConstraintViolation, ), } } pub mod recursive { pub enum ConstraintViolation { List(crate::model::list::ConstraintViolation), } } } ``` This commit fixes things by making the `RecursiveShapeBoxer` model transform configurable so that the "cycles through lists and maps introduce indirection" assumption can be lifted. This allows a server model transform, `RecursiveConstraintViolationBoxer`, to tag member shapes along recursive paths with a new trait, `ConstraintViolationRustBoxTrait`, that the constraint violation type generation then utilizes to ensure that no infinitely recursive constraint violation types get generated. Likewise, places where constraint violations are handled (like where unconstrained types are converted to constrained types) have been updated to account for the scenario where they now are or need to be boxed. Parametrized tests have been added to exhaustively test combinations of models exercising recursive paths going through (sparse and non-sparse) list and map shapes, as well as union and structure shapes (`RecursiveConstraintViolationsTest`). These tests even assert that the specific member shapes along the cycles are tagged as expected (`RecursiveConstraintViolationBoxerTest`).
1 parent 181889d commit 33a4d09

File tree

17 files changed

+438
-61
lines changed

17 files changed

+438
-61
lines changed

codegen-client/src/main/kotlin/software/amazon/smithy/rust/codegen/client/smithy/ClientCodegenVisitor.kt

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -105,14 +105,14 @@ class ClientCodegenVisitor(
105105
// Add errors attached at the service level to the models
106106
.let { ModelTransformer.create().copyServiceErrorsToOperations(it, settings.getService(it)) }
107107
// Add `Box<T>` to recursive shapes as necessary
108-
.let(RecursiveShapeBoxer::transform)
108+
.let(RecursiveShapeBoxer()::transform)
109109
// Normalize the `message` field on errors when enabled in settings (default: true)
110110
.letIf(settings.codegenConfig.addMessageToErrors, AddErrorMessage::transform)
111111
// NormalizeOperations by ensuring every operation has an input & output shape
112112
.let(OperationNormalizer::transform)
113113
// Drop unsupported event stream operations from the model
114114
.let { RemoveEventStreamOperations.transform(it, settings) }
115-
// - Normalize event stream operations
115+
// Normalize event stream operations
116116
.let(EventStreamNormalizer::transform)
117117

118118
/**

codegen-client/src/test/kotlin/software/amazon/smithy/rust/codegen/client/customizations/ResiliencyConfigCustomizationTest.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ internal class ResiliencyConfigCustomizationTest {
3636

3737
@Test
3838
fun `generates a valid config`() {
39-
val model = RecursiveShapeBoxer.transform(OperationNormalizer.transform(baseModel))
39+
val model = RecursiveShapeBoxer().transform(OperationNormalizer.transform(baseModel))
4040
val project = TestWorkspace.testProject()
4141
val codegenContext = testCodegenContext(model, settings = project.rustSettings())
4242

codegen-core/src/main/kotlin/software/amazon/smithy/rust/codegen/core/smithy/traits/RustBoxTrait.kt

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,8 @@ import software.amazon.smithy.model.traits.Trait
1212
/**
1313
* Trait indicating that this shape should be represented with `Box<T>` when converted into Rust
1414
*
15-
* This is used to handle recursive shapes. See RecursiveShapeBoxer.
15+
* This is used to handle recursive shapes.
16+
* See [software.amazon.smithy.rust.codegen.core.smithy.transformers.RecursiveShapeBoxer].
1617
*
1718
* This trait is synthetic, applied during code generation, and never used in actual models.
1819
*/

codegen-core/src/main/kotlin/software/amazon/smithy/rust/codegen/core/smithy/transformers/RecursiveShapeBoxer.kt

Lines changed: 35 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -7,21 +7,29 @@ package software.amazon.smithy.rust.codegen.core.smithy.transformers
77

88
import software.amazon.smithy.codegen.core.TopologicalIndex
99
import software.amazon.smithy.model.Model
10-
import software.amazon.smithy.model.shapes.ListShape
10+
import software.amazon.smithy.model.shapes.CollectionShape
1111
import software.amazon.smithy.model.shapes.MapShape
1212
import software.amazon.smithy.model.shapes.MemberShape
13-
import software.amazon.smithy.model.shapes.SetShape
1413
import software.amazon.smithy.model.shapes.Shape
1514
import software.amazon.smithy.model.transform.ModelTransformer
1615
import software.amazon.smithy.rust.codegen.core.smithy.traits.RustBoxTrait
1716
import software.amazon.smithy.rust.codegen.core.util.hasTrait
1817

19-
object RecursiveShapeBoxer {
18+
class RecursiveShapeBoxer(
19+
private val containsIndirectionPredicate: (Collection<Shape>) -> Boolean = ::containsIndirection,
20+
private val boxShapeFn: (MemberShape) -> MemberShape = ::addRustBoxTrait,
21+
) {
2022
/**
21-
* Transform a model which may contain recursive shapes into a model annotated with [RustBoxTrait]
23+
* Transform a model which may contain recursive shapes into a model annotated with [RustBoxTrait].
2224
*
23-
* When recursive shapes do NOT go through a List, Map, or Set, they must be boxed in Rust. This function will
24-
* iteratively find loops & add the `RustBox` trait in a deterministic way until it reaches a fixed point.
25+
* When recursive shapes do NOT go through a `CollectionShape` or a `MapShape` shape, they must be boxed in Rust.
26+
* This function will iteratively find loops and add the [RustBoxTrait] trait in a deterministic way until it
27+
* reaches a fixed point.
28+
*
29+
* Why `CollectionShape`s and `MapShape`s? Note that `CollectionShape`s get rendered in Rust as `Vec<T>`, and
30+
* `MapShape`s as `HashMap<String, T>`; they're the only Smithy shapes that "organically" introduce indirection
31+
* (via a pointer to the heap) in the recursive path. For other recursive paths, we thus have to introduce the
32+
* indirection artificially ourselves using `Box`.
2533
*
2634
* This function MUST be deterministic (always choose the same shapes to `Box`). If it is not, that is a bug. Even so
2735
* this function may cause backward compatibility issues in certain pathological cases where a changes to recursive
@@ -41,12 +49,12 @@ object RecursiveShapeBoxer {
4149
* If [model] contains no loops, return null.
4250
*/
4351
private fun transformInner(model: Model): Model? {
44-
// Execute 1-step of the boxing algorithm in the path to reaching a fixed point
45-
// 1. Find all the shapes that are part of a cycle
46-
// 2. Find all the loops that those shapes are part of
47-
// 3. Filter out the loops that go through a layer of indirection
48-
// 3. Pick _just one_ of the remaining loops to fix
49-
// 4. Select the member shape in that loop with the earliest shape id
52+
// Execute 1 step of the boxing algorithm in the path to reaching a fixed point:
53+
// 1. Find all the shapes that are part of a cycle.
54+
// 2. Find all the loops that those shapes are part of.
55+
// 3. Filter out the loops that go through a layer of indirection.
56+
// 3. Pick _just one_ of the remaining loops to fix.
57+
// 4. Select the member shape in that loop with the earliest shape id.
5058
// 5. Box it.
5159
// (External to this function) Go back to 1.
5260
val index = TopologicalIndex.of(model)
@@ -58,34 +66,32 @@ object RecursiveShapeBoxer {
5866
// Flatten the connections into shapes.
5967
loops.map { it.shapes }
6068
}
61-
val loopToFix = loops.firstOrNull { !containsIndirection(it) }
69+
val loopToFix = loops.firstOrNull { !containsIndirectionPredicate(it) }
6270

6371
return loopToFix?.let { loop: List<Shape> ->
6472
check(loop.isNotEmpty())
65-
// pick the shape to box in a deterministic way
73+
// Pick the shape to box in a deterministic way.
6674
val shapeToBox = loop.filterIsInstance<MemberShape>().minByOrNull { it.id }!!
6775
ModelTransformer.create().mapShapes(model) { shape ->
6876
if (shape == shapeToBox) {
69-
shape.asMemberShape().get().toBuilder().addTrait(RustBoxTrait()).build()
77+
boxShapeFn(shape.asMemberShape().get())
7078
} else {
7179
shape
7280
}
7381
}
7482
}
7583
}
84+
}
7685

77-
/**
78-
* Check if a List<Shape> contains a shape which will use a pointer when represented in Rust, avoiding the
79-
* need to add more Boxes
80-
*/
81-
private fun containsIndirection(loop: List<Shape>): Boolean {
82-
return loop.find {
83-
when (it) {
84-
is ListShape,
85-
is MapShape,
86-
is SetShape, -> true
87-
else -> it.hasTrait<RustBoxTrait>()
88-
}
89-
} != null
86+
/**
87+
* Check if a `List<Shape>` contains a shape which will use a pointer when represented in Rust, avoiding the
88+
* need to add more `Box`es.
89+
*/
90+
private fun containsIndirection(loop: Collection<Shape>): Boolean = loop.find {
91+
when (it) {
92+
is CollectionShape, is MapShape -> true
93+
else -> it.hasTrait<RustBoxTrait>()
9094
}
91-
}
95+
} != null
96+
97+
private fun addRustBoxTrait(shape: MemberShape): MemberShape = shape.toBuilder().addTrait(RustBoxTrait()).build()

codegen-server/src/main/kotlin/software/amazon/smithy/rust/codegen/server/smithy/ServerCodegenVisitor.kt

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,7 @@ import software.amazon.smithy.rust.codegen.server.smithy.generators.protocol.Ser
7878
import software.amazon.smithy.rust.codegen.server.smithy.protocols.ServerProtocolLoader
7979
import software.amazon.smithy.rust.codegen.server.smithy.traits.isReachableFromOperationInput
8080
import software.amazon.smithy.rust.codegen.server.smithy.transformers.AttachValidationExceptionToConstrainedOperationInputsInAllowList
81+
import software.amazon.smithy.rust.codegen.server.smithy.transformers.RecursiveConstraintViolationBoxer
8182
import software.amazon.smithy.rust.codegen.server.smithy.transformers.RemoveEbsModelValidationException
8283
import software.amazon.smithy.rust.codegen.server.smithy.transformers.ShapesReachableFromOperationInputTagger
8384
import java.util.logging.Logger
@@ -162,7 +163,9 @@ open class ServerCodegenVisitor(
162163
// Add errors attached at the service level to the models
163164
.let { ModelTransformer.create().copyServiceErrorsToOperations(it, settings.getService(it)) }
164165
// Add `Box<T>` to recursive shapes as necessary
165-
.let(RecursiveShapeBoxer::transform)
166+
.let(RecursiveShapeBoxer()::transform)
167+
// Add `Box<T>` to recursive constraint violations as necessary
168+
.let(RecursiveConstraintViolationBoxer::transform)
166169
// Normalize operations by adding synthetic input and output shapes to every operation
167170
.let(OperationNormalizer::transform)
168171
// Remove the EBS model's own `ValidationException`, which collides with `smithy.framework#ValidationException`

codegen-server/src/main/kotlin/software/amazon/smithy/rust/codegen/server/smithy/generators/CollectionConstraintViolationGenerator.kt

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,12 +9,15 @@ import software.amazon.smithy.model.shapes.CollectionShape
99
import software.amazon.smithy.rust.codegen.core.rustlang.RustWriter
1010
import software.amazon.smithy.rust.codegen.core.rustlang.Visibility
1111
import software.amazon.smithy.rust.codegen.core.rustlang.join
12-
import software.amazon.smithy.rust.codegen.core.rustlang.rust
1312
import software.amazon.smithy.rust.codegen.core.rustlang.rustTemplate
13+
import software.amazon.smithy.rust.codegen.core.smithy.makeRustBoxed
1414
import software.amazon.smithy.rust.codegen.core.smithy.module
15+
import software.amazon.smithy.rust.codegen.core.util.hasTrait
16+
import software.amazon.smithy.rust.codegen.core.util.letIf
1517
import software.amazon.smithy.rust.codegen.server.smithy.PubCrateConstraintViolationSymbolProvider
1618
import software.amazon.smithy.rust.codegen.server.smithy.ServerCodegenContext
1719
import software.amazon.smithy.rust.codegen.server.smithy.canReachConstrainedShape
20+
import software.amazon.smithy.rust.codegen.server.smithy.traits.ConstraintViolationRustBoxTrait
1821
import software.amazon.smithy.rust.codegen.server.smithy.traits.isReachableFromOperationInput
1922

2023
class CollectionConstraintViolationGenerator(
@@ -38,16 +41,22 @@ class CollectionConstraintViolationGenerator(
3841
private val constraintsInfo: List<TraitInfo> = collectionConstraintsInfo.map { it.toTraitInfo() }
3942

4043
fun render() {
41-
val memberShape = model.expectShape(shape.member.target)
44+
val targetShape = model.expectShape(shape.member.target)
4245
val constraintViolationSymbol = constraintViolationSymbolProvider.toSymbol(shape)
4346
val constraintViolationName = constraintViolationSymbol.name
44-
val isMemberConstrained = memberShape.canReachConstrainedShape(model, symbolProvider)
47+
val isMemberConstrained = targetShape.canReachConstrainedShape(model, symbolProvider)
4548
val constraintViolationVisibility = Visibility.publicIf(publicConstrainedTypes, Visibility.PUBCRATE)
4649

4750
modelsModuleWriter.withInlineModule(constraintViolationSymbol.module()) {
4851
val constraintViolationVariants = constraintsInfo.map { it.constraintViolationVariant }.toMutableList()
4952
if (isMemberConstrained) {
5053
constraintViolationVariants += {
54+
val memberConstraintViolationSymbol =
55+
constraintViolationSymbolProvider.toSymbol(targetShape).letIf(
56+
shape.member.hasTrait<ConstraintViolationRustBoxTrait>(),
57+
) {
58+
it.makeRustBoxed()
59+
}
5160
rustTemplate(
5261
"""
5362
/// Constraint violation error when an element doesn't satisfy its own constraints.
@@ -56,7 +65,7 @@ class CollectionConstraintViolationGenerator(
5665
##[doc(hidden)]
5766
Member(usize, #{MemberConstraintViolationSymbol})
5867
""",
59-
"MemberConstraintViolationSymbol" to constraintViolationSymbolProvider.toSymbol(memberShape),
68+
"MemberConstraintViolationSymbol" to memberConstraintViolationSymbol,
6069
)
6170
}
6271
}

codegen-server/src/main/kotlin/software/amazon/smithy/rust/codegen/server/smithy/generators/MapConstraintViolationGenerator.kt

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,10 +11,13 @@ import software.amazon.smithy.model.traits.LengthTrait
1111
import software.amazon.smithy.rust.codegen.core.rustlang.RustWriter
1212
import software.amazon.smithy.rust.codegen.core.rustlang.Visibility
1313
import software.amazon.smithy.rust.codegen.core.rustlang.rustTemplate
14+
import software.amazon.smithy.rust.codegen.core.smithy.makeRustBoxed
1415
import software.amazon.smithy.rust.codegen.core.smithy.module
1516
import software.amazon.smithy.rust.codegen.core.util.hasTrait
17+
import software.amazon.smithy.rust.codegen.core.util.letIf
1618
import software.amazon.smithy.rust.codegen.server.smithy.PubCrateConstraintViolationSymbolProvider
1719
import software.amazon.smithy.rust.codegen.server.smithy.ServerCodegenContext
20+
import software.amazon.smithy.rust.codegen.server.smithy.traits.ConstraintViolationRustBoxTrait
1821
import software.amazon.smithy.rust.codegen.server.smithy.traits.isReachableFromOperationInput
1922

2023
class MapConstraintViolationGenerator(
@@ -47,7 +50,14 @@ class MapConstraintViolationGenerator(
4750
constraintViolationCodegenScopeMutableList.add("KeyConstraintViolationSymbol" to constraintViolationSymbolProvider.toSymbol(keyShape))
4851
}
4952
if (isValueConstrained(valueShape, model, symbolProvider)) {
50-
constraintViolationCodegenScopeMutableList.add("ValueConstraintViolationSymbol" to constraintViolationSymbolProvider.toSymbol(valueShape))
53+
constraintViolationCodegenScopeMutableList.add(
54+
"ValueConstraintViolationSymbol" to
55+
constraintViolationSymbolProvider.toSymbol(valueShape).letIf(
56+
shape.value.hasTrait<ConstraintViolationRustBoxTrait>(),
57+
) {
58+
it.makeRustBoxed()
59+
},
60+
)
5161
constraintViolationCodegenScopeMutableList.add("KeySymbol" to constrainedShapeSymbolProvider.toSymbol(keyShape))
5262
}
5363
val constraintViolationCodegenScope = constraintViolationCodegenScopeMutableList.toTypedArray()

codegen-server/src/main/kotlin/software/amazon/smithy/rust/codegen/server/smithy/generators/ServerBuilderConstraintViolations.kt

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,13 +20,13 @@ import software.amazon.smithy.rust.codegen.core.rustlang.rustTemplate
2020
import software.amazon.smithy.rust.codegen.core.smithy.RuntimeType
2121
import software.amazon.smithy.rust.codegen.core.smithy.isOptional
2222
import software.amazon.smithy.rust.codegen.core.smithy.makeRustBoxed
23-
import software.amazon.smithy.rust.codegen.core.smithy.traits.RustBoxTrait
2423
import software.amazon.smithy.rust.codegen.core.util.hasTrait
2524
import software.amazon.smithy.rust.codegen.core.util.letIf
2625
import software.amazon.smithy.rust.codegen.core.util.toPascalCase
2726
import software.amazon.smithy.rust.codegen.server.smithy.PubCrateConstraintViolationSymbolProvider
2827
import software.amazon.smithy.rust.codegen.server.smithy.ServerCodegenContext
2928
import software.amazon.smithy.rust.codegen.server.smithy.targetCanReachConstrainedShape
29+
import software.amazon.smithy.rust.codegen.server.smithy.traits.ConstraintViolationRustBoxTrait
3030

3131
/**
3232
* Renders constraint violation types that arise when building a structure shape builder.
@@ -138,8 +138,8 @@ class ServerBuilderConstraintViolations(
138138

139139
val constraintViolationSymbol =
140140
constraintViolationSymbolProvider.toSymbol(targetShape)
141-
// If the corresponding structure's member is boxed, box this constraint violation symbol too.
142-
.letIf(constraintViolation.forMember.hasTrait<RustBoxTrait>()) {
141+
// Box this constraint violation symbol if necessary.
142+
.letIf(constraintViolation.forMember.hasTrait<ConstraintViolationRustBoxTrait>()) {
143143
it.makeRustBoxed()
144144
}
145145

codegen-server/src/main/kotlin/software/amazon/smithy/rust/codegen/server/smithy/generators/ServerBuilderGenerator.kt

Lines changed: 15 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ import software.amazon.smithy.rust.codegen.server.smithy.ServerRuntimeType
4949
import software.amazon.smithy.rust.codegen.server.smithy.canReachConstrainedShape
5050
import software.amazon.smithy.rust.codegen.server.smithy.hasConstraintTraitOrTargetHasConstraintTrait
5151
import software.amazon.smithy.rust.codegen.server.smithy.targetCanReachConstrainedShape
52+
import software.amazon.smithy.rust.codegen.server.smithy.traits.ConstraintViolationRustBoxTrait
5253
import software.amazon.smithy.rust.codegen.server.smithy.traits.isReachableFromOperationInput
5354
import software.amazon.smithy.rust.codegen.server.smithy.wouldHaveConstrainedWrapperTupleTypeWerePublicConstrainedTypesEnabled
5455

@@ -541,18 +542,15 @@ class ServerBuilderGenerator(
541542
val hasBox = builderMemberSymbol(member)
542543
.mapRustType { it.stripOuter<RustType.Option>() }
543544
.isRustBoxed()
545+
val errHasBox = member.hasTrait<ConstraintViolationRustBoxTrait>()
546+
544547
if (hasBox) {
545548
writer.rustTemplate(
546549
"""
547550
.map(|v| match *v {
548551
#{MaybeConstrained}::Constrained(x) => Ok(Box::new(x)),
549552
#{MaybeConstrained}::Unconstrained(x) => Ok(Box::new(x.try_into()?)),
550553
})
551-
.map(|res|
552-
res${ if (constrainedTypeHoldsFinalType(member)) "" else ".map(|v| v.into())" }
553-
.map_err(|err| ConstraintViolation::${constraintViolation.name()}(Box::new(err)))
554-
)
555-
.transpose()?
556554
""",
557555
*codegenScope,
558556
)
@@ -563,16 +561,23 @@ class ServerBuilderGenerator(
563561
#{MaybeConstrained}::Constrained(x) => Ok(x),
564562
#{MaybeConstrained}::Unconstrained(x) => x.try_into(),
565563
})
566-
.map(|res|
567-
res${if (constrainedTypeHoldsFinalType(member)) "" else ".map(|v| v.into())"}
568-
.map_err(ConstraintViolation::${constraintViolation.name()})
569-
)
570-
.transpose()?
571564
""",
572565
*codegenScope,
573566
)
574567
}
575568

569+
writer.rustTemplate(
570+
"""
571+
.map(|res|
572+
res${if (constrainedTypeHoldsFinalType(member)) "" else ".map(|v| v.into())"}
573+
${if (errHasBox) ".map_err(Box::new)" else "" }
574+
.map_err(ConstraintViolation::${constraintViolation.name()})
575+
)
576+
.transpose()?
577+
""",
578+
*codegenScope,
579+
)
580+
576581
// Constrained types are not public and this is a member shape that would have generated a
577582
// public constrained type, were the setting to be enabled.
578583
// We've just checked the constraints hold by going through the non-public

0 commit comments

Comments
 (0)