zerodds_wire_probe/recursion.rs
1//! The aggregates of a type graph that sit on a cycle.
2//!
3//! A dump emitter inlines a member's statements into its owner's, and a
4//! type graph with a cycle has no finite inline expansion: the CORBA
5//! C++ emitter followed `recursive-mutual`'s
6//! `A → sequence<B> → B → sequence<A>` until the Rust stack ran out,
7//! and the C and Fast DDS emitters shared the walk and the crash. The
8//! types this module names are emitted as functions instead, and the
9//! expansion calls them where it would have unrolled them.
10//!
11//! ## Which walks ask, and which two did not
12//!
13//! Three emitters carry the answer: [`mod@crate::dump`], [`crate::dump_cpp`]
14//! and [`crate::corba_cpp`]'s dump half, each of which emits one
15//! function per aggregate named here and calls it in place of the
16//! expansion.
17//!
18//! Two ran past it and followed the cycle instead, measured on
19//! 2026-08-31 over `recursive-mutual` — both reached
20//! "has overflowed its stack" and `SIGABRT`:
21//!
22//! - `corba_java::dump_at`. The C++ half of that same file was given the
23//! machinery; the Java half was not.
24//! - `dump_ros::emit`. Its comment named `ros_idl` as the gate, and
25//! `ros_idl` does refuse a recursive type — but `wireprobe-dump --ros`
26//! is a separate process invocation and no gate on this function.
27//!
28//! Neither has a shape to emit a function into — a Java method body and
29//! a C block inside the ROS decode template — and neither column takes a
30//! recursive type at all, so both now refuse one by name, in the words
31//! `corba_idl` and `ros_idl` already use for it. A refusal is a cell
32//! that says why; a stack overflow is a cell that measured nothing.
33//!
34//! The fill emitters do not ask, and do not need to: a fill walk follows
35//! the sample, and a recursive type has finite samples because the
36//! sequence that closes its cycle is eventually empty. That is a
37//! termination argument rather than a bound, and it is the sample's
38//! nesting that bounds them.
39
40use crate::contract;
41use zerodds_ast::{Ast, TypeKind, TypeNode};
42use zerodds_ast_core::Id;
43
44/// The aggregates reachable from `root` that sit on a cycle, in
45/// first-visit order.
46pub fn recursive_aggregates(ast: &Ast, root: Id<TypeNode>) -> Vec<Id<TypeNode>> {
47 let mut all: Vec<Id<TypeNode>> = Vec::new();
48 let mut queue = Vec::new();
49 collect_aggregates(ast, root, &mut queue, &mut Vec::new());
50 while let Some(id) = queue.pop() {
51 if all.contains(&id) {
52 continue;
53 }
54 all.push(id);
55 queue.extend(aggregate_successors(ast, id));
56 }
57 all.into_iter()
58 .filter(|id| reaches(ast, *id, *id, &mut Vec::new()))
59 .collect()
60}
61
62/// Whether `target` is reachable from `from` over aggregate edges.
63///
64/// zerodds-lint: recursion-depth = the number of aggregates in the
65/// model. `seen` holds every aggregate this descent has already entered
66/// and nothing is entered twice, so the depth cannot pass the count of
67/// distinct aggregates the model declares — which is what makes the
68/// walk end on the cycle it is looking for rather than following it.
69fn reaches(
70 ast: &Ast,
71 from: Id<TypeNode>,
72 target: Id<TypeNode>,
73 seen: &mut Vec<Id<TypeNode>>,
74) -> bool {
75 for next in aggregate_successors(ast, from) {
76 if next == target {
77 return true;
78 }
79 if !seen.contains(&next) {
80 seen.push(next);
81 if reaches(ast, next, target, seen) {
82 return true;
83 }
84 }
85 }
86 false
87}
88
89/// The aggregates one step below `type_`: those its members or case
90/// members reach without passing through another aggregate.
91fn aggregate_successors(ast: &Ast, type_: Id<TypeNode>) -> Vec<Id<TypeNode>> {
92 let mut found = Vec::new();
93 let Ok(node) = ast.type_(type_) else {
94 return found;
95 };
96 match &node.kind {
97 TypeKind::Structure { .. } | TypeKind::Valuetype { .. } => {
98 for member in contract::members(ast, type_) {
99 collect_aggregates(ast, member.type_, &mut found, &mut Vec::new());
100 }
101 }
102 TypeKind::Union { .. } => {
103 for case in contract::union_cases(ast, type_) {
104 collect_aggregates(ast, case.type_, &mut found, &mut Vec::new());
105 }
106 }
107 _ => {}
108 }
109 found
110}
111
112/// The first aggregates below `type_`, walking through the connectors
113/// — aliases, sequences, arrays, maps — and stopping at each aggregate
114/// found.
115///
116/// zerodds-lint: recursion-depth = the number of connectors in the
117/// model. `open` holds the connectors this descent has already entered
118/// and one is never entered twice, so the depth cannot pass the count of
119/// distinct typedef, sequence, array and map nodes the model declares.
120///
121/// The guard is not decoration. A connector chain is a tree in a model
122/// written in IDL — a `sequence<sequence<long>>` is two nodes the parser
123/// made, and a typedef names a type that was already declared — but a
124/// typedef that resolves to itself is a chain with no end, and the
125/// frontend admits `typedef Tee Tee;` with no diagnostic. This function
126/// is the one every dump emitter asks *before* it can refuse a cycle, so
127/// it is the one place that cannot answer by running out of stack.
128/// `contract::reject_alias_cycle` refuses the same model at each
129/// emitter's entry; this is what holds when the function is called on
130/// its own.
131fn collect_aggregates(
132 ast: &Ast,
133 type_: Id<TypeNode>,
134 found: &mut Vec<Id<TypeNode>>,
135 open: &mut Vec<Id<TypeNode>>,
136) {
137 let Ok(node) = ast.type_(type_) else {
138 return;
139 };
140 if open.contains(&type_) {
141 // A connector already on this descent: following it again adds
142 // no aggregate that is not found by the first visit.
143 return;
144 }
145 open.push(type_);
146 match &node.kind {
147 TypeKind::Alias { aliased } => {
148 if let Some(target) = aliased.resolved() {
149 collect_aggregates(ast, target, found, open);
150 }
151 }
152 TypeKind::Sequence { element, .. } | TypeKind::Array { element, .. } => {
153 if let Some(target) = element.resolved() {
154 collect_aggregates(ast, target, found, open);
155 }
156 }
157 TypeKind::Map { key, value, .. } => {
158 if let Some(target) = key.resolved() {
159 collect_aggregates(ast, target, found, open);
160 }
161 if let Some(target) = value.resolved() {
162 collect_aggregates(ast, target, found, open);
163 }
164 }
165 TypeKind::Structure { .. } | TypeKind::Valuetype { .. } | TypeKind::Union { .. } => {
166 if !found.contains(&type_) {
167 found.push(type_);
168 }
169 }
170 _ => {}
171 }
172}