Skip to main content

camel_component_validator/
xsd_bridge.rs

1use std::sync::Arc;
2use std::sync::atomic::{AtomicUsize, Ordering};
3use std::time::{Duration, Instant};
4
5use async_trait::async_trait;
6use camel_bridge::download::{default_cache_dir_for_spec, ensure_binary_for_spec};
7use camel_bridge::health::wait_for_health;
8use camel_bridge::process::{BridgeError, BridgeProcess, BridgeProcessConfig};
9use camel_bridge::reconnect::BridgeReconnectHandler;
10use camel_bridge::spec::XML_BRIDGE;
11use camel_component_api::RuntimeObservability;
12use dashmap::DashMap;
13use prost::Message;
14use sha2::{Digest, Sha256};
15use std::sync::OnceLock;
16use tokio::sync::{Mutex, RwLock, watch};
17use tonic::transport::Channel;
18use tracing::error;
19
20use crate::error::ValidatorError;
21use crate::proto;
22use crate::proto::{
23    HealthCheckRequest, RegisterSchemaRequest, RegisterSchemaResponse, ValidateResponse,
24    ValidateWithRequest,
25};
26use crate::schema_set::SchemaSet;
27
28pub type SchemaId = String;
29
30impl RegisterSchemaRequest {
31    /// Builds the wire request for a fully discovered schema set.
32    ///
33    /// This is the single conversion seam for both the first registration and
34    /// reconnect replay, so the internal `BTreeMap`s are mapped onto the
35    /// generated `HashMap` fields in exactly one place.
36    pub(crate) fn new(schema_id: SchemaId, set: &SchemaSet) -> Self {
37        Self {
38            schema_id,
39            schema: set.root_bytes().to_vec(),
40            root_system_id: set.root_system_id.clone(),
41            resources: set
42                .resources
43                .iter()
44                .map(|(key, bytes)| (key.clone(), bytes.clone()))
45                .collect(),
46            aliases: set
47                .aliases
48                .iter()
49                .map(|(key, value)| (key.clone(), value.clone()))
50                .collect(),
51        }
52    }
53
54    fn check_encoded_size(&self) -> Result<(), ValidatorError> {
55        if self.encoded_len() > XSD_BRIDGE_MAX_REGISTER_REQUEST_SIZE {
56            return Err(ValidatorError::Transport {
57                message: "SCHEMA_SET_REQUEST_SIZE_CAP: encoded registration exceeds 17 MiB"
58                    .to_string(),
59                source: None,
60            });
61        }
62        Ok(())
63    }
64}
65
66// ── Bridge gRPC decode limit ─────────────────────────────────────────────────
67
68/// Maximum inbound gRPC message size accepted when decoding Java bridge
69/// responses.
70///
71/// The xml-bridge uses a 17 MiB ingress ceiling (see
72/// `bridges/xml/src/main/resources/application.yml`). The response ceiling
73/// also covers the protobuf envelope around a maximal response. Without this,
74/// tonic's 4 MiB default rejects legal responses and surfaces a broken or
75/// oversized bridge reply as an opaque transport error instead of a domain
76/// error.
77pub(crate) const XSD_BRIDGE_MAX_DECODING_MESSAGE_SIZE: usize = 17 * 1024 * 1024;
78
79/// Full encoded registration ceiling, including the legacy root mirror and
80/// virtual-key metadata. Also bounds full encoded validation requests. Keep
81/// synchronized with XML server ingress.
82pub(crate) const XSD_BRIDGE_MAX_REGISTER_REQUEST_SIZE: usize = XSD_BRIDGE_MAX_DECODING_MESSAGE_SIZE;
83
84pub(crate) fn xsd_bridge_decode_limit() -> usize {
85    XSD_BRIDGE_MAX_DECODING_MESSAGE_SIZE
86}
87
88/// Production-path constructor for bridge gRPC clients.
89///
90/// Applies [`xsd_bridge_decode_limit`] to every RPC issued through this
91/// client. All `XsdValidatorClient` construction sites must go through this
92/// helper rather than calling `XsdValidatorClient::new` directly, so a
93/// channel handed over by the backend always decodes maximal legal messages.
94pub(crate) fn xsd_bridge_client(
95    channel: Channel,
96) -> proto::xsd_validator_client::XsdValidatorClient<Channel> {
97    proto::xsd_validator_client::XsdValidatorClient::new(channel)
98        .max_decoding_message_size(xsd_bridge_decode_limit())
99}
100
101#[derive(Debug, Clone)]
102pub enum BridgeState {
103    Starting,
104    Ready { channel: Channel },
105    Degraded(String),
106    Restarting { attempt: u32, next_at: Instant },
107    Stopped,
108}
109
110pub struct XmlBridgeSlot {
111    pub state_rx: watch::Receiver<BridgeState>,
112    pub(crate) state_tx: watch::Sender<BridgeState>,
113    pub process: Arc<Mutex<Option<BridgeProcess>>>,
114}
115
116impl std::fmt::Debug for XmlBridgeSlot {
117    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
118        f.debug_struct("XmlBridgeSlot").finish()
119    }
120}
121
122#[async_trait]
123pub trait XsdBridge: Send + Sync {
124    /// Registers a fully discovered schema set under `schema_id`.
125    ///
126    /// Callers own discovery: the backend performs no filesystem access and
127    /// never re-discovers or re-roots the set.
128    async fn register(&self, schema_id: &str, set: &SchemaSet) -> Result<SchemaId, ValidatorError>;
129    async fn validate(&self, schema_id: &str, doc_bytes: Vec<u8>) -> Result<(), ValidatorError>;
130}
131
132#[async_trait]
133trait XsdBridgeRpc: Send + Sync {
134    async fn register_schema(
135        &self,
136        channel: Channel,
137        request: RegisterSchemaRequest,
138    ) -> Result<RegisterSchemaResponse, ValidatorError>;
139
140    async fn validate_with(
141        &self,
142        channel: Channel,
143        request: ValidateWithRequest,
144    ) -> Result<ValidateResponse, ValidatorError>;
145}
146
147#[derive(Debug)]
148struct GrpcXsdBridgeRpc;
149
150#[async_trait]
151impl XsdBridgeRpc for GrpcXsdBridgeRpc {
152    async fn register_schema(
153        &self,
154        channel: Channel,
155        request: RegisterSchemaRequest,
156    ) -> Result<RegisterSchemaResponse, ValidatorError> {
157        let mut client = xsd_bridge_client(channel);
158        let response = client.register_schema(request).await.map_err(|e| {
159            ValidatorError::transport_with_source("xml-bridge register_schema RPC failed", e)
160        })?;
161        Ok(response.into_inner())
162    }
163
164    async fn validate_with(
165        &self,
166        channel: Channel,
167        request: ValidateWithRequest,
168    ) -> Result<ValidateResponse, ValidatorError> {
169        let mut client = xsd_bridge_client(channel);
170        let response = client.validate_with(request).await.map_err(|e| {
171            ValidatorError::transport_with_source("xml-bridge validate_with RPC failed", e)
172        })?;
173        Ok(response.into_inner())
174    }
175}
176
177#[async_trait]
178trait XsdBridgeLauncher: Send + Sync {
179    async fn start(
180        &self,
181        version: &str,
182        cache_dir: &std::path::Path,
183        timeout_ms: u64,
184    ) -> Result<(Option<BridgeProcess>, Channel), ValidatorError>;
185
186    async fn stop(&self, process: Option<BridgeProcess>) {
187        if let Some(process) = process {
188            let _ = process.stop().await;
189        }
190    }
191}
192
193struct ProcessXsdBridgeLauncher;
194
195#[async_trait]
196impl XsdBridgeLauncher for ProcessXsdBridgeLauncher {
197    async fn start(
198        &self,
199        version: &str,
200        cache_dir: &std::path::Path,
201        timeout_ms: u64,
202    ) -> Result<(Option<BridgeProcess>, Channel), ValidatorError> {
203        let binary_path = ensure_binary_for_spec(&XML_BRIDGE, version, cache_dir)
204            .await
205            .map_err(|e| ValidatorError::endpoint(format!("XML bridge binary unavailable: {e}")))?;
206
207        let process_config = BridgeProcessConfig::xml(binary_path, timeout_ms);
208        let (process, channel) = BridgeProcess::start_and_connect(&process_config)
209            .await
210            .map_err(|e| ValidatorError::endpoint(format!("XML bridge start failed: {e}")))?;
211
212        wait_for_health(&channel, Duration::from_secs(10), |ch| {
213            let mut client = proto::health_client::HealthClient::new(ch);
214            async move {
215                let resp = client.check(HealthCheckRequest {}).await?;
216                Ok(resp.into_inner().status == "SERVING")
217            }
218        })
219        .await
220        .map_err(|e| ValidatorError::endpoint(format!("XML bridge health check failed: {e}")))?;
221
222        Ok((Some(process), channel))
223    }
224}
225
226#[derive(Debug, Clone)]
227struct ReadyBridge {
228    channel: Channel,
229    generation: usize,
230}
231
232#[derive(Clone)]
233pub struct XsdBridgeBackend {
234    /// Published only after the entire retained schema set is acknowledged.
235    /// Cache acknowledgement insertion holds this lock for reading, fencing it
236    /// against generation invalidation before the next replay snapshot.
237    channel: Arc<RwLock<Option<ReadyBridge>>>,
238    generation: Arc<AtomicUsize>,
239    /// Full retained snapshots keyed by schema id: root systemId, resource map,
240    /// and alias map, so reconnect replay re-supplies the exact same set.
241    schemas: Arc<DashMap<SchemaId, Arc<SchemaSet>>>,
242    schema_cache_max_entries: Arc<AtomicUsize>,
243    slot: Arc<XmlBridgeSlot>,
244    rpc: Arc<dyn XsdBridgeRpc>,
245    launcher: Arc<dyn XsdBridgeLauncher>,
246    start_lock: Arc<Mutex<()>>,
247    bridge_version: String,
248    bridge_cache_dir: std::path::PathBuf,
249    bridge_start_timeout_ms: u64,
250    observability: Arc<OnceLock<(Arc<dyn RuntimeObservability>, String)>>,
251}
252
253impl std::fmt::Debug for XsdBridgeBackend {
254    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
255        f.debug_struct("XsdBridgeBackend")
256            .field("bridge_version", &self.bridge_version)
257            .field("bridge_cache_dir", &self.bridge_cache_dir)
258            .finish()
259    }
260}
261
262impl XsdBridgeBackend {
263    pub fn new() -> Self {
264        let (state_tx, state_rx) = watch::channel(BridgeState::Stopped);
265        let slot = Arc::new(XmlBridgeSlot {
266            state_rx,
267            state_tx,
268            process: Arc::new(Mutex::new(None)),
269        });
270
271        Self {
272            channel: Arc::new(RwLock::new(None)),
273            generation: Arc::new(AtomicUsize::new(0)),
274            schemas: Arc::new(DashMap::new()),
275            schema_cache_max_entries: Arc::new(AtomicUsize::new(
276                crate::config::DEFAULT_SCHEMA_CACHE_MAX_ENTRIES,
277            )),
278            slot,
279            rpc: Arc::new(GrpcXsdBridgeRpc),
280            launcher: Arc::new(ProcessXsdBridgeLauncher),
281            start_lock: Arc::new(Mutex::new(())),
282            bridge_version: crate::BRIDGE_VERSION.to_string(),
283            bridge_cache_dir: default_cache_dir_for_spec(&XML_BRIDGE),
284            bridge_start_timeout_ms: 30_000,
285            observability: Arc::new(OnceLock::new()),
286        }
287    }
288
289    #[cfg(test)]
290    fn for_test(rpc: Arc<dyn XsdBridgeRpc>, channel: Channel) -> Self {
291        let (state_tx, state_rx) = watch::channel(BridgeState::Ready {
292            channel: channel.clone(),
293        });
294        let slot = Arc::new(XmlBridgeSlot {
295            state_rx,
296            state_tx,
297            process: Arc::new(Mutex::new(None)),
298        });
299        Self {
300            channel: Arc::new(RwLock::new(Some(ReadyBridge {
301                channel,
302                generation: 1,
303            }))),
304            generation: Arc::new(AtomicUsize::new(1)),
305            schemas: Arc::new(DashMap::new()),
306            schema_cache_max_entries: Arc::new(AtomicUsize::new(
307                crate::config::DEFAULT_SCHEMA_CACHE_MAX_ENTRIES,
308            )),
309            slot,
310            rpc,
311            launcher: Arc::new(ProcessXsdBridgeLauncher),
312            start_lock: Arc::new(Mutex::new(())),
313            bridge_version: crate::BRIDGE_VERSION.to_string(),
314            bridge_cache_dir: default_cache_dir_for_spec(&XML_BRIDGE),
315            bridge_start_timeout_ms: 30_000,
316            observability: Arc::new(OnceLock::new()),
317        }
318    }
319
320    pub fn set_observability(&self, runtime: Arc<dyn RuntimeObservability>, route_id: String) {
321        self.observability.set((runtime, route_id)).ok();
322    }
323
324    pub fn schema_id_for(xsd_bytes: &[u8]) -> SchemaId {
325        let mut hasher = Sha256::new();
326        hasher.update(xsd_bytes);
327        format!("xsd-{}", hex::encode(hasher.finalize()))
328    }
329
330    /// Update the maximum number of entries allowed in the schema cache.
331    /// If the cache already exceeds the new limit, it is cleared immediately.
332    pub fn set_schema_cache_max_entries(&self, max_entries: usize) {
333        if self.schemas.len() > max_entries {
334            self.schemas.clear();
335        }
336        self.schema_cache_max_entries
337            .store(max_entries, Ordering::Relaxed);
338    }
339
340    async fn ensure_bridge_ready(&self) -> Result<ReadyBridge, ValidatorError> {
341        if let Some(ch) = self.channel.read().await.clone() {
342            return Ok(ch);
343        }
344
345        let _guard = self.start_lock.lock().await;
346        if let Some(ch) = self.channel.read().await.clone() {
347            return Ok(ch);
348        }
349
350        let _ = self.slot.state_tx.send(BridgeState::Starting);
351        self.launch_and_seed().await
352    }
353
354    async fn restart_bridge(
355        &self,
356        failed_generation: usize,
357    ) -> Result<ReadyBridge, ValidatorError> {
358        let _guard = self.start_lock.lock().await;
359        if let Some(ready) = self.channel.read().await.as_ref()
360            && ready.generation > failed_generation
361        {
362            return Ok(ready.clone());
363        }
364
365        let _ = self.slot.state_tx.send(BridgeState::Restarting {
366            attempt: 0,
367            next_at: Instant::now(),
368        });
369        self.launch_and_seed().await
370    }
371
372    /// Caller holds `start_lock`. Invalidate before the cache snapshot so an
373    /// acknowledgement either enters that snapshot or is retried on the next
374    /// ready generation. Never hold channel/process locks across bridge RPCs.
375    async fn launch_and_seed(&self) -> Result<ReadyBridge, ValidatorError> {
376        self.channel.write().await.take();
377        let old_process = {
378            let mut process_guard = self.slot.process.lock().await;
379            process_guard.take()
380        };
381        self.launcher.stop(old_process).await;
382
383        let (process, channel) = match self.start_bridge_process().await {
384            Ok(launched) => launched,
385            Err(e) => {
386                let _ = self
387                    .slot
388                    .state_tx
389                    .send(BridgeState::Degraded(e.to_string()));
390                return Err(e);
391            }
392        };
393
394        if let Err(e) = self.reseed_with_channel(&channel).await {
395            self.launcher.stop(process).await;
396            let _ = self
397                .slot
398                .state_tx
399                .send(BridgeState::Degraded(e.to_string()));
400            return Err(e);
401        }
402        let ready = ReadyBridge {
403            channel: channel.clone(),
404            generation: self.generation.fetch_add(1, Ordering::Relaxed) + 1,
405        };
406        {
407            // Lock order: startup, channel, process. Publication is the final
408            // step, after all registrations have completed successfully.
409            let mut published = self.channel.write().await;
410            *self.slot.process.lock().await = process;
411            *published = Some(ready.clone());
412        }
413        let _ = self.slot.state_tx.send(BridgeState::Ready {
414            channel: channel.clone(),
415        });
416
417        Ok(ready)
418    }
419
420    async fn start_bridge_process(
421        &self,
422    ) -> Result<(Option<BridgeProcess>, Channel), ValidatorError> {
423        self.launcher
424            .start(
425                &self.bridge_version,
426                &self.bridge_cache_dir,
427                self.bridge_start_timeout_ms,
428            )
429            .await
430    }
431
432    /// Typed transport-retry classification (rc-7owor).
433    ///
434    /// Restart + full-set replay only for a direct tonic `Unavailable` source.
435    /// Other status codes, arbitrary attached causes, non-tonic sources and
436    /// source-less domain refusals do not establish connection/process loss.
437    /// Diagnostic wording never determines retry policy.
438    fn is_transport_retryable(err: &ValidatorError) -> bool {
439        matches!(
440            err,
441            ValidatorError::Transport {
442                source: Some(source),
443                ..
444            } if source.downcast_ref::<tonic::Status>()
445                .is_some_and(|status| status.code() == tonic::Code::Unavailable)
446        )
447    }
448
449    pub async fn shutdown(&self) {
450        let mut guard = self.slot.process.lock().await;
451        if let Some(p) = guard.take()
452            && let Err(e) = p.stop().await
453        {
454            tracing::warn!("Failed to stop XSD bridge process: {}", e);
455        }
456    }
457
458    async fn register_with_channel(
459        &self,
460        ready: &ReadyBridge,
461        schema_id: &str,
462        set: &SchemaSet,
463    ) -> Result<Option<SchemaId>, ValidatorError> {
464        let request = RegisterSchemaRequest::new(schema_id.to_string(), set);
465        request.check_encoded_size()?;
466        let response = self
467            .rpc
468            .register_schema(ready.channel.clone(), request)
469            .await?;
470
471        if let Some(err) = response.error {
472            return Err(ValidatorError::from_bridge_error(&err));
473        }
474
475        let published = self.channel.read().await;
476        if !published
477            .as_ref()
478            .is_some_and(|current| current.generation == ready.generation)
479        {
480            // This acknowledgement belongs to a retired process. It must not
481            // enter the retained cache or masquerade as a current local hit.
482            return Ok(None);
483        }
484
485        // Evict the entire cache if it has reached capacity. A full clear is
486        // acceptable because schemas are re-registered on demand and also
487        // re-seeded after bridge reconnects.
488        let max_entries = self.schema_cache_max_entries.load(Ordering::Relaxed);
489        if self.schemas.len() >= max_entries && !self.schemas.contains_key(schema_id) {
490            self.schemas.clear();
491        }
492
493        // Only a bridge-acknowledged set is cached; a local hit never stands in
494        // for the RPC acknowledgement.
495        self.schemas
496            .insert(schema_id.to_string(), Arc::new(set.clone()));
497        Ok(Some(schema_id.to_string()))
498    }
499
500    /// Re-registers every retained schema set on `channel`, from cache only
501    /// (never the filesystem). Single reseed path shared by `restart_bridge`
502    /// (awaited for both startup and restart) and the compatibility
503    /// `on_reconnect` hook, so the paths cannot drift (rc-bbwfn).
504    ///
505    /// Returns only after every retained set is acknowledged by the bridge.
506    /// The first failure is metered, logged, and returned, so a reseed is
507    /// never swallowed into a half-seeded bridge.
508    async fn reseed_with_channel(&self, channel: &Channel) -> Result<(), ValidatorError> {
509        // Snapshot full (SchemaId, Arc<SchemaSet>) pairs and release the
510        // DashMap guards BEFORE any await, so a slow RPC cannot stall
511        // writers.
512        let schemas: Vec<(SchemaId, Arc<SchemaSet>)> = self
513            .schemas
514            .iter()
515            .map(|entry| (entry.key().clone(), Arc::clone(entry.value())))
516            .collect();
517
518        let observability = self
519            .observability
520            .get()
521            .map(|(rt, rid)| (Arc::clone(rt), rid.clone()));
522
523        for (schema_id, set) in schemas {
524            let outcome = async {
525                let request = RegisterSchemaRequest::new(schema_id.clone(), &set);
526                request.check_encoded_size()?;
527                let response = self.rpc.register_schema(channel.clone(), request).await?;
528                match response.error {
529                    // A refusal response is a failed re-registration, not an
530                    // acknowledgement: the same contract as
531                    // `register_with_channel`.
532                    Some(err) => Err(ValidatorError::from_bridge_error(&err)),
533                    None => Ok(()),
534                }
535            }
536            .await;
537            if let Err(e) = outcome {
538                if let Some((ref rt, ref rid)) = observability {
539                    rt.metrics()
540                        .increment_errors(rid, "e:validator:reconnect-reseed");
541                }
542                // log-policy: outside-contract
543                error!(schema_id = %schema_id, error = %e, "re-seed schema failed after reconnect");
544                return Err(e);
545            }
546        }
547        Ok(())
548    }
549}
550
551impl BridgeReconnectHandler for XsdBridgeBackend {
552    fn on_reconnect(&self, channel: &Channel) -> Result<(), BridgeError> {
553        let this = self.clone();
554        let channel = channel.clone();
555        tokio::spawn(async move {
556            // One reseed code path with `restart_bridge` (rc-bbwfn). Failures
557            // are metered and logged inside `reseed_with_channel`; this hook
558            // has no caller to propagate to.
559            let _ = this.reseed_with_channel(&channel).await;
560        });
561        Ok(())
562    }
563}
564
565#[async_trait]
566impl XsdBridge for XsdBridgeBackend {
567    async fn register(&self, schema_id: &str, set: &SchemaSet) -> Result<SchemaId, ValidatorError> {
568        let mut ready = self.ensure_bridge_ready().await?;
569        let mut transport_retried = false;
570        loop {
571            {
572                let published = self.channel.read().await;
573                if published
574                    .as_ref()
575                    .is_some_and(|current| current.generation == ready.generation)
576                    && self.schemas.contains_key(schema_id)
577                {
578                    return Ok(schema_id.to_string());
579                }
580            }
581            match self.register_with_channel(&ready, schema_id, set).await {
582                Ok(Some(id)) => return Ok(id),
583                Ok(None) => ready = self.ensure_bridge_ready().await?,
584                Err(e) if !transport_retried && Self::is_transport_retryable(&e) => {
585                    transport_retried = true;
586                    ready = self.restart_bridge(ready.generation).await?;
587                }
588                Err(e) => return Err(e),
589            }
590        }
591    }
592
593    async fn validate(&self, schema_id: &str, doc_bytes: Vec<u8>) -> Result<(), ValidatorError> {
594        let req = ValidateWithRequest {
595            schema_id: schema_id.to_string(),
596            document: doc_bytes,
597        };
598        if req.encoded_len() > XSD_BRIDGE_MAX_REGISTER_REQUEST_SIZE {
599            return Err(ValidatorError::Transport {
600                message: "VALIDATION_REQUEST_SIZE_CAP: encoded validation exceeds 17 MiB"
601                    .to_string(),
602                source: None,
603            });
604        }
605        let channel = self.ensure_bridge_ready().await?;
606
607        let response = match self
608            .rpc
609            .validate_with(channel.channel.clone(), req.clone())
610            .await
611        {
612            Ok(resp) => resp,
613            Err(e) if Self::is_transport_retryable(&e) => {
614                let restarted = self.restart_bridge(channel.generation).await?;
615                self.rpc.validate_with(restarted.channel, req).await?
616            }
617            Err(e) => return Err(e),
618        };
619
620        if let Some(err) = response.error {
621            return Err(ValidatorError::from_bridge_error(&err));
622        }
623        if response.valid {
624            return Ok(());
625        }
626
627        let details = response
628            .errors
629            .iter()
630            .map(|e| format!("{}:{} {}", e.line, e.column, e.message))
631            .collect::<Vec<_>>()
632            .join("\n");
633        Err(ValidatorError::validation(format!(
634            "XSD validation failed:\n{details}"
635        )))
636    }
637}
638
639impl Default for XsdBridgeBackend {
640    fn default() -> Self {
641        Self::new()
642    }
643}
644
645#[cfg(test)]
646mod tests {
647    use super::*;
648    use std::collections::{BTreeMap, HashMap};
649    use std::sync::Mutex as StdMutex;
650    use std::sync::atomic::{AtomicUsize, Ordering};
651    use tokio::sync::Notify;
652    use tonic::transport::Endpoint;
653
654    #[derive(Debug)]
655    struct MockRpc {
656        register_calls: Arc<AtomicUsize>,
657        validate_ok: bool,
658        captured: Arc<StdMutex<Vec<RegisterSchemaRequest>>>,
659        signal: Arc<Notify>,
660    }
661
662    impl MockRpc {
663        fn new(validate_ok: bool) -> Self {
664            Self {
665                register_calls: Arc::new(AtomicUsize::new(0)),
666                validate_ok,
667                captured: Arc::new(StdMutex::new(Vec::new())),
668                signal: Arc::new(Notify::new()),
669            }
670        }
671    }
672
673    #[async_trait]
674    impl XsdBridgeRpc for MockRpc {
675        async fn register_schema(
676            &self,
677            _channel: Channel,
678            request: RegisterSchemaRequest,
679        ) -> Result<RegisterSchemaResponse, ValidatorError> {
680            self.register_calls.fetch_add(1, Ordering::SeqCst);
681            self.captured
682                .lock()
683                .expect("capture lock")
684                .push(request.clone());
685            self.signal.notify_one();
686            Ok(RegisterSchemaResponse {
687                schema_id: request.schema_id,
688                error: None,
689            })
690        }
691
692        async fn validate_with(
693            &self,
694            _channel: Channel,
695            _request: ValidateWithRequest,
696        ) -> Result<ValidateResponse, ValidatorError> {
697            Ok(ValidateResponse {
698                valid: self.validate_ok,
699                errors: Vec::new(),
700                error: None,
701            })
702        }
703    }
704
705    fn lazy_channel() -> Channel {
706        Endpoint::from_static("http://127.0.0.1:65535").connect_lazy()
707    }
708
709    fn test_backend(rpc: &Arc<MockRpc>) -> XsdBridgeBackend {
710        XsdBridgeBackend::for_test(Arc::clone(rpc) as Arc<dyn XsdBridgeRpc>, lazy_channel())
711    }
712
713    /// Deadline-bounded wait for the mock to observe `min` register calls.
714    ///
715    /// Uses the mock's `Notify` signal instead of a bare sleep, so the test is
716    /// deterministic and does not consume the test-sleep ratchet.
717    async fn wait_for_register_calls(rpc: &MockRpc, min: usize) {
718        let deadline = Instant::now() + Duration::from_secs(2);
719        loop {
720            if rpc.captured.lock().expect("capture lock").len() >= min {
721                return;
722            }
723            let remaining = deadline.saturating_duration_since(Instant::now());
724            assert!(
725                !remaining.is_zero(),
726                "timed out waiting for {min} register calls"
727            );
728            if tokio::time::timeout(remaining, rpc.signal.notified())
729                .await
730                .is_err()
731            {
732                assert!(
733                    rpc.captured.lock().expect("capture lock").len() >= min,
734                    "timed out waiting for {min} register calls"
735                );
736                return;
737            }
738        }
739    }
740
741    fn sample_set(root_bytes: &[u8]) -> SchemaSet {
742        let mut resources: BTreeMap<String, Vec<u8>> = BTreeMap::new();
743        resources.insert("root.xsd".to_string(), root_bytes.to_vec());
744        SchemaSet {
745            root_system_id: "root.xsd".to_string(),
746            resources,
747            aliases: BTreeMap::new(),
748        }
749    }
750
751    fn xml_ingress_limit() -> usize {
752        include_str!("../../../../bridges/xml/src/main/resources/application.yml")
753            .lines()
754            .find_map(|line| line.trim().strip_prefix("max-inbound-message-size:"))
755            .expect("XML gRPC ingress configuration")
756            .trim()
757            .parse()
758            .expect("numeric ingress limit")
759    }
760
761    #[test]
762    fn register_request_at_byte_budget_fits_ingress() {
763        use crate::schema_set::MAX_SCHEMA_BYTES;
764
765        let prefix = br#"<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema"><!--"#;
766        let suffix = b"--></xs:schema>";
767        let mut root = prefix.to_vec();
768        root.resize(MAX_SCHEMA_BYTES - suffix.len(), b' ');
769        root.extend_from_slice(suffix);
770        let set = sample_set(&root);
771        assert_eq!(set.root_bytes().len(), MAX_SCHEMA_BYTES);
772        let request = RegisterSchemaRequest::new(set.cache_key(), &set);
773        assert_eq!(request.schema, root, "legacy root must remain populated");
774        let encoded = request.encode_to_vec();
775        assert_eq!(encoded.len(), request.encoded_len());
776        assert!(encoded.len() > 16 * 1024 * 1024);
777        assert!(encoded.len() <= XSD_BRIDGE_MAX_REGISTER_REQUEST_SIZE);
778        assert!(
779            encoded.len() <= xml_ingress_limit(),
780            "encoded request {} exceeds configured ingress {}",
781            encoded.len(),
782            xml_ingress_limit()
783        );
784    }
785
786    #[test]
787    fn xml_ingress_matches_request_limit() {
788        assert_eq!(xml_ingress_limit(), XSD_BRIDGE_MAX_REGISTER_REQUEST_SIZE);
789        assert_eq!(
790            XSD_BRIDGE_MAX_REGISTER_REQUEST_SIZE,
791            xsd_bridge_decode_limit()
792        );
793    }
794
795    mod generations {
796        use super::*;
797        use std::future::{Future, poll_fn};
798        use std::pin::Pin;
799        use std::task::Poll;
800
801        type Events = Arc<StdMutex<Vec<String>>>;
802
803        struct CountingLauncher {
804            launches: AtomicUsize,
805            stops: AtomicUsize,
806            events: Events,
807        }
808
809        #[async_trait]
810        impl XsdBridgeLauncher for CountingLauncher {
811            async fn start(
812                &self,
813                _version: &str,
814                _cache_dir: &std::path::Path,
815                _timeout_ms: u64,
816            ) -> Result<(Option<BridgeProcess>, Channel), ValidatorError> {
817                let launch = self.launches.fetch_add(1, Ordering::SeqCst) + 1;
818                self.events.lock().unwrap().push(format!("launch:{launch}"));
819                Ok((None, lazy_channel()))
820            }
821
822            async fn stop(&self, process: Option<BridgeProcess>) {
823                assert!(process.is_none(), "tests must not launch a real process");
824                self.stops.fetch_add(1, Ordering::SeqCst);
825                self.events.lock().unwrap().push("stop".to_string());
826            }
827        }
828
829        struct GatedRpc {
830            calls: AtomicUsize,
831            gated_calls: Vec<usize>,
832            fail_call: usize,
833            entered: Notify,
834            release: Notify,
835            events: Events,
836        }
837
838        impl GatedRpc {
839            fn new(gated_calls: Vec<usize>, fail_call: usize) -> Arc<Self> {
840                Arc::new(Self {
841                    calls: AtomicUsize::new(0),
842                    gated_calls,
843                    fail_call,
844                    entered: Notify::new(),
845                    release: Notify::new(),
846                    events: Arc::new(StdMutex::new(Vec::new())),
847                })
848            }
849        }
850
851        #[async_trait]
852        impl XsdBridgeRpc for GatedRpc {
853            async fn register_schema(
854                &self,
855                _channel: Channel,
856                request: RegisterSchemaRequest,
857            ) -> Result<RegisterSchemaResponse, ValidatorError> {
858                let call = self.calls.fetch_add(1, Ordering::SeqCst) + 1;
859                self.events
860                    .lock()
861                    .unwrap()
862                    .push(format!("register:{call}:{}", request.schema_id));
863                if self.gated_calls.contains(&call) {
864                    self.entered.notify_one();
865                    self.release.notified().await;
866                }
867                if call == self.fail_call {
868                    self.events.lock().unwrap().push(format!("fail:{call}"));
869                    return Err(ValidatorError::transport_with_source(
870                        "test reseed transport failure",
871                        tonic::Status::unavailable("test connection lost"),
872                    ));
873                }
874                self.events
875                    .lock()
876                    .unwrap()
877                    .push(format!("ack:{call}:{}", request.schema_id));
878                Ok(RegisterSchemaResponse {
879                    schema_id: request.schema_id,
880                    error: None,
881                })
882            }
883
884            async fn validate_with(
885                &self,
886                _channel: Channel,
887                _request: ValidateWithRequest,
888            ) -> Result<ValidateResponse, ValidatorError> {
889                unreachable!("generation tests exercise readiness and registration")
890            }
891        }
892
893        fn fixture(rpc: &Arc<GatedRpc>) -> (XsdBridgeBackend, Arc<CountingLauncher>) {
894            let mut backend = XsdBridgeBackend::for_test(rpc.clone(), lazy_channel());
895            let launcher = Arc::new(CountingLauncher {
896                launches: AtomicUsize::new(0),
897                stops: AtomicUsize::new(0),
898                events: rpc.events.clone(),
899            });
900            backend.launcher = launcher.clone();
901            (backend, launcher)
902        }
903
904        fn retain(backend: &XsdBridgeBackend, id: &str) {
905            backend
906                .schemas
907                .insert(id.to_string(), Arc::new(sample_set(id.as_bytes())));
908        }
909
910        async fn poll_once<F: Future>(mut future: Pin<&mut F>) -> Poll<F::Output> {
911            poll_fn(|cx| Poll::Ready(future.as_mut().poll(cx))).await
912        }
913
914        mod n1 {
915            use super::*;
916
917            #[derive(Clone, Copy, Debug, PartialEq, Eq)]
918            enum Operation {
919                Register,
920                Validate,
921            }
922
923            impl Operation {
924                async fn run(self, backend: &XsdBridgeBackend) -> Result<(), ValidatorError> {
925                    match self {
926                        Self::Register => backend
927                            .register("xsd-target", &sample_set(b"<target/>"))
928                            .await
929                            .map(|_| ()),
930                        Self::Validate => {
931                            backend.validate("xsd-target", b"<target/>".to_vec()).await
932                        }
933                    }
934                }
935            }
936
937            struct ScriptedRpc {
938                operation: Operation,
939                code: tonic::Code,
940                failures: usize,
941                attempts: AtomicUsize,
942                registrations: StdMutex<Vec<RegisterSchemaRequest>>,
943                validations: AtomicUsize,
944                events: Events,
945            }
946
947            impl ScriptedRpc {
948                fn target(&self, operation: Operation, id: &str) -> Result<(), ValidatorError> {
949                    if operation == self.operation && id == "xsd-target" {
950                        let attempt = self.attempts.fetch_add(1, Ordering::SeqCst) + 1;
951                        self.events
952                            .lock()
953                            .unwrap()
954                            .push(format!("target:{attempt}"));
955                        if attempt <= self.failures {
956                            return Err(ValidatorError::transport_with_source(
957                                "xml-bridge RPC failed",
958                                tonic::Status::new(
959                                    self.code,
960                                    format!("connection unavailable: attempt {attempt}"),
961                                ),
962                            ));
963                        }
964                    }
965                    Ok(())
966                }
967            }
968
969            #[async_trait]
970            impl XsdBridgeRpc for ScriptedRpc {
971                async fn register_schema(
972                    &self,
973                    _channel: Channel,
974                    request: RegisterSchemaRequest,
975                ) -> Result<RegisterSchemaResponse, ValidatorError> {
976                    self.registrations.lock().unwrap().push(request.clone());
977                    self.target(Operation::Register, &request.schema_id)?;
978                    self.events
979                        .lock()
980                        .unwrap()
981                        .push(format!("ack:{}", request.schema_id));
982                    Ok(RegisterSchemaResponse {
983                        schema_id: request.schema_id,
984                        error: None,
985                    })
986                }
987
988                async fn validate_with(
989                    &self,
990                    _channel: Channel,
991                    request: ValidateWithRequest,
992                ) -> Result<ValidateResponse, ValidatorError> {
993                    self.validations.fetch_add(1, Ordering::SeqCst);
994                    self.target(Operation::Validate, &request.schema_id)?;
995                    Ok(ValidateResponse {
996                        valid: true,
997                        errors: vec![],
998                        error: None,
999                    })
1000                }
1001            }
1002
1003            fn fixture(
1004                operation: Operation,
1005                code: tonic::Code,
1006                failures: usize,
1007            ) -> (XsdBridgeBackend, Arc<ScriptedRpc>, Arc<CountingLauncher>) {
1008                let events = Arc::new(StdMutex::new(vec![]));
1009                let rpc = Arc::new(ScriptedRpc {
1010                    operation,
1011                    code,
1012                    failures,
1013                    attempts: AtomicUsize::new(0),
1014                    registrations: StdMutex::new(vec![]),
1015                    validations: AtomicUsize::new(0),
1016                    events: events.clone(),
1017                });
1018                let launcher = Arc::new(CountingLauncher {
1019                    launches: AtomicUsize::new(0),
1020                    stops: AtomicUsize::new(0),
1021                    events,
1022                });
1023                let mut backend = XsdBridgeBackend::for_test(rpc.clone(), lazy_channel());
1024                backend.launcher = launcher.clone();
1025                (backend, rpc, launcher)
1026            }
1027
1028            fn assert_size_refusal(error: &ValidatorError) {
1029                assert!(matches!(
1030                    error,
1031                    ValidatorError::Transport { source: None, .. }
1032                ));
1033                assert!(!XsdBridgeBackend::is_transport_retryable(error));
1034                let message = error.to_string();
1035                assert!(message.len() <= 512);
1036                assert!(!message.contains("private-schema-id"));
1037                assert!(!message.contains("PRIVATE_DOCUMENT"));
1038            }
1039
1040            fn assert_status(error: &ValidatorError, code: tonic::Code, attempt: usize) {
1041                let ValidatorError::Transport {
1042                    source: Some(source),
1043                    ..
1044                } = error
1045                else {
1046                    panic!("expected typed RPC status: {error:?}");
1047                };
1048                let status = source
1049                    .downcast_ref::<tonic::Status>()
1050                    .expect("tonic source");
1051                assert_eq!(status.code(), code);
1052                assert_eq!(
1053                    status.message(),
1054                    format!("connection unavailable: attempt {attempt}")
1055                );
1056            }
1057
1058            #[tokio::test]
1059            async fn oversized_validation_is_refused_before_readiness_or_rpc() {
1060                tokio::time::timeout(Duration::from_secs(2), async {
1061                    let mut outcomes = vec![];
1062                    for cold in [true, false] {
1063                        let (backend, rpc, launcher) = fixture(Operation::Validate, tonic::Code::Ok, 0);
1064                        if cold {
1065                            backend.channel.write().await.take();
1066                            backend.slot.state_tx.send(BridgeState::Stopped).unwrap();
1067                        } else {
1068                            backend.register("private-schema-id", &sample_set(b"<first/>")).await.unwrap();
1069                            backend.register("xsd-other", &sample_set(b"<other/>")).await.unwrap();
1070                            rpc.registrations.lock().unwrap().clear();
1071                        }
1072                        let retained: Vec<_> = backend.schemas.iter()
1073                            .map(|entry| (entry.key().clone(), entry.value().clone())).collect();
1074                        let mut state = backend.slot.state_rx.clone();
1075                        state.borrow_and_update();
1076                        let mut document = vec![b'x'; XSD_BRIDGE_MAX_REGISTER_REQUEST_SIZE + 1];
1077                        document[..16].copy_from_slice(b"PRIVATE_DOCUMENT");
1078                        let result = backend.validate("private-schema-id", document).await;
1079                        let validations = rpc.validations.load(Ordering::SeqCst);
1080                        let unchanged = !state.has_changed().unwrap()
1081                            && backend.generation.load(Ordering::SeqCst) == 1
1082                            && backend.channel.read().await.as_ref().map(|ready| ready.generation)
1083                                == if cold { None } else { Some(1) }
1084                            && backend.schemas.len() == retained.len()
1085                            && retained.iter().all(|(id, set)| Arc::ptr_eq(&backend.schemas.get(id).unwrap(), set));
1086                        if !cold {
1087                            backend.validate("xsd-other", b"<other/>".to_vec()).await.unwrap();
1088                        }
1089                        outcomes.push((cold, result, validations, unchanged,
1090                            launcher.launches.load(Ordering::SeqCst),
1091                            launcher.stops.load(Ordering::SeqCst),
1092                            rpc.registrations.lock().unwrap().len()));
1093                    }
1094                    for (cold, result, validations, unchanged, launches, stops, registrations) in outcomes {
1095                        assert_size_refusal(&result.expect_err("oversized validation must be refused"));
1096                        assert_eq!((validations, launches, stops, registrations), (0, 0, 0, 0), "cold={cold}");
1097                        assert!(unchanged, "refusal must preserve channel, generation, state and cache; cold={cold}");
1098                    }
1099                }).await.expect("validation size preflight must finish within deadline");
1100            }
1101
1102            #[tokio::test]
1103            async fn validation_encoded_limit_includes_id_and_overhead() {
1104                tokio::time::timeout(Duration::from_secs(2), async {
1105                    let limit = XSD_BRIDGE_MAX_REGISTER_REQUEST_SIZE;
1106                    assert_eq!(limit, xml_ingress_limit());
1107                    assert_eq!(limit, xsd_bridge_decode_limit());
1108                    let (backend, rpc, launcher) = fixture(Operation::Validate, tonic::Code::Ok, 0);
1109                    for extra in [0, 1] {
1110                        let mut request = ValidateWithRequest {
1111                            schema_id: "private-schema-id".to_string(),
1112                            document: vec![b'x'; limit - 128],
1113                        };
1114                        let overhead = request.encoded_len() - request.document.len();
1115                        request.document.resize(limit - overhead + extra, b'x');
1116                        assert!(request.document.len() < limit);
1117                        assert_eq!(request.encoded_len(), limit + extra);
1118                        let result = backend.validate(&request.schema_id, request.document).await;
1119                        if extra == 0 {
1120                            result.expect("exact encoded ceiling is admitted");
1121                        } else {
1122                            assert_size_refusal(
1123                                &result.expect_err("ID and protobuf overhead cross ceiling"),
1124                            );
1125                        }
1126                    }
1127                    assert_eq!(rpc.validations.load(Ordering::SeqCst), 1);
1128                    assert_eq!(launcher.launches.load(Ordering::SeqCst), 0);
1129                    assert_eq!(launcher.stops.load(Ordering::SeqCst), 0);
1130                })
1131                .await
1132                .expect("encoded validation boundary must finish within deadline");
1133            }
1134
1135            #[tokio::test]
1136            async fn limit_statuses_do_not_restart_register_or_validate() {
1137                tokio::time::timeout(Duration::from_secs(2), async {
1138                    let mut outcomes = vec![];
1139                    for operation in [Operation::Register, Operation::Validate] {
1140                        for code in [
1141                            tonic::Code::ResourceExhausted,
1142                            tonic::Code::OutOfRange,
1143                            tonic::Code::InvalidArgument,
1144                            tonic::Code::FailedPrecondition,
1145                        ] {
1146                            let (backend, rpc, launcher) = fixture(operation, code, 2);
1147                            retain(&backend, "xsd-other");
1148                            if operation == Operation::Validate {
1149                                retain(&backend, "xsd-target");
1150                            }
1151                            let mut state = backend.slot.state_rx.clone();
1152                            state.borrow_and_update();
1153                            let result = operation.run(&backend).await;
1154                            backend
1155                                .validate("xsd-other", b"<other/>".to_vec())
1156                                .await
1157                                .unwrap();
1158                            let registrations = rpc.registrations.lock().unwrap().len();
1159                            outcomes.push((
1160                                operation,
1161                                code,
1162                                result,
1163                                rpc.attempts.load(Ordering::SeqCst),
1164                                launcher.launches.load(Ordering::SeqCst),
1165                                launcher.stops.load(Ordering::SeqCst),
1166                                registrations,
1167                                backend.channel.read().await.as_ref().unwrap().generation,
1168                                !state.has_changed().unwrap(),
1169                            ));
1170                        }
1171                    }
1172                    for (
1173                        operation,
1174                        code,
1175                        result,
1176                        attempts,
1177                        launches,
1178                        stops,
1179                        registrations,
1180                        generation,
1181                        unchanged,
1182                    ) in outcomes
1183                    {
1184                        assert_status(&result.expect_err("limit status must surface"), code, 1);
1185                        assert_eq!(
1186                            (attempts, launches, stops, generation),
1187                            (1, 0, 0, 1),
1188                            "{operation:?} {code:?}"
1189                        );
1190                        assert_eq!(registrations, usize::from(operation == Operation::Register));
1191                        assert!(unchanged);
1192                    }
1193                })
1194                .await
1195                .expect("limit statuses must finish within deadline");
1196            }
1197
1198            #[test]
1199            fn typed_retry_classifier_only_accepts_unavailable() {
1200                let mut mismatches = vec![];
1201                for value in 0..=16 {
1202                    let code = tonic::Code::from_i32(value);
1203                    for cause in [false, true] {
1204                        for message in ["connection unavailable", "document size limit exceeded"] {
1205                            let mut status = tonic::Status::new(code, message);
1206                            if cause {
1207                                status.set_source(Arc::new(std::io::Error::other(
1208                                    std::io::Error::new(
1209                                        std::io::ErrorKind::TimedOut,
1210                                        "h2 cancelled: connection unavailable",
1211                                    ),
1212                                )));
1213                            }
1214                            let error = ValidatorError::transport_with_source("RPC failed", status);
1215                            if XsdBridgeBackend::is_transport_retryable(&error)
1216                                != (code == tonic::Code::Unavailable)
1217                            {
1218                                mismatches.push((code, cause, message));
1219                            }
1220                        }
1221                    }
1222                }
1223                let non_tonic = ValidatorError::transport_with_source(
1224                    "connection unavailable",
1225                    std::io::Error::other(tonic::Status::unavailable(
1226                        "nested status is not direct source",
1227                    )),
1228                );
1229                let domain = ValidatorError::Transport {
1230                    message: "connection unavailable".into(),
1231                    source: None,
1232                };
1233                assert!(
1234                    !XsdBridgeBackend::is_transport_retryable(&non_tonic),
1235                    "non-tonic source is not process loss"
1236                );
1237                assert!(!XsdBridgeBackend::is_transport_retryable(&domain));
1238                assert!(
1239                    mismatches.is_empty(),
1240                    "only Unavailable may restart: {mismatches:?}"
1241                );
1242            }
1243
1244            async fn assert_unavailable_retry(failures: usize) {
1245                for operation in [Operation::Register, Operation::Validate] {
1246                    let (backend, rpc, launcher) =
1247                        fixture(operation, tonic::Code::Unavailable, failures);
1248                    let mut set = sample_set(b"<root/>");
1249                    set.resources
1250                        .insert("child.xsd".into(), b"<child/>".to_vec());
1251                    set.aliases.insert("urn:child".into(), "child.xsd".into());
1252                    backend.schemas.insert("xsd-other".into(), Arc::new(set));
1253                    retain(
1254                        &backend,
1255                        if operation == Operation::Validate {
1256                            "xsd-target"
1257                        } else {
1258                            "xsd-cached"
1259                        },
1260                    );
1261                    let retained: Vec<_> = backend
1262                        .schemas
1263                        .iter()
1264                        .map(|entry| RegisterSchemaRequest::new(entry.key().clone(), entry.value()))
1265                        .collect();
1266                    let result = operation.run(&backend).await;
1267                    if failures == 1 {
1268                        result.unwrap();
1269                    } else {
1270                        assert_status(
1271                            &result.expect_err("second failure must surface"),
1272                            tonic::Code::Unavailable,
1273                            2,
1274                        );
1275                    }
1276                    assert_eq!(
1277                        rpc.attempts.load(Ordering::SeqCst),
1278                        2,
1279                        "{operation:?}: no third target attempt"
1280                    );
1281                    assert_eq!(launcher.launches.load(Ordering::SeqCst), 1);
1282                    assert_eq!(launcher.stops.load(Ordering::SeqCst), 1);
1283                    assert_eq!(backend.generation.load(Ordering::SeqCst), 2);
1284                    assert_eq!(backend.channel.read().await.as_ref().unwrap().generation, 2);
1285                    let events = rpc.events.lock().unwrap();
1286                    assert_eq!(&events[..3], ["target:1", "stop", "launch:1"]);
1287                    assert_eq!(
1288                        &events[3..5]
1289                            .iter()
1290                            .cloned()
1291                            .collect::<std::collections::BTreeSet<_>>(),
1292                        &retained
1293                            .iter()
1294                            .map(|request| format!("ack:{}", request.schema_id))
1295                            .collect()
1296                    );
1297                    assert_eq!(events[5], "target:2", "all full-set replays precede retry");
1298                    let registrations = rpc.registrations.lock().unwrap();
1299                    let replay = if operation == Operation::Register {
1300                        &registrations[1..3]
1301                    } else {
1302                        &registrations[..]
1303                    };
1304                    assert_eq!(
1305                        registrations.len(),
1306                        if operation == Operation::Register {
1307                            4
1308                        } else {
1309                            2
1310                        }
1311                    );
1312                    for expected in retained {
1313                        assert!(replay.contains(&expected), "replay preserves full set");
1314                    }
1315                }
1316            }
1317
1318            #[tokio::test]
1319            async fn unavailable_retries_register_and_validate_once() {
1320                tokio::time::timeout(Duration::from_secs(2), assert_unavailable_retry(1))
1321                    .await
1322                    .expect("single retry must finish within deadline");
1323            }
1324
1325            #[tokio::test]
1326            async fn second_unavailable_is_propagated_without_another_restart() {
1327                tokio::time::timeout(Duration::from_secs(2), assert_unavailable_retry(2))
1328                    .await
1329                    .expect("second failure must finish within deadline");
1330            }
1331        }
1332
1333        #[tokio::test]
1334        async fn oversized_encoded_request_is_non_retryable() {
1335            tokio::time::timeout(Duration::from_secs(5), async {
1336                let rpc = GatedRpc::new(vec![], 0);
1337                let (backend, launcher) = fixture(&rpc);
1338                let mut set = sample_set(b"<xs:schema/>");
1339                let long_key = "k".repeat(XSD_BRIDGE_MAX_REGISTER_REQUEST_SIZE);
1340                set.resources.insert(long_key.clone(), vec![1]);
1341                assert!(
1342                    RegisterSchemaRequest::new("xsd-large".to_string(), &set).encoded_len()
1343                        > XSD_BRIDGE_MAX_REGISTER_REQUEST_SIZE
1344                );
1345                let initial = backend.register("xsd-large", &set).await.err();
1346                let initial_launches = launcher.launches.load(Ordering::SeqCst);
1347                let initial_stops = launcher.stops.load(Ordering::SeqCst);
1348                // Seed the retained snapshot directly to exercise restart replay
1349                // even though initial registration must refuse this request.
1350                backend
1351                    .schemas
1352                    .insert("xsd-large".to_string(), Arc::new(set));
1353                let replay = backend.restart_bridge(1).await.err();
1354                assert_eq!(rpc.calls.load(Ordering::SeqCst), 0, "no registration RPCs");
1355                assert_eq!(initial_launches, 0, "refusal must not restart the bridge");
1356                assert_eq!(initial_stops, 0);
1357                assert_eq!(launcher.launches.load(Ordering::SeqCst), 1);
1358                assert_eq!(launcher.stops.load(Ordering::SeqCst), 2);
1359                assert!(backend.channel.read().await.is_none());
1360                for error in [initial, replay] {
1361                    let error = error.expect("oversized registration must be refused");
1362                    assert!(matches!(
1363                        error,
1364                        ValidatorError::Transport { source: None, .. }
1365                    ));
1366                    assert!(!XsdBridgeBackend::is_transport_retryable(&error));
1367                    let message = error.to_string();
1368                    assert!(message.len() <= 512);
1369                    assert!(message.contains("SCHEMA_SET_REQUEST_SIZE_CAP"));
1370                    assert!(!message.contains(&long_key));
1371                }
1372            })
1373            .await
1374            .expect("oversized registration and replay must finish within deadline");
1375        }
1376
1377        #[tokio::test]
1378        async fn concurrent_caller_waits_for_restart_reseed() {
1379            tokio::time::timeout(Duration::from_secs(2), async {
1380                let rpc = GatedRpc::new(vec![1], 0);
1381                let (backend, launcher) = fixture(&rpc);
1382                retain(&backend, "xsd-a");
1383                retain(&backend, "xsd-b");
1384                let mut restart = Box::pin(backend.restart_bridge(1));
1385                assert!(poll_once(restart.as_mut()).await.is_pending());
1386                rpc.entered.notified().await;
1387
1388                let mut caller = Box::pin(backend.ensure_bridge_ready());
1389                assert!(
1390                    poll_once(caller.as_mut()).await.is_pending(),
1391                    "readiness must stay pending until every reseed acknowledgement"
1392                );
1393                rpc.release.notify_one();
1394                let (restarted, ready) = tokio::join!(restart, caller);
1395                assert_eq!(restarted.unwrap().generation, 2);
1396                assert_eq!(ready.unwrap().generation, 2);
1397                assert_eq!(launcher.launches.load(Ordering::SeqCst), 1);
1398                assert_eq!(rpc.calls.load(Ordering::SeqCst), 2);
1399                assert_eq!(
1400                    rpc.events
1401                        .lock()
1402                        .unwrap()
1403                        .iter()
1404                        .filter(|e| e.starts_with("ack:"))
1405                        .count(),
1406                    2
1407                );
1408            })
1409            .await
1410            .expect("restart/readiness must finish within deadline");
1411        }
1412
1413        #[tokio::test]
1414        async fn failed_reseed_unpublishes_and_next_call_reseeds() {
1415            tokio::time::timeout(Duration::from_secs(2), async {
1416                let rpc = GatedRpc::new(vec![], 1);
1417                let (backend, launcher) = fixture(&rpc);
1418                retain(&backend, "xsd-a");
1419                retain(&backend, "xsd-b");
1420                let err = backend
1421                    .restart_bridge(1)
1422                    .await
1423                    .expect_err("first reseed fails");
1424                assert!(XsdBridgeBackend::is_transport_retryable(&err));
1425                assert!(matches!(
1426                    *backend.slot.state_rx.borrow(),
1427                    BridgeState::Degraded(_)
1428                ));
1429                assert!(
1430                    backend.channel.read().await.is_none(),
1431                    "failed generation must not remain published"
1432                );
1433                assert!(backend.slot.process.lock().await.is_none());
1434                assert_eq!(
1435                    launcher.stops.load(Ordering::SeqCst),
1436                    2,
1437                    "old and failed replacement must be stopped"
1438                );
1439                assert_eq!(
1440                    backend.schemas.len(),
1441                    2,
1442                    "failure must retain the full replay set"
1443                );
1444
1445                let ready = backend.ensure_bridge_ready().await.unwrap();
1446                assert!(ready.generation > 1);
1447                assert_eq!(
1448                    backend.channel.read().await.as_ref().unwrap().generation,
1449                    ready.generation
1450                );
1451                assert!(matches!(
1452                    *backend.slot.state_rx.borrow(),
1453                    BridgeState::Ready { .. }
1454                ));
1455                assert_eq!(launcher.launches.load(Ordering::SeqCst), 2);
1456                assert_eq!(rpc.calls.load(Ordering::SeqCst), 3);
1457                let mut acknowledged: Vec<_> = rpc
1458                    .events
1459                    .lock()
1460                    .unwrap()
1461                    .iter()
1462                    .filter(|e| e.starts_with("ack:"))
1463                    .map(|e| e.rsplit(':').next().unwrap().to_string())
1464                    .collect();
1465                acknowledged.sort();
1466                assert_eq!(acknowledged, ["xsd-a", "xsd-b"]);
1467            })
1468            .await
1469            .expect("failed replay and subsequent startup must finish within deadline");
1470        }
1471
1472        #[tokio::test]
1473        async fn concurrent_transport_retries_restart_once() {
1474            tokio::time::timeout(Duration::from_secs(2), async {
1475                let rpc = GatedRpc::new(vec![1], 0);
1476                let (backend, launcher) = fixture(&rpc);
1477                retain(&backend, "xsd-a");
1478                let mut first = Box::pin(backend.restart_bridge(1));
1479                assert!(poll_once(first.as_mut()).await.is_pending());
1480                rpc.entered.notified().await;
1481                let mut second = Box::pin(backend.restart_bridge(1));
1482                assert!(poll_once(second.as_mut()).await.is_pending());
1483                rpc.release.notify_one();
1484                let (first, second) = tokio::join!(first, second);
1485                assert_eq!(
1486                    launcher.launches.load(Ordering::SeqCst),
1487                    1,
1488                    "two failures from one generation need one replacement"
1489                );
1490                assert_eq!(first.unwrap().generation, 2);
1491                assert_eq!(second.unwrap().generation, 2);
1492                assert_eq!(rpc.calls.load(Ordering::SeqCst), 1);
1493            })
1494            .await
1495            .expect("concurrent restarts must finish within deadline");
1496        }
1497
1498        #[tokio::test]
1499        async fn stale_generation_restart_reuses_current() {
1500            tokio::time::timeout(Duration::from_secs(2), async {
1501                let rpc = GatedRpc::new(vec![], 0);
1502                let (backend, launcher) = fixture(&rpc);
1503                // Establish generation 2 through the real restart path.
1504                assert_eq!(backend.restart_bridge(1).await.unwrap().generation, 2);
1505                launcher.launches.store(0, Ordering::SeqCst);
1506                launcher.stops.store(0, Ordering::SeqCst);
1507                rpc.events.lock().unwrap().clear();
1508
1509                let ready = backend.restart_bridge(1).await.unwrap();
1510                assert_eq!(
1511                    launcher.launches.load(Ordering::SeqCst),
1512                    0,
1513                    "a late generation-1 failure must reuse generation 2"
1514                );
1515                assert_eq!(launcher.stops.load(Ordering::SeqCst), 0);
1516                assert_eq!(ready.generation, 2);
1517                assert!(rpc.events.lock().unwrap().is_empty());
1518            })
1519            .await
1520            .expect("stale restart must finish within deadline");
1521        }
1522
1523        #[tokio::test]
1524        async fn stale_generation_ack_is_reregistered() {
1525            tokio::time::timeout(Duration::from_secs(2), async {
1526                let rpc = GatedRpc::new(vec![1, 2], 0);
1527                let (backend, launcher) = fixture(&rpc);
1528                let set = sample_set(b"<stale/>");
1529                let mut registration = Box::pin(backend.register("xsd-stale", &set));
1530                assert!(poll_once(registration.as_mut()).await.is_pending());
1531                rpc.entered.notified().await;
1532                assert_eq!(backend.restart_bridge(1).await.unwrap().generation, 2);
1533                assert!(!backend.schemas.contains_key("xsd-stale"));
1534
1535                rpc.release.notify_one();
1536                assert!(
1537                    poll_once(registration.as_mut()).await.is_pending(),
1538                    "old acknowledgement must not complete registration on the current generation"
1539                );
1540                rpc.entered.notified().await;
1541                assert!(
1542                    !backend.schemas.contains_key("xsd-stale"),
1543                    "old acknowledgement must not become a local hit"
1544                );
1545                assert_eq!(
1546                    *rpc.events.lock().unwrap(),
1547                    [
1548                        "register:1:xsd-stale",
1549                        "stop",
1550                        "launch:1",
1551                        "ack:1:xsd-stale",
1552                        "register:2:xsd-stale",
1553                    ]
1554                );
1555                rpc.release.notify_one();
1556                assert_eq!(registration.await.unwrap(), "xsd-stale");
1557                assert_eq!(
1558                    backend.register("xsd-stale", &set).await.unwrap(),
1559                    "xsd-stale"
1560                );
1561                assert_eq!(
1562                    rpc.calls.load(Ordering::SeqCst),
1563                    2,
1564                    "local reuse is allowed only after the current acknowledgement"
1565                );
1566                assert_eq!(launcher.launches.load(Ordering::SeqCst), 1);
1567                assert_eq!(
1568                    *rpc.events.lock().unwrap(),
1569                    [
1570                        "register:1:xsd-stale",
1571                        "stop",
1572                        "launch:1",
1573                        "ack:1:xsd-stale",
1574                        "register:2:xsd-stale",
1575                        "ack:2:xsd-stale",
1576                    ]
1577                );
1578            })
1579            .await
1580            .expect("stale acknowledgement recovery must finish within deadline");
1581        }
1582
1583        #[tokio::test]
1584        async fn cached_register_waits_for_successful_generation() {
1585            tokio::time::timeout(Duration::from_secs(2), async {
1586                let mut outcomes = Vec::new();
1587                for failed_reseed in [false, true] {
1588                    let rpc = if failed_reseed {
1589                        GatedRpc::new(vec![2], 1)
1590                    } else {
1591                        GatedRpc::new(vec![1], 0)
1592                    };
1593                    let (backend, launcher) = fixture(&rpc);
1594                    retain(&backend, "xsd-cached");
1595                    let set = sample_set(b"xsd-cached");
1596                    let mut restart = Box::pin(backend.restart_bridge(1));
1597                    if failed_reseed {
1598                        let err = restart.as_mut().await.expect_err("first reseed fails");
1599                        assert!(XsdBridgeBackend::is_transport_retryable(&err));
1600                        assert!(matches!(
1601                            *backend.slot.state_rx.borrow(),
1602                            BridgeState::Degraded(_)
1603                        ));
1604                        assert!(backend.channel.read().await.is_none());
1605                        assert_eq!(launcher.stops.load(Ordering::SeqCst), 2);
1606                    } else {
1607                        assert!(poll_once(restart.as_mut()).await.is_pending());
1608                        rpc.entered.notified().await;
1609                    }
1610
1611                    let mut cached = Box::pin(backend.register("xsd-cached", &set));
1612                    let observed = poll_once(cached.as_mut()).await;
1613                    let pending = observed.is_pending();
1614                    if failed_reseed && pending {
1615                        rpc.entered.notified().await;
1616                    }
1617                    let launches = launcher.launches.load(Ordering::SeqCst);
1618                    let registrations = rpc.calls.load(Ordering::SeqCst);
1619                    assert!(backend.channel.read().await.is_none());
1620                    rpc.release.notify_one();
1621
1622                    if !failed_reseed {
1623                        assert_eq!(restart.await.unwrap().generation, 2);
1624                    }
1625                    let registered = match observed {
1626                        Poll::Ready(result) => result,
1627                        Poll::Pending => cached.await,
1628                    };
1629                    assert_eq!(registered.unwrap(), "xsd-cached");
1630                    let current_acknowledged = backend
1631                        .channel
1632                        .read()
1633                        .await
1634                        .as_ref()
1635                        .is_some_and(|ready| ready.generation > 1);
1636                    outcomes.push((
1637                        failed_reseed,
1638                        pending,
1639                        launches,
1640                        registrations,
1641                        current_acknowledged,
1642                    ));
1643                    assert_eq!(rpc.calls.load(Ordering::SeqCst), registrations);
1644                }
1645                // Check both cases together so an early local hit cannot hide
1646                // the separate recovery failure behind the first assertion.
1647                assert_eq!(
1648                    outcomes,
1649                    [(false, true, 1, 1, true), (true, true, 2, 2, true)],
1650                    "cached register must wait for reseed and recover after failed reseed: \
1651                     (failed reseed, pending, launches, registrations, current acknowledged)"
1652                );
1653            })
1654            .await
1655            .expect("cached registration and recovery must finish within deadline");
1656        }
1657    }
1658
1659    #[tokio::test]
1660    async fn xsd_bridge_reconnect_reseeds() {
1661        let rpc = Arc::new(MockRpc::new(true));
1662        let calls = Arc::clone(&rpc.register_calls);
1663        let backend = test_backend(&rpc);
1664
1665        let set_a = sample_set(b"<xsd:a/>");
1666        let set_b = sample_set(b"<xsd:b/>");
1667        let _id_a = backend.register("xsd-a", &set_a).await.unwrap();
1668        let _id_b = backend.register("xsd-b", &set_b).await.unwrap();
1669
1670        backend.on_reconnect(&lazy_channel()).unwrap();
1671        wait_for_register_calls(&rpc, 4).await;
1672
1673        assert!(calls.load(Ordering::SeqCst) >= 4);
1674    }
1675
1676    #[tokio::test]
1677    async fn register_request_carries_root_system_id_and_resources() {
1678        let rpc = Arc::new(MockRpc::new(true));
1679        let backend = test_backend(&rpc);
1680
1681        let mut resources: BTreeMap<String, Vec<u8>> = BTreeMap::new();
1682        resources.insert("root.xsd".to_string(), b"<xs:schema/>".to_vec());
1683        resources.insert("a/child.xsd".to_string(), b"<xs:schema/>".to_vec());
1684        let set = SchemaSet {
1685            root_system_id: "root.xsd".to_string(),
1686            resources,
1687            aliases: BTreeMap::new(),
1688        };
1689
1690        backend.register("xsd-root", &set).await.unwrap();
1691
1692        let captured = rpc.captured.lock().unwrap();
1693        assert_eq!(captured.len(), 1);
1694        let req = &captured[0];
1695        assert_eq!(req.schema_id, "xsd-root");
1696        assert_eq!(req.root_system_id, "root.xsd");
1697        assert_eq!(req.schema, b"<xs:schema/>".to_vec());
1698        assert_eq!(req.resources.len(), 2);
1699        assert_eq!(
1700            req.resources.get("a/child.xsd").map(Vec::as_slice),
1701            Some(b"<xs:schema/>".as_slice())
1702        );
1703    }
1704
1705    #[tokio::test]
1706    async fn register_request_carries_alias_map() {
1707        let rpc = Arc::new(MockRpc::new(true));
1708        let backend = test_backend(&rpc);
1709
1710        let mut resources: BTreeMap<String, Vec<u8>> = BTreeMap::new();
1711        resources.insert("root.xsd".to_string(), b"<xs:schema/>".to_vec());
1712        resources.insert("a/parent.xsd".to_string(), b"<xs:schema/>".to_vec());
1713        // Redundant URI-keyed entry, wire-compat with the normalized path.
1714        resources.insert(
1715            "http://example.com/parent.xsd".to_string(),
1716            b"<xs:schema/>".to_vec(),
1717        );
1718        let mut aliases: BTreeMap<String, String> = BTreeMap::new();
1719        aliases.insert(
1720            "http://example.com/parent.xsd".to_string(),
1721            "a/parent.xsd".to_string(),
1722        );
1723        let set = SchemaSet {
1724            root_system_id: "root.xsd".to_string(),
1725            resources,
1726            aliases,
1727        };
1728
1729        backend.register("xsd-alias", &set).await.unwrap();
1730
1731        let captured = rpc.captured.lock().unwrap();
1732        let req = &captured[0];
1733        assert_eq!(
1734            req.aliases
1735                .get("http://example.com/parent.xsd")
1736                .map(String::as_str),
1737            Some("a/parent.xsd")
1738        );
1739        // The redundant URI-keyed resource still carries its bytes.
1740        assert!(req.resources.contains_key("http://example.com/parent.xsd"));
1741    }
1742
1743    #[tokio::test]
1744    async fn register_preserves_schema_aliases() {
1745        let rpc = Arc::new(MockRpc::new(true));
1746        let backend = test_backend(&rpc);
1747
1748        // Root bytes are DISTINCT from the child bytes, and the redundant
1749        // alias-URI resource carries the SAME bytes as its mapped child.
1750        let mut resources: BTreeMap<String, Vec<u8>> = BTreeMap::new();
1751        resources.insert("root.xsd".to_string(), b"<root/>".to_vec());
1752        resources.insert("a/parent.xsd".to_string(), b"<child/>".to_vec());
1753        resources.insert(
1754            "http://example.com/parent.xsd".to_string(),
1755            b"<child/>".to_vec(),
1756        );
1757        let mut aliases: BTreeMap<String, String> = BTreeMap::new();
1758        aliases.insert(
1759            "http://example.com/parent.xsd".to_string(),
1760            "a/parent.xsd".to_string(),
1761        );
1762        let set = SchemaSet {
1763            root_system_id: "root.xsd".to_string(),
1764            resources,
1765            aliases,
1766        };
1767
1768        let expected_resources: HashMap<String, Vec<u8>> = [
1769            ("root.xsd".to_string(), b"<root/>".to_vec()),
1770            ("a/parent.xsd".to_string(), b"<child/>".to_vec()),
1771            (
1772                "http://example.com/parent.xsd".to_string(),
1773                b"<child/>".to_vec(),
1774            ),
1775        ]
1776        .into_iter()
1777        .collect();
1778        let expected_aliases: HashMap<String, String> = [(
1779            "http://example.com/parent.xsd".to_string(),
1780            "a/parent.xsd".to_string(),
1781        )]
1782        .into_iter()
1783        .collect();
1784
1785        backend.register("xsd-keep", &set).await.unwrap();
1786
1787        backend.on_reconnect(&lazy_channel()).unwrap();
1788        wait_for_register_calls(&rpc, 2).await;
1789
1790        let captured = rpc.captured.lock().unwrap();
1791        assert_eq!(captured.len(), 2, "exactly one register and one replay");
1792
1793        // Both the initial registration and the reconnect replay must carry the
1794        // whole set, not just a count.
1795        for (label, req) in [("initial", &captured[0]), ("replay", &captured[1])] {
1796            assert_eq!(req.schema_id, "xsd-keep", "{label}: schema_id");
1797            assert_eq!(req.root_system_id, "root.xsd", "{label}: root_system_id");
1798            assert_eq!(
1799                req.schema,
1800                b"<root/>".to_vec(),
1801                "{label}: legacy schema field must carry the root bytes"
1802            );
1803            assert_eq!(
1804                req.resources, expected_resources,
1805                "{label}: full resources map must be preserved"
1806            );
1807            assert_eq!(
1808                req.aliases, expected_aliases,
1809                "{label}: full alias map must be preserved"
1810            );
1811            assert_eq!(
1812                req.resources.get("http://example.com/parent.xsd"),
1813                req.resources.get("a/parent.xsd"),
1814                "{label}: alias-URI resource must duplicate its mapped child bytes"
1815            );
1816        }
1817    }
1818
1819    #[tokio::test]
1820    async fn register_does_not_read_filesystem() {
1821        let rpc = Arc::new(MockRpc::new(true));
1822        let backend = test_backend(&rpc);
1823
1824        // Discover against a real tempdir, then DROP it before registering.
1825        let dir = tempfile::tempdir().unwrap();
1826        let root = dir.path().join("root.xsd");
1827        std::fs::write(
1828            &root,
1829            br#"<?xml version="1.0"?><xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema"/>"#,
1830        )
1831        .unwrap();
1832        let set = crate::schema_set::discover_schema_set(&root, &BTreeMap::new()).unwrap();
1833        drop(dir); // The root on disk is gone; the backend must not touch the FS.
1834
1835        backend.register("xsd-nofs", &set).await.unwrap();
1836        backend.on_reconnect(&lazy_channel()).unwrap();
1837        wait_for_register_calls(&rpc, 2).await;
1838
1839        let captured = rpc.captured.lock().unwrap();
1840        assert!(
1841            captured.len() >= 2,
1842            "register and replay must succeed without the files on disk"
1843        );
1844        for req in captured.iter() {
1845            assert!(
1846                req.resources.contains_key(&req.root_system_id),
1847                "root bytes must be retained from the discovered snapshot"
1848            );
1849        }
1850    }
1851
1852    /// Writes `root.xsd` importing `a/child.xsd` under `dir` and returns the
1853    /// root path. `child` bytes are written separately so a caller can mutate
1854    /// only the child.
1855    fn write_root_with_child(dir: &std::path::Path, child_bytes: &[u8]) -> std::path::PathBuf {
1856        let root = dir.join("root.xsd");
1857        let child = dir.join("a").join("child.xsd");
1858        std::fs::create_dir_all(child.parent().expect("child parent")).unwrap();
1859        std::fs::write(
1860            &root,
1861            br#"<?xml version="1.0"?><xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema"><xs:include schemaLocation="a/child.xsd"/></xs:schema>"#,
1862        )
1863        .unwrap();
1864        std::fs::write(&child, child_bytes).unwrap();
1865        root
1866    }
1867
1868    #[tokio::test]
1869    async fn changed_child_yields_new_cache_key() {
1870        let dir = tempfile::tempdir().unwrap();
1871        let root = write_root_with_child(
1872            dir.path(),
1873            br#"<?xml version="1.0"?><xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema"><xs:element name="a" type="xs:string"/></xs:schema>"#,
1874        );
1875
1876        let no_aliases = BTreeMap::new();
1877        let set1 = crate::schema_set::discover_schema_set(&root, &no_aliases).unwrap();
1878        let id1 = set1.cache_key();
1879        let root_bytes = set1.root_bytes().to_vec();
1880
1881        // Change ONLY the child's bytes, then rediscover.
1882        std::fs::write(
1883            dir.path().join("a").join("child.xsd"),
1884            br#"<?xml version="1.0"?><xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema"><xs:element name="b" type="xs:int"/></xs:schema>"#,
1885        )
1886        .unwrap();
1887        let set2 = crate::schema_set::discover_schema_set(&root, &no_aliases).unwrap();
1888        let id2 = set2.cache_key();
1889
1890        assert_ne!(id1, id2, "a changed child must rotate the cache key");
1891        assert_eq!(
1892            root_bytes.as_slice(),
1893            set2.root_bytes(),
1894            "only the child changed; the legacy root bytes must be unchanged"
1895        );
1896
1897        // Both ids are the full set cache keys, not the legacy root-only hash.
1898        assert_ne!(
1899            id1,
1900            XsdBridgeBackend::schema_id_for(&root_bytes),
1901            "the id must be the full-set cache key, not the root-only helper"
1902        );
1903
1904        // Registering both retains two requests keyed by the exact cache key.
1905        let rpc = Arc::new(MockRpc::new(true));
1906        let backend = test_backend(&rpc);
1907        let recorded1 = backend.register(&id1, &set1).await.unwrap();
1908        let recorded2 = backend.register(&id2, &set2).await.unwrap();
1909        assert_ne!(recorded1, recorded2);
1910        {
1911            let captured = rpc.captured.lock().unwrap();
1912            assert_eq!(captured.len(), 2, "one request per distinct cache key");
1913            assert_eq!(captured[0].schema_id, id1);
1914            assert_eq!(captured[1].schema_id, id2);
1915        }
1916
1917        // Alias-map-only change: both alias targets are present with identical
1918        // bytes, so the resource map is byte-for-byte unchanged; only the alias
1919        // target mapping differs. The key must still rotate, proving the alias
1920        // map participates in the digest.
1921        let mut resources: BTreeMap<String, Vec<u8>> = BTreeMap::new();
1922        resources.insert("root.xsd".to_string(), b"<root/>".to_vec());
1923        resources.insert("a/parent.xsd".to_string(), b"<child/>".to_vec());
1924        resources.insert("b/parent.xsd".to_string(), b"<child/>".to_vec());
1925        resources.insert(
1926            "http://example.com/parent.xsd".to_string(),
1927            b"<child/>".to_vec(),
1928        );
1929        let mut aliases_a: BTreeMap<String, String> = BTreeMap::new();
1930        aliases_a.insert(
1931            "http://example.com/parent.xsd".to_string(),
1932            "a/parent.xsd".to_string(),
1933        );
1934        let mut aliases_b = aliases_a.clone();
1935        aliases_b.insert(
1936            "http://example.com/parent.xsd".to_string(),
1937            "b/parent.xsd".to_string(),
1938        );
1939        let set_alias_a = SchemaSet {
1940            root_system_id: "root.xsd".to_string(),
1941            resources: resources.clone(),
1942            aliases: aliases_a,
1943        };
1944        let set_alias_b = SchemaSet {
1945            root_system_id: "root.xsd".to_string(),
1946            resources,
1947            aliases: aliases_b,
1948        };
1949        assert_ne!(
1950            set_alias_a.cache_key(),
1951            set_alias_b.cache_key(),
1952            "an alias-mapping change alone must rotate the cache key"
1953        );
1954    }
1955
1956    #[test]
1957    fn cache_key_is_stable_for_unchanged_set() {
1958        let mut resources: BTreeMap<String, Vec<u8>> = BTreeMap::new();
1959        resources.insert("root.xsd".to_string(), b"<root/>".to_vec());
1960        resources.insert("a/child.xsd".to_string(), b"<child/>".to_vec());
1961        let mut aliases: BTreeMap<String, String> = BTreeMap::new();
1962        aliases.insert(
1963            "http://example.com/child.xsd".to_string(),
1964            "a/child.xsd".to_string(),
1965        );
1966        let set = SchemaSet {
1967            root_system_id: "root.xsd".to_string(),
1968            resources,
1969            aliases,
1970        };
1971        assert_eq!(set.cache_key(), set.cache_key(), "key is deterministic");
1972
1973        // Same content, different insertion order: BTreeMap iteration is sorted,
1974        // so the key must not depend on insertion order.
1975        let mut reordered: BTreeMap<String, Vec<u8>> = BTreeMap::new();
1976        reordered.insert("a/child.xsd".to_string(), b"<child/>".to_vec());
1977        reordered.insert("root.xsd".to_string(), b"<root/>".to_vec());
1978        let clone = SchemaSet {
1979            root_system_id: "root.xsd".to_string(),
1980            resources: reordered,
1981            aliases: set.aliases.clone(),
1982        };
1983        assert_eq!(
1984            set.cache_key(),
1985            clone.cache_key(),
1986            "insertion order must not affect the key"
1987        );
1988    }
1989
1990    #[tokio::test]
1991    async fn reconnect_reseeds_every_virtual_path() {
1992        let dir = tempfile::tempdir().unwrap();
1993        let root = write_root_with_child(
1994            dir.path(),
1995            br#"<?xml version="1.0"?><xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema"/>"#,
1996        );
1997
1998        let mut aliases: BTreeMap<String, String> = BTreeMap::new();
1999        aliases.insert(
2000            "http://example.com/child.xsd".to_string(),
2001            "a/child.xsd".to_string(),
2002        );
2003        let set = crate::schema_set::discover_schema_set(&root, &aliases).unwrap();
2004
2005        // Two real relative entries (root + child) plus the redundant
2006        // alias-URI entry: three virtual keys.
2007        assert_eq!(
2008            set.resources.len(),
2009            3,
2010            "expected root, child, and alias-URI keys, got {:?}",
2011            set.resources.keys().collect::<Vec<_>>()
2012        );
2013        let expected_resources: HashMap<String, Vec<u8>> = set
2014            .resources
2015            .iter()
2016            .map(|(key, bytes)| (key.clone(), bytes.clone()))
2017            .collect();
2018        let expected_aliases: HashMap<String, String> = set
2019            .aliases
2020            .iter()
2021            .map(|(key, value)| (key.clone(), value.clone()))
2022            .collect();
2023        let root_system_id = set.root_system_id.clone();
2024        let root_bytes = set.root_bytes().to_vec();
2025        let schema_id = set.cache_key();
2026
2027        let rpc = Arc::new(MockRpc::new(true));
2028        let backend = test_backend(&rpc);
2029        backend.register(&schema_id, &set).await.unwrap();
2030
2031        // Drop the files: replay must come from the retained snapshot, not the FS.
2032        drop(dir);
2033        backend.on_reconnect(&lazy_channel()).unwrap();
2034        wait_for_register_calls(&rpc, 2).await;
2035
2036        let captured = rpc.captured.lock().unwrap();
2037        assert_eq!(captured.len(), 2, "exactly one register and one replay");
2038        let replay = &captured[1];
2039        assert_eq!(replay.schema_id, schema_id, "schema id");
2040        assert_eq!(replay.root_system_id, root_system_id, "root systemId");
2041        assert_eq!(
2042            replay.schema, root_bytes,
2043            "legacy schema field must carry the root bytes"
2044        );
2045        assert_eq!(
2046            replay.resources, expected_resources,
2047            "replay must carry every virtual path, not just a count"
2048        );
2049        assert_eq!(
2050            replay.aliases, expected_aliases,
2051            "replay must carry the alias map"
2052        );
2053    }
2054
2055    #[tokio::test]
2056    async fn root_without_imports_registers_one_entry() {
2057        let dir = tempfile::tempdir().unwrap();
2058        let root = dir.path().join("root.xsd");
2059        std::fs::write(
2060            &root,
2061            br#"<?xml version="1.0"?><xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema"/>"#,
2062        )
2063        .unwrap();
2064
2065        let set = crate::schema_set::discover_schema_set(&root, &BTreeMap::new()).unwrap();
2066        assert_eq!(set.resources.len(), 1, "a single file is a one-entry set");
2067        assert_eq!(set.root_system_id, "root.xsd");
2068        assert!(set.aliases.is_empty());
2069
2070        let rpc = Arc::new(MockRpc::new(true));
2071        let backend = test_backend(&rpc);
2072        let schema_id = set.cache_key();
2073        backend.register(&schema_id, &set).await.unwrap();
2074
2075        let captured = rpc.captured.lock().unwrap();
2076        assert_eq!(captured.len(), 1);
2077        let req = &captured[0];
2078        assert_eq!(req.schema_id, schema_id);
2079        assert_eq!(req.root_system_id, "root.xsd");
2080        assert_eq!(req.resources.len(), 1);
2081        assert_eq!(
2082            req.resources.get("root.xsd").map(Vec::as_slice),
2083            Some(set.root_bytes()),
2084            "the one resource must be the root's bytes"
2085        );
2086        assert!(req.aliases.is_empty());
2087        // Legacy wire-compat field: the root entry bytes.
2088        assert_eq!(
2089            req.schema,
2090            set.root_bytes().to_vec(),
2091            "legacy schema field must stay the root bytes"
2092        );
2093    }
2094
2095    // ── transport-retry classification (rc-7owor) ───────────────────────────
2096
2097    /// A bridge transport failure carries the tonic RPC status in `source`.
2098    /// That is the error class that must trigger restart + full-set replay, so
2099    /// it must classify retryable even though its Display string never shows
2100    /// the tonic code.
2101    #[test]
2102    fn transport_error_with_tonic_source_classifies_retryable() {
2103        let err = ValidatorError::transport_with_source(
2104            "xml-bridge validate_with RPC failed",
2105            tonic::Status::unavailable("tcp connect error: ConnectionRefused"),
2106        );
2107        assert!(
2108            XsdBridgeBackend::is_transport_retryable(&err),
2109            "a Transport wrapping a tonic Status must classify retryable: {err}"
2110        );
2111    }
2112
2113    /// A source-less `Transport` is a domain refusal mapped by
2114    /// `ValidatorError::from_bridge_error`, not a transport failure. It must
2115    /// never classify retryable — even when its wording would collide with a
2116    /// string heuristic — so a domain error never triggers restart/replay.
2117    #[test]
2118    fn sourceless_domain_transport_is_not_retryable() {
2119        let domain = crate::proto::BridgeError {
2120            kind: crate::proto::bridge_error::Kind::Internal as i32,
2121            message: "upstream transport unavailable".to_string(),
2122            line: 0,
2123            column: 0,
2124            detail: String::new(),
2125        };
2126        let err = ValidatorError::from_bridge_error(&domain);
2127        assert!(
2128            !XsdBridgeBackend::is_transport_retryable(&err),
2129            "a source-less domain Transport must not classify retryable: {err}"
2130        );
2131    }
2132
2133    // ── reseed ordering (rc-bbwfn) ──────────────────────────────────────────
2134
2135    /// Register RPC that always fails at the transport level, for reseed
2136    /// failure paths.
2137    struct RejectingRpc;
2138
2139    #[async_trait]
2140    impl XsdBridgeRpc for RejectingRpc {
2141        async fn register_schema(
2142            &self,
2143            _channel: Channel,
2144            _request: RegisterSchemaRequest,
2145        ) -> Result<RegisterSchemaResponse, ValidatorError> {
2146            Err(ValidatorError::transport_with_source(
2147                "xml-bridge register_schema RPC failed",
2148                tonic::Status::unavailable("connection refused"),
2149            ))
2150        }
2151
2152        async fn validate_with(
2153            &self,
2154            _channel: Channel,
2155            _request: ValidateWithRequest,
2156        ) -> Result<ValidateResponse, ValidatorError> {
2157            Ok(ValidateResponse {
2158                valid: true,
2159                errors: Vec::new(),
2160                error: None,
2161            })
2162        }
2163    }
2164
2165    /// The shared reseed must complete every retained registration BEFORE it
2166    /// returns, so `restart_bridge` hands the channel back only over a fully
2167    /// seeded bridge and the retried RPC can never observe `unknown
2168    /// schema_id` (rc-bbwfn). A spawn-based reseed would return before the
2169    /// mock observes any registration and fail this assertion.
2170    #[tokio::test]
2171    async fn reseed_completes_all_registrations_before_returning() {
2172        let rpc = Arc::new(MockRpc::new(true));
2173        let backend = test_backend(&rpc);
2174
2175        let set_a = sample_set(b"<xsd:a/>");
2176        let set_b = sample_set(b"<xsd:b/>");
2177        let _id_a = backend.register("xsd-a", &set_a).await.unwrap();
2178        let _id_b = backend.register("xsd-b", &set_b).await.unwrap();
2179        assert_eq!(rpc.captured.lock().expect("capture lock").len(), 2);
2180
2181        backend.reseed_with_channel(&lazy_channel()).await.unwrap();
2182
2183        let captured = rpc.captured.lock().expect("capture lock");
2184        assert_eq!(
2185            captured.len(),
2186            4,
2187            "both retained sets must be re-registered before the reseed returns"
2188        );
2189        let mut replayed: Vec<&str> = captured[2..]
2190            .iter()
2191            .map(|req| req.schema_id.as_str())
2192            .collect();
2193        replayed.sort_unstable();
2194        assert_eq!(
2195            replayed,
2196            ["xsd-a", "xsd-b"],
2197            "the replay must carry every retained schema id"
2198        );
2199    }
2200
2201    /// A reseed failure must surface as an error (rc-bbwfn) — never be
2202    /// swallowed into a half-seeded bridge — and keep its transport
2203    /// classification so the existing retry behavior still applies.
2204    #[tokio::test]
2205    async fn reseed_failure_surfaces_as_error() {
2206        let rpc = Arc::new(RejectingRpc);
2207        let backend =
2208            XsdBridgeBackend::for_test(Arc::clone(&rpc) as Arc<dyn XsdBridgeRpc>, lazy_channel());
2209        // A retained set, as left behind by a prior acknowledged registration.
2210        backend
2211            .schemas
2212            .insert("xsd-a".to_string(), Arc::new(sample_set(b"<xsd:a/>")));
2213
2214        let err = backend
2215            .reseed_with_channel(&lazy_channel())
2216            .await
2217            .expect_err("a failed re-registration must surface as an error");
2218        assert!(
2219            XsdBridgeBackend::is_transport_retryable(&err),
2220            "the surfaced error keeps its transport classification: {err}"
2221        );
2222    }
2223}