@@ -305,12 +305,22 @@ pub fn generate_tags(
305305 percent_encode ( tags. as_bytes ( ) , CONTROLS ) . to_string ( )
306306}
307307
308+ /// Owns the spawned request task and aborts it when dropped.
309+ struct AbortOnDrop ( JoinHandle < anyhow:: Result < http:: Response < Bytes > > > ) ;
310+
311+ impl Drop for AbortOnDrop {
312+ fn drop ( & mut self ) {
313+ // A no-op once the task has completed, so the success path is unaffected.
314+ self . 0 . abort ( ) ;
315+ }
316+ }
317+
308318#[ derive( Default ) ]
309319enum SenderFuture {
310320 #[ default]
311321 Error ,
312322 Outstanding ( ResponseFuture ) ,
313- Submitted ( JoinHandle < anyhow :: Result < http :: Response < Bytes > > > ) ,
323+ Submitted ( AbortOnDrop ) ,
314324}
315325
316326pub struct PayloadSender {
@@ -322,7 +332,7 @@ pub struct PayloadSender {
322332}
323333
324334const BOUNDARY : & str = "------------------------44617461646f67" ;
325- const BOUNDARY_LINE : & str = concat ! ( "--" , BOUNDARY , " \r \n " ) ;
335+ const BOUNDARY_LINE : & str = concat ! ( "--" , BOUNDARY ) ;
326336
327337impl PayloadSender {
328338 pub fn new (
@@ -426,18 +436,19 @@ impl PayloadSender {
426436 SenderFuture :: Outstanding ( future) => {
427437 if self . needs_boundary {
428438 let header = concat ! (
429- BOUNDARY_LINE ,
430- "Content-Disposition: form-data; name=\" event\" ; filename=\" event.json\" \r \n " ,
431- "Content-Type: application/json\r \n " ,
432- "\r \n " ,
439+ BOUNDARY_LINE ,
440+ "\r \n " ,
441+ "Content-Disposition: form-data; name=\" event\" ; filename=\" event.json\" \r \n " ,
442+ "Content-Type: application/json\r \n " ,
443+ "\r \n " ,
433444 ) ;
434445 self . sender . send_chunk ( header. into ( ) ) . await ?;
435446 }
436447
437- self . future = SenderFuture :: Submitted ( tokio:: spawn ( async move {
448+ self . future = SenderFuture :: Submitted ( AbortOnDrop ( tokio:: spawn ( async move {
438449 let resp = future. await ?;
439450 Ok ( resp)
440- } ) ) ;
451+ } ) ) ) ;
441452 true
442453 }
443454 future => {
@@ -463,7 +474,7 @@ impl PayloadSender {
463474 // insert a trailing ]
464475 if self . needs_boundary {
465476 self . sender
466- . send_chunk ( concat ! ( "]\r \n " , BOUNDARY_LINE ) . into ( ) )
477+ . send_chunk ( concat ! ( "]\r \n " , BOUNDARY_LINE , "-- \r \n " ) . into ( ) )
467478 . await ?;
468479 } else {
469480 self . sender . send_chunk ( Bytes :: from_static ( b"]" ) ) . await ?;
@@ -472,17 +483,14 @@ impl PayloadSender {
472483 drop ( self . sender ) ;
473484 // Once the body is fully sent, bound the wait for the response headers and (if
474485 // needed) the response body under a single timeout - a slow/stalled server must
475- // not be able to hang this indefinitely. Abort the spawned task on timeout so the
476- // underlying request is actually cancelled instead of left running detached .
486+ // not be able to hang this indefinitely. Returning here drops `future`, whose
487+ // `AbortOnDrop` cancels the underlying request rather than detaching it .
477488 let response =
478- match tokio:: time:: timeout ( Duration :: from_millis ( self . timeout_ms ) , & mut future)
489+ match tokio:: time:: timeout ( Duration :: from_millis ( self . timeout_ms ) , & mut future. 0 )
479490 . await
480491 {
481492 Ok ( joined) => joined??,
482- Err ( _) => {
483- future. abort ( ) ;
484- return Err ( anyhow:: anyhow!( "debugger payload request timed out" ) ) ;
485- }
493+ Err ( _) => return Err ( anyhow:: anyhow!( "debugger payload request timed out" ) ) ,
486494 } ;
487495
488496 let status = response. status ( ) . as_u16 ( ) ;
@@ -673,7 +681,10 @@ pub fn generate_new_id() -> Uuid {
673681#[ cfg( test) ]
674682mod tests {
675683 use super :: * ;
684+ use libdd_capabilities:: { ChunkFuture , HttpError , MaybeSend , StreamingBodySender } ;
676685 use std:: borrow:: Cow ;
686+ use std:: sync:: atomic:: { AtomicBool , Ordering } ;
687+ use std:: sync:: Arc ;
677688
678689 fn agent_endpoint ( ) -> Endpoint {
679690 Endpoint :: from_slice ( "http://localhost:8126" )
@@ -786,6 +797,107 @@ mod tests {
786797 }
787798 }
788799
800+ struct SetOnDrop ( Arc < AtomicBool > ) ;
801+
802+ impl Drop for SetOnDrop {
803+ fn drop ( & mut self ) {
804+ self . 0 . store ( true , Ordering :: SeqCst ) ;
805+ }
806+ }
807+
808+ /// A response that never arrives, holding `guard` for as long as it lives.
809+ ///
810+ /// The guard is captured by move rather than constructed in the body, because an
811+ /// async block does not run its body until first polled: a task aborted before
812+ /// its first poll would otherwise never arm the observer.
813+ async fn stalled_response ( guard : SetOnDrop ) -> Result < http:: Response < Bytes > , HttpError > {
814+ let _guard = guard;
815+ future:: pending :: < ( ) > ( ) . await ;
816+ unreachable ! ( )
817+ }
818+
819+ /// Accepts and discards body chunks, so the test's stalled response future is the
820+ /// only thing the spawned task ever waits on.
821+ struct DiscardingBodySender ;
822+
823+ impl StreamingBodySender for DiscardingBodySender {
824+ fn send_chunk ( & mut self , _data : Bytes ) -> ChunkFuture < ' _ > {
825+ Box :: pin ( async { Ok ( ( ) ) } )
826+ }
827+ }
828+
829+ /// A client whose requests never complete, so a test can observe what happens to
830+ /// the in-flight task once the sender goes away. The flag is set when the
831+ /// response future is dropped, which happens only if the spawned task was
832+ /// aborted rather than detached.
833+ ///
834+ /// `request_streamed` is overridden rather than relying on the default
835+ /// implementation: that one parks on the body channel until the `BodySender` is
836+ /// dropped, so a task abandoned before `finish()` would be cancelled before it
837+ /// ever reached `request()` and the flag would never be armed.
838+ #[ derive( Clone , Debug ) ]
839+ struct StalledClient ( Arc < AtomicBool > ) ;
840+
841+ impl HttpClientCapability for StalledClient {
842+ fn new_client ( ) -> Self {
843+ Self ( Arc :: new ( AtomicBool :: new ( false ) ) )
844+ }
845+
846+ fn new_without_connection_pooling ( ) -> Self {
847+ Self :: new_client ( )
848+ }
849+
850+ fn request (
851+ & self ,
852+ _req : http:: Request < Bytes > ,
853+ ) -> impl std:: future:: Future < Output = Result < http:: Response < Bytes > , HttpError > > + MaybeSend
854+ {
855+ stalled_response ( SetOnDrop ( self . 0 . clone ( ) ) )
856+ }
857+
858+ fn request_streamed ( & self , _req : http:: Request < ( ) > ) -> ( BodySender , ResponseFuture ) {
859+ (
860+ Box :: new ( DiscardingBodySender ) ,
861+ Box :: pin ( stalled_response ( SetOnDrop ( self . 0 . clone ( ) ) ) ) ,
862+ )
863+ }
864+ }
865+
866+ #[ tokio:: test]
867+ async fn test_dropping_payload_sender_aborts_in_flight_request ( ) {
868+ let cancelled = Arc :: new ( AtomicBool :: new ( false ) ) ;
869+
870+ let mut config = Config :: default ( ) ;
871+ config. set_endpoint ( agent_endpoint ( ) ) . unwrap ( ) ;
872+
873+ let mut sender = PayloadSender :: new_to_endpoint_with_client (
874+ config. snapshots_endpoint . as_ref ( ) . unwrap ( ) ,
875+ DebuggerType :: Snapshots ,
876+ "" ,
877+ StalledClient ( cancelled. clone ( ) ) ,
878+ )
879+ . unwrap ( ) ;
880+
881+ // Spawns the request task, which then never completes.
882+ sender. append ( b"[{}]" ) . await . unwrap ( ) ;
883+
884+ // Abandoning the sender (an enclosing timeout, a `select!`, a cancelled task)
885+ // must cancel that request instead of leaving it running with its connection.
886+ drop ( sender) ;
887+
888+ for _ in 0 ..100 {
889+ if cancelled. load ( Ordering :: SeqCst ) {
890+ break ;
891+ }
892+ tokio:: task:: yield_now ( ) . await ;
893+ }
894+
895+ assert ! (
896+ cancelled. load( Ordering :: SeqCst ) ,
897+ "in-flight request was detached instead of aborted"
898+ ) ;
899+ }
900+
789901 #[ test]
790902 fn test_payload_rejected_display_is_downcastable ( ) {
791903 let err: anyhow:: Error = anyhow:: Error :: from ( PayloadRejected {
0 commit comments