@@ -719,3 +719,71 @@ async fn test_export_body_reports_a_second_snapshot_through_the_stream() {
719719
720720 assert ! ( format!( "{error:?}" ) . contains( "more than one snapshot" ) , "{error:?}" ) ;
721721}
722+
723+ /// Parks inside the plan until the test releases it, so the request can be abandoned first and the
724+ /// summary send lands on a receiver that is already gone.
725+ struct GatedDriver {
726+ entered : tokio:: sync:: mpsc:: UnboundedSender < ( ) > ,
727+ release : std:: sync:: Mutex < Option < std:: sync:: mpsc:: Receiver < ( ) > > > ,
728+ saw : tokio:: sync:: mpsc:: UnboundedSender < String > ,
729+ }
730+
731+ impl RetentionDriver for GatedDriver {
732+ fn validate_retention ( & self , _policy : & RetentionPolicy ) -> Result < ( ) , String > {
733+ Ok ( ( ) )
734+ }
735+
736+ fn plan_retention (
737+ & self ,
738+ scan : & crate :: serving:: RetentionScan < ' _ > ,
739+ start : & mut dyn FnMut ( peryx_policy:: RetentionSummary ) -> Result < ( ) , String > ,
740+ _emit : & mut dyn FnMut ( RetentionDecision ) -> Result < ( ) , String > ,
741+ ) -> Result < ( ) , String > {
742+ self . entered . send ( ( ) ) . unwrap ( ) ;
743+ // Blocking is correct here: the plan already runs on the blocking pool.
744+ let release = self . release . lock ( ) . unwrap ( ) . take ( ) . expect ( "released once" ) ;
745+ release. recv ( ) . unwrap ( ) ;
746+ let summary = peryx_policy:: RetentionSummary {
747+ policy_version : scan. policy . version ( ) ,
748+ frontier : peryx_policy:: RetentionFrontier :: default ( ) ,
749+ } ;
750+ start ( summary) . inspect_err ( |reason| self . saw . send ( reason. clone ( ) ) . unwrap ( ) )
751+ }
752+ }
753+
754+ /// A client that abandons an export leaves nothing waiting for the summary, so the send that opens
755+ /// the snapshot has nowhere to go. The worker has to learn the request is gone rather than carry on
756+ /// producing a body no one will read.
757+ ///
758+ /// The order is forced rather than raced: the driver parks inside the plan, the test drops the
759+ /// request and waits for that drop to complete, and only then is the driver released.
760+ #[ tokio:: test( flavor = "multi_thread" , worker_threads = 2 ) ]
761+ async fn test_export_body_tells_the_worker_when_the_request_is_gone ( ) {
762+ let ( _dir, meta) = store ( ) ;
763+ let ( entered, mut entered_rx) = tokio:: sync:: mpsc:: unbounded_channel ( ) ;
764+ let ( saw, mut saw_rx) = tokio:: sync:: mpsc:: unbounded_channel ( ) ;
765+ let ( release, release_rx) = std:: sync:: mpsc:: channel ( ) ;
766+ let driver: Arc < dyn RetentionDriver > = Arc :: new ( GatedDriver {
767+ entered,
768+ release : std:: sync:: Mutex :: new ( Some ( release_rx) ) ,
769+ saw,
770+ } ) ;
771+ let gates = super :: RetentionGates :: new ( 1 ) ;
772+ let permit = gates. try_enter ( "alpha" ) . unwrap ( ) ;
773+ let export = super :: RetentionExport {
774+ index : "alpha" . to_owned ( ) ,
775+ ecosystem : "example" . to_owned ( ) ,
776+ policy : empty_policy ( ) ,
777+ now : None ,
778+ after : 0 ,
779+ expect : None ,
780+ } ;
781+ let request = tokio:: spawn ( async move { super :: export_body ( driver, meta, export, permit) . await . map ( |_| ( ) ) } ) ;
782+
783+ entered_rx. recv ( ) . await . expect ( "the plan parked" ) ;
784+ request. abort ( ) ;
785+ assert ! ( request. await . unwrap_err( ) . is_cancelled( ) ) ;
786+ release. send ( ( ) ) . unwrap ( ) ;
787+
788+ assert_eq ! ( saw_rx. recv( ) . await . as_deref( ) , Some ( "export request gone" ) ) ;
789+ }
0 commit comments