Skip to main content

riffdb_client_rust/
session.rs

1//! Optional bounded multiplexed transport for generated application operations.
2
3use std::collections::BTreeMap;
4use std::fmt;
5use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
6use std::sync::{Arc, Mutex};
7
8use futures_util::stream;
9use riffdb_proto::{decode_application_error, decode_public_error, v1};
10use tokio::sync::{mpsc, oneshot, watch};
11use tonic::{Code, Status};
12
13use crate::status::{checked_application_status, checked_status};
14use crate::{
15    CallMetadata, ClientError, DetailsFreeStatus, OutcomeUnknown, ProtocolFailure,
16    ProtocolFailureKind, RiffDbClient, generate_request_id,
17};
18
19/// Version of the first bounded application-session protocol.
20pub const APPLICATION_SESSION_PROTOCOL_V1: u32 = 1;
21/// Hard public ceiling for independently in-flight session operations.
22pub const MAX_APPLICATION_SESSION_IN_FLIGHT: u16 = 128;
23const OUTBOUND_SESSION_ITEMS: usize = 257;
24
25/// Invalid local construction of one exact application-session identity.
26#[derive(Clone, Copy, Debug, Eq, PartialEq)]
27pub struct ApplicationSessionConfigurationError;
28
29impl fmt::Display for ApplicationSessionConfigurationError {
30    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
31        formatter.write_str("application session identity is invalid")
32    }
33}
34
35impl std::error::Error for ApplicationSessionConfigurationError {}
36
37/// Exact immutable application identity selected by one optional session.
38#[derive(Clone, Debug, Eq, PartialEq)]
39pub struct ApplicationSessionIdentity {
40    contract_lineage: String,
41    contract_version: u64,
42    contract_bundle_hash: [u8; 32],
43    query_module_hashes: Vec<[u8; 32]>,
44    application_lock_hash: [u8; 32],
45    maximum_in_flight: u16,
46}
47
48impl ApplicationSessionIdentity {
49    /// Builds one canonical exact identity. Module hashes must already be in
50    /// strict byte order so generated lock drift fails locally rather than
51    /// being silently normalized.
52    pub fn new(
53        contract_lineage: String,
54        contract_version: u64,
55        contract_bundle_hash: [u8; 32],
56        query_module_hashes: Vec<[u8; 32]>,
57        application_lock_hash: [u8; 32],
58        maximum_in_flight: u16,
59    ) -> Result<Self, ApplicationSessionConfigurationError> {
60        if contract_lineage.is_empty()
61            || contract_lineage.len() > 256
62            || contract_version == 0
63            || maximum_in_flight == 0
64            || maximum_in_flight > MAX_APPLICATION_SESSION_IN_FLIGHT
65            || query_module_hashes.len() > 32
66            || query_module_hashes
67                .windows(2)
68                .any(|pair| pair[0] >= pair[1])
69        {
70            return Err(ApplicationSessionConfigurationError);
71        }
72        Ok(Self {
73            contract_lineage,
74            contract_version,
75            contract_bundle_hash,
76            query_module_hashes,
77            application_lock_hash,
78            maximum_in_flight,
79        })
80    }
81
82    fn open_request(&self, request_id: Vec<u8>) -> v1::ApplicationSessionOpen {
83        v1::ApplicationSessionOpen {
84            protocol_version: APPLICATION_SESSION_PROTOCOL_V1,
85            contract: Some(riffdb_proto::app::v1::ContractSelector {
86                lineage: self.contract_lineage.clone(),
87                version: self.contract_version,
88                bundle_hash: self.contract_bundle_hash.to_vec(),
89            }),
90            query_module_hashes: self
91                .query_module_hashes
92                .iter()
93                .map(|hash| hash.to_vec())
94                .collect(),
95            application_lock_hash: self.application_lock_hash.to_vec(),
96            requested_max_in_flight: u32::from(self.maximum_in_flight),
97            request_id,
98        }
99    }
100
101    fn matches_opened(&self, opened: &v1::ApplicationSessionOpened) -> bool {
102        let Some(contract) = opened.contract.as_ref() else {
103            return false;
104        };
105        opened.protocol_version == APPLICATION_SESSION_PROTOCOL_V1
106            && contract.lineage == self.contract_lineage
107            && contract.version == self.contract_version
108            && contract.bundle_hash.as_slice() == self.contract_bundle_hash
109            && opened.application_lock_hash.as_slice() == self.application_lock_hash
110            && opened.maximum_in_flight == u32::from(self.maximum_in_flight)
111            && opened.query_module_hashes.len() == self.query_module_hashes.len()
112            && opened
113                .query_module_hashes
114                .iter()
115                .zip(&self.query_module_hashes)
116                .all(|(actual, expected)| actual.as_slice() == expected)
117    }
118}
119
120enum PendingResponse {
121    Awaiting(oneshot::Sender<v1::ApplicationSessionResponse>),
122    /// Bounded tombstone for a cancellation acknowledgement. Keeping this
123    /// identity until the server answers prevents a deliberately cancelled
124    /// operation from being misclassified as an unknown response.
125    Ignore,
126}
127
128/// Cloneable optional session transport. It retains only bounded correlation
129/// state and no durable response history or semantic authority.
130#[derive(Clone)]
131pub(crate) struct BoundedApplicationSession {
132    outbound: mpsc::Sender<v1::ApplicationSessionRequest>,
133    pending: Arc<Mutex<BTreeMap<u64, PendingResponse>>>,
134    closed: Arc<AtomicBool>,
135    shutdown: watch::Sender<bool>,
136    next_correlation: Arc<AtomicU64>,
137    metadata: CallMetadata,
138    maximum_in_flight: usize,
139}
140
141impl BoundedApplicationSession {
142    pub(crate) async fn open(
143        client: &mut RiffDbClient,
144        identity: ApplicationSessionIdentity,
145        metadata: CallMetadata,
146    ) -> Result<Self, ClientError> {
147        let (outbound, receiver) = mpsc::channel(OUTBOUND_SESSION_ITEMS);
148        let (shutdown, shutdown_receiver) = watch::channel(false);
149        let opening_correlation = 1_u64;
150        outbound
151            .send(v1::ApplicationSessionRequest {
152                correlation_id: opening_correlation,
153                request: Some(v1::application_session_request::Request::Open(
154                    identity.open_request(
155                        generate_request_id()
156                            .map_err(ClientError::IdentifierGeneration)?
157                            .into_bytes()
158                            .to_vec(),
159                    ),
160                )),
161            })
162            .await
163            .map_err(|_| ClientError::ConnectionFailure)?;
164        let outbound_stream = session_outbound_stream(receiver, shutdown_receiver);
165        let mut inbound = client
166            .open_application_session(outbound_stream, &metadata)
167            .await?;
168        let opened = inbound
169            .message()
170            .await
171            .map_err(checked_status)?
172            .ok_or(ClientError::ConnectionFailure)?;
173        let Some(v1::application_session_response::Response::Opened(opened_identity)) =
174            opened.response.as_ref()
175        else {
176            return Err(invalid_inbound());
177        };
178        if opened.correlation_id != opening_correlation || !identity.matches_opened(opened_identity)
179        {
180            return Err(invalid_inbound());
181        }
182
183        let pending = Arc::new(Mutex::new(BTreeMap::<u64, PendingResponse>::new()));
184        let response_pending = Arc::clone(&pending);
185        let closed = Arc::new(AtomicBool::new(false));
186        let response_closed = Arc::clone(&closed);
187        tokio::spawn(async move {
188            while let Ok(Some(response)) = inbound.message().await {
189                if !route_session_response(&response_pending, response) {
190                    break;
191                }
192            }
193            response_closed.store(true, Ordering::Release);
194            close_session_pending(&response_pending);
195        });
196
197        Ok(Self {
198            outbound,
199            pending,
200            closed,
201            shutdown,
202            next_correlation: Arc::new(AtomicU64::new(opening_correlation + 1)),
203            metadata,
204            maximum_in_flight: usize::from(identity.maximum_in_flight),
205        })
206    }
207
208    pub(crate) fn matches_metadata(&self, metadata: &CallMetadata) -> bool {
209        self.metadata.has_same_session_scope(metadata)
210    }
211
212    /// Ends the request stream for a controlled shutdown. Pending operations
213    /// are released without manufacturing outcomes; commands retain the
214    /// ordinary outcome-unknown and idempotency recovery contract.
215    pub(crate) fn close(&self) {
216        self.closed.store(true, Ordering::Release);
217        let _ = self.shutdown.send(true);
218        close_session_pending(&self.pending);
219    }
220
221    async fn call(
222        &self,
223        request: v1::application_session_request::Request,
224    ) -> Result<v1::ApplicationSessionResponse, ClientError> {
225        if self.closed.load(Ordering::Acquire) {
226            return Err(ClientError::ConnectionFailure);
227        }
228        let (sender, receiver) = oneshot::channel();
229        let correlation_id = {
230            let mut pending = self.pending.lock().map_err(|_| invalid_inbound())?;
231            if self.closed.load(Ordering::Acquire) {
232                return Err(ClientError::ConnectionFailure);
233            }
234            if pending
235                .values()
236                .filter(|pending| matches!(pending, PendingResponse::Awaiting(_)))
237                .count()
238                >= self.maximum_in_flight
239                || pending.len() >= self.maximum_in_flight.saturating_mul(2)
240            {
241                return Err(local_capacity());
242            }
243            let correlation_id = self.next_correlation.fetch_add(1, Ordering::Relaxed);
244            if correlation_id == 0 || correlation_id == u64::MAX {
245                self.closed.store(true, Ordering::Release);
246                return Err(invalid_inbound());
247            }
248            if pending
249                .insert(correlation_id, PendingResponse::Awaiting(sender))
250                .is_some()
251            {
252                self.closed.store(true, Ordering::Release);
253                return Err(invalid_inbound());
254            }
255            if self
256                .outbound
257                .try_send(v1::ApplicationSessionRequest {
258                    correlation_id,
259                    request: Some(request),
260                })
261                .is_err()
262            {
263                pending.remove(&correlation_id);
264                self.closed.store(true, Ordering::Release);
265                return Err(ClientError::ConnectionFailure);
266            }
267            correlation_id
268        };
269        let mut guard = PendingCallGuard {
270            correlation_id,
271            next_correlation: Arc::clone(&self.next_correlation),
272            outbound: self.outbound.clone(),
273            pending: Arc::clone(&self.pending),
274            closed: Arc::clone(&self.closed),
275            maximum_in_flight: self.maximum_in_flight,
276            armed: true,
277        };
278        let response = receiver.await;
279        guard.armed = false;
280        response.map_err(|_| ClientError::ConnectionFailure)
281    }
282
283    pub(crate) async fn execute_command(
284        &self,
285        request: v1::ExecuteCommandRequest,
286    ) -> Result<v1::ExecuteCommandResponse, ClientError> {
287        let response = self
288            .call(v1::application_session_request::Request::Command(request))
289            .await?;
290        match response.response {
291            Some(v1::application_session_response::Response::Command(response)) => Ok(response),
292            Some(v1::application_session_response::Response::Failure(failure)) => {
293                Err(session_failure(failure, SessionExpected::Command))
294            }
295            _ => Err(invalid_inbound()),
296        }
297    }
298
299    pub(crate) async fn execute_query(
300        &self,
301        request: riffdb_proto::app::v1::ExecuteQueryRequest,
302    ) -> Result<riffdb_proto::app::v1::ExecuteQueryResponse, ClientError> {
303        let response = self
304            .call(v1::application_session_request::Request::Query(request))
305            .await?;
306        match response.response {
307            Some(v1::application_session_response::Response::Query(response)) => Ok(response),
308            Some(v1::application_session_response::Response::Failure(failure)) => {
309                Err(session_failure(failure, SessionExpected::Query))
310            }
311            _ => Err(invalid_inbound()),
312        }
313    }
314}
315
316fn session_outbound_stream(
317    receiver: mpsc::Receiver<v1::ApplicationSessionRequest>,
318    shutdown: watch::Receiver<bool>,
319) -> impl futures_util::Stream<Item = v1::ApplicationSessionRequest> + Send + 'static {
320    stream::unfold(
321        (receiver, shutdown),
322        |(mut receiver, mut shutdown)| async move {
323            if *shutdown.borrow() {
324                return None;
325            }
326            tokio::select! {
327                biased;
328                changed = shutdown.changed() => {
329                    let _ = changed;
330                    None
331                }
332                item = receiver.recv() => item.map(|item| (item, (receiver, shutdown))),
333            }
334        },
335    )
336}
337
338fn route_session_response(
339    pending: &Mutex<BTreeMap<u64, PendingResponse>>,
340    response: v1::ApplicationSessionResponse,
341) -> bool {
342    let pending = pending
343        .lock()
344        .ok()
345        .and_then(|mut pending| pending.remove(&response.correlation_id));
346    match pending {
347        Some(PendingResponse::Awaiting(sender)) => {
348            let _ = sender.send(response);
349            true
350        }
351        Some(PendingResponse::Ignore) => true,
352        None => false,
353    }
354}
355
356fn close_session_pending(pending: &Mutex<BTreeMap<u64, PendingResponse>>) {
357    if let Ok(mut pending) = pending.lock() {
358        pending.clear();
359    }
360}
361
362struct PendingCallGuard {
363    correlation_id: u64,
364    next_correlation: Arc<AtomicU64>,
365    outbound: mpsc::Sender<v1::ApplicationSessionRequest>,
366    pending: Arc<Mutex<BTreeMap<u64, PendingResponse>>>,
367    closed: Arc<AtomicBool>,
368    maximum_in_flight: usize,
369    armed: bool,
370}
371
372impl Drop for PendingCallGuard {
373    fn drop(&mut self) {
374        if !self.armed {
375            return;
376        }
377        let retained = self.pending.lock().ok().is_some_and(|mut pending| {
378            // Routing a response removes its pending entry before waking the
379            // caller. Cancellation in that window has nothing left to cancel.
380            if !pending.contains_key(&self.correlation_id) {
381                return true;
382            }
383            let cancel_correlation = self.next_correlation.fetch_add(1, Ordering::Relaxed);
384            if cancel_correlation == 0 || cancel_correlation == u64::MAX {
385                pending.remove(&self.correlation_id);
386                return false;
387            }
388            if !matches!(
389                pending.insert(self.correlation_id, PendingResponse::Ignore),
390                Some(PendingResponse::Awaiting(_))
391            ) || pending.len() >= self.maximum_in_flight.saturating_mul(2)
392            {
393                return false;
394            }
395            pending.insert(cancel_correlation, PendingResponse::Ignore);
396            if self
397                .outbound
398                .try_send(v1::ApplicationSessionRequest {
399                    correlation_id: cancel_correlation,
400                    request: Some(v1::application_session_request::Request::Cancel(
401                        v1::ApplicationSessionCancel {
402                            target_correlation_id: self.correlation_id,
403                        },
404                    )),
405                })
406                .is_err()
407            {
408                pending.remove(&self.correlation_id);
409                pending.remove(&cancel_correlation);
410                return false;
411            }
412            true
413        });
414        if !retained {
415            self.closed.store(true, Ordering::Release);
416        }
417    }
418}
419
420#[derive(Clone, Copy)]
421enum SessionExpected {
422    Command,
423    Query,
424}
425
426fn session_failure(
427    failure: v1::ApplicationSessionFailure,
428    expected: SessionExpected,
429) -> ClientError {
430    let expected_kind = match expected {
431        SessionExpected::Command => v1::ApplicationSessionOperationKind::Command,
432        SessionExpected::Query => v1::ApplicationSessionOperationKind::Query,
433    };
434    if v1::ApplicationSessionOperationKind::try_from(failure.operation_kind) != Ok(expected_kind) {
435        return invalid_inbound();
436    }
437    let code = Code::from_i32(failure.grpc_code);
438    if failure.details.is_empty() {
439        return match (expected, code) {
440            (SessionExpected::Command, Code::Unknown) => {
441                ClientError::OutcomeUnknown(OutcomeUnknown)
442            }
443            (_, Code::Unauthenticated) => {
444                ClientError::DetailsFree(DetailsFreeStatus::Unauthenticated)
445            }
446            (_, Code::Cancelled) => ClientError::DetailsFree(DetailsFreeStatus::Cancelled),
447            (_, Code::DeadlineExceeded) => {
448                ClientError::DetailsFree(DetailsFreeStatus::DeadlineExceeded)
449            }
450            (_, Code::Unavailable) => {
451                ClientError::DetailsFree(DetailsFreeStatus::TransportUnavailable)
452            }
453            (_, Code::Internal) => ClientError::DetailsFree(DetailsFreeStatus::EmergencyInternal),
454            _ => ClientError::ConnectionFailure,
455        };
456    }
457    match expected {
458        SessionExpected::Command => match decode_public_error(&failure.details) {
459            Ok(error) => checked_status(Status::with_details(
460                code,
461                error.safe_message(),
462                failure.details.into(),
463            )),
464            Err(_) => invalid_inbound(),
465        },
466        SessionExpected::Query => match decode_application_error(&failure.details) {
467            Ok(error) => checked_application_status(Status::with_details(
468                code,
469                error.safe_message(),
470                failure.details.into(),
471            )),
472            Err(_) => invalid_inbound(),
473        },
474    }
475}
476
477fn invalid_inbound() -> ClientError {
478    ClientError::Protocol(ProtocolFailure::new(
479        ProtocolFailureKind::InvalidInboundMessage,
480    ))
481}
482
483fn local_capacity() -> ClientError {
484    ClientError::Public(riffdb_errors::PublicError::overloaded())
485}
486
487#[cfg(test)]
488mod tests {
489    use super::*;
490
491    fn test_session(
492        maximum_in_flight: usize,
493    ) -> (
494        BoundedApplicationSession,
495        mpsc::Receiver<v1::ApplicationSessionRequest>,
496    ) {
497        let (outbound, receiver) = mpsc::channel(OUTBOUND_SESSION_ITEMS);
498        let (shutdown, _shutdown_receiver) = watch::channel(false);
499        (
500            BoundedApplicationSession {
501                outbound,
502                pending: Arc::new(Mutex::new(BTreeMap::new())),
503                closed: Arc::new(AtomicBool::new(false)),
504                shutdown,
505                next_correlation: Arc::new(AtomicU64::new(2)),
506                metadata: CallMetadata::default(),
507                maximum_in_flight,
508            },
509            receiver,
510        )
511    }
512
513    #[tokio::test]
514    async fn explicit_close_releases_pending_and_ends_the_request_stream() {
515        use futures_util::StreamExt;
516
517        let (outbound, receiver) = mpsc::channel(OUTBOUND_SESSION_ITEMS);
518        let (shutdown, shutdown_receiver) = watch::channel(false);
519        let session = BoundedApplicationSession {
520            outbound,
521            pending: Arc::new(Mutex::new(BTreeMap::new())),
522            closed: Arc::new(AtomicBool::new(false)),
523            shutdown,
524            next_correlation: Arc::new(AtomicU64::new(2)),
525            metadata: CallMetadata::default(),
526            maximum_in_flight: 1,
527        };
528        let stream = session_outbound_stream(receiver, shutdown_receiver);
529        tokio::pin!(stream);
530        session.close();
531        assert!(stream.next().await.is_none());
532        assert!(session.closed.load(Ordering::Acquire));
533        assert!(session.pending.lock().expect("pending").is_empty());
534    }
535
536    #[test]
537    fn identity_rejects_unsorted_modules_and_unbounded_inflight() {
538        let high = [9; 32];
539        let low = [8; 32];
540        assert!(
541            ApplicationSessionIdentity::new(
542                "TicketDesk".to_owned(),
543                1,
544                [1; 32],
545                vec![high, low],
546                [2; 32],
547                16,
548            )
549            .is_err()
550        );
551        assert!(
552            ApplicationSessionIdentity::new(
553                "TicketDesk".to_owned(),
554                1,
555                [1; 32],
556                vec![low, high],
557                [2; 32],
558                129,
559            )
560            .is_err()
561        );
562    }
563
564    #[tokio::test]
565    async fn responses_complete_by_correlation_not_submission_order() {
566        let (session, mut outbound) = test_session(2);
567        let first_session = session.clone();
568        let first = tokio::spawn(async move {
569            first_session
570                .execute_query(riffdb_proto::app::v1::ExecuteQueryRequest::default())
571                .await
572        });
573        let second_session = session.clone();
574        let second = tokio::spawn(async move {
575            second_session
576                .execute_query(riffdb_proto::app::v1::ExecuteQueryRequest::default())
577                .await
578        });
579        let first_request = outbound.recv().await.expect("first request");
580        let second_request = outbound.recv().await.expect("second request");
581        assert!(first_request.correlation_id < second_request.correlation_id);
582
583        for correlation_id in [second_request.correlation_id, first_request.correlation_id] {
584            assert!(route_session_response(
585                &session.pending,
586                v1::ApplicationSessionResponse {
587                    correlation_id,
588                    response: Some(v1::application_session_response::Response::Query(
589                        riffdb_proto::app::v1::ExecuteQueryResponse::default(),
590                    )),
591                },
592            ));
593        }
594        assert!(first.await.expect("first task").is_ok());
595        assert!(second.await.expect("second task").is_ok());
596    }
597
598    #[tokio::test]
599    async fn in_flight_capacity_is_bounded_and_cancellation_is_explicit() {
600        let (session, mut outbound) = test_session(1);
601        let first_session = session.clone();
602        let first = tokio::spawn(async move {
603            first_session
604                .execute_query(riffdb_proto::app::v1::ExecuteQueryRequest::default())
605                .await
606        });
607        let submitted = outbound.recv().await.expect("submitted request");
608        assert!(matches!(
609            session
610                .execute_query(riffdb_proto::app::v1::ExecuteQueryRequest::default())
611                .await,
612            Err(ClientError::Public(error))
613                if error.kind() == riffdb_errors::PublicErrorKind::Overloaded
614        ));
615
616        first.abort();
617        let _ = first.await;
618        let cancellation = outbound.recv().await.expect("cancellation request");
619        let cancellation_correlation = cancellation.correlation_id;
620        let Some(v1::application_session_request::Request::Cancel(cancellation)) =
621            cancellation.request
622        else {
623            panic!("dropped operation must send cancellation")
624        };
625        assert_eq!(cancellation.target_correlation_id, submitted.correlation_id);
626        assert!(route_session_response(
627            &session.pending,
628            v1::ApplicationSessionResponse {
629                correlation_id: submitted.correlation_id,
630                response: Some(v1::application_session_response::Response::Failure(
631                    v1::ApplicationSessionFailure {
632                        operation_kind: v1::ApplicationSessionOperationKind::Query as i32,
633                        grpc_code: Code::Cancelled as i32,
634                        details: Vec::new(),
635                    },
636                )),
637            },
638        ));
639        assert!(route_session_response(
640            &session.pending,
641            v1::ApplicationSessionResponse {
642                correlation_id: cancellation_correlation,
643                response: Some(v1::application_session_response::Response::Cancellation(
644                    v1::ApplicationSessionCancellation {
645                        target_correlation_id: submitted.correlation_id,
646                        disposition: v1::ApplicationSessionCancellationDisposition::QueryCancelled
647                            as i32,
648                    },
649                ),),
650            },
651        ));
652        assert!(session.pending.lock().expect("pending").is_empty());
653    }
654
655    #[tokio::test]
656    async fn cancelling_after_response_routing_preserves_the_session() {
657        let (session, mut outbound) = test_session(1);
658        let mut call =
659            Box::pin(session.execute_query(riffdb_proto::app::v1::ExecuteQueryRequest::default()));
660        assert!(futures_util::poll!(call.as_mut()).is_pending());
661        let request = outbound.recv().await.unwrap();
662        assert!(route_session_response(
663            &session.pending,
664            v1::ApplicationSessionResponse {
665                correlation_id: request.correlation_id,
666                response: None,
667            }
668        ));
669        // The response is delivered but its caller has not been polled again.
670        drop(call);
671        assert!(!session.closed.load(Ordering::Acquire));
672        assert!(session.pending.lock().unwrap().is_empty());
673        assert!(matches!(
674            outbound.try_recv(),
675            Err(mpsc::error::TryRecvError::Empty)
676        ));
677        let mut next =
678            Box::pin(session.execute_query(riffdb_proto::app::v1::ExecuteQueryRequest::default()));
679        assert!(futures_util::poll!(next.as_mut()).is_pending());
680        assert!(outbound.recv().await.unwrap().correlation_id > request.correlation_id);
681    }
682
683    #[tokio::test]
684    async fn stream_loss_releases_all_pending_calls() {
685        let (session, mut outbound) = test_session(2);
686        let call_session = session.clone();
687        let call = tokio::spawn(async move {
688            call_session
689                .execute_query(riffdb_proto::app::v1::ExecuteQueryRequest::default())
690                .await
691        });
692        let _submitted = outbound.recv().await.expect("submitted request");
693        close_session_pending(&session.pending);
694        session.closed.store(true, Ordering::Release);
695        assert!(matches!(
696            call.await.expect("call task"),
697            Err(ClientError::ConnectionFailure)
698        ));
699        assert!(matches!(
700            session
701                .execute_query(riffdb_proto::app::v1::ExecuteQueryRequest::default())
702                .await,
703            Err(ClientError::ConnectionFailure)
704        ));
705    }
706
707    #[tokio::test]
708    async fn unknown_or_duplicate_response_identity_fails_closed() {
709        let (session, mut outbound) = test_session(1);
710        let call_session = session.clone();
711        let call = tokio::spawn(async move {
712            call_session
713                .execute_query(riffdb_proto::app::v1::ExecuteQueryRequest::default())
714                .await
715        });
716        let submitted = outbound.recv().await.expect("submitted request");
717        assert!(!route_session_response(
718            &session.pending,
719            v1::ApplicationSessionResponse {
720                correlation_id: submitted.correlation_id + 1,
721                response: Some(v1::application_session_response::Response::Query(
722                    riffdb_proto::app::v1::ExecuteQueryResponse::default(),
723                )),
724            },
725        ));
726        close_session_pending(&session.pending);
727        session.closed.store(true, Ordering::Release);
728        assert!(matches!(
729            call.await.expect("call task"),
730            Err(ClientError::ConnectionFailure)
731        ));
732        assert!(matches!(
733            session
734                .execute_query(riffdb_proto::app::v1::ExecuteQueryRequest::default())
735                .await,
736            Err(ClientError::ConnectionFailure)
737        ));
738    }
739}