22#![ allow( clippy:: unwrap_used) ]
33
44use github_copilot_sdk:: test_support:: { JsonRpcClient , JsonRpcNotification , JsonRpcRequest } ;
5- use tokio:: io:: { AsyncWrite , AsyncWriteExt , duplex} ;
5+ use tokio:: io:: { AsyncRead , AsyncReadExt , AsyncWrite , AsyncWriteExt , duplex} ;
66use tokio:: sync:: { broadcast, mpsc} ;
77
88/// Write a Content-Length framed JSON-RPC message to a writer.
@@ -13,6 +13,28 @@ async fn write_framed(writer: &mut (impl AsyncWrite + Unpin), body: &[u8]) {
1313 writer. flush ( ) . await . unwrap ( ) ;
1414}
1515
16+ async fn read_framed ( reader : & mut ( impl AsyncRead + Unpin ) ) -> Vec < u8 > {
17+ let mut header = String :: new ( ) ;
18+ loop {
19+ let mut byte = [ 0u8 ; 1 ] ;
20+ reader. read_exact ( & mut byte) . await . unwrap ( ) ;
21+ header. push ( byte[ 0 ] as char ) ;
22+ if header. ends_with ( "\r \n \r \n " ) {
23+ break ;
24+ }
25+ }
26+
27+ let length = header
28+ . trim ( )
29+ . strip_prefix ( "Content-Length: " )
30+ . unwrap ( )
31+ . parse ( )
32+ . unwrap ( ) ;
33+ let mut body = vec ! [ 0u8 ; length] ;
34+ reader. read_exact ( & mut body) . await . unwrap ( ) ;
35+ body
36+ }
37+
1638#[ tokio:: test]
1739async fn request_response_round_trip ( ) {
1840 // duplex: client_write → server_read, server_write → client_read
@@ -410,3 +432,116 @@ async fn send_request_cancellation_does_not_leak_pending() {
410432 assert_eq ! ( response. result. unwrap( ) [ "ok" ] , true ) ;
411433 server_task. await . unwrap ( ) ;
412434}
435+
436+ #[ test]
437+ fn lone_surrogate_yields_unexpected_end_of_hex_escape ( ) {
438+ let error = serde_json:: from_slice :: < serde_json:: Value > ( br#""\ud83d""# ) . unwrap_err ( ) ;
439+
440+ assert_eq ! (
441+ error. to_string( ) ,
442+ "unexpected end of hex escape at line 1 column 8"
443+ ) ;
444+ }
445+
446+ #[ tokio:: test]
447+ async fn lone_surrogate_frame_is_recovered_without_closing_connection ( ) {
448+ let ( client_write, mut server_read) = duplex ( 4096 ) ;
449+ let ( mut server_write, client_read) = duplex ( 4096 ) ;
450+ let ( notification_tx, _) = broadcast:: channel ( 16 ) ;
451+ let ( request_tx, _) = mpsc:: unbounded_channel ( ) ;
452+ let client = JsonRpcClient :: new ( client_write, client_read, notification_tx, request_tx) ;
453+
454+ let server_task = tokio:: spawn ( async move {
455+ let request: JsonRpcRequest =
456+ serde_json:: from_slice ( & read_framed ( & mut server_read) . await ) . unwrap ( ) ;
457+ let response = format ! (
458+ r#"{{"jsonrpc":"2.0","id":{},"result":{{"name":"invalid \ud83d value"}}}}"# ,
459+ request. id
460+ ) ;
461+ write_framed ( & mut server_write, response. as_bytes ( ) ) . await ;
462+
463+ let request: JsonRpcRequest =
464+ serde_json:: from_slice ( & read_framed ( & mut server_read) . await ) . unwrap ( ) ;
465+ let response = serde_json:: json!( {
466+ "jsonrpc" : "2.0" ,
467+ "id" : request. id,
468+ "result" : { "name" : "still connected" }
469+ } ) ;
470+ write_framed ( & mut server_write, & serde_json:: to_vec ( & response) . unwrap ( ) ) . await ;
471+ } ) ;
472+
473+ let response = client. send_request ( "models.list" , None ) . await . unwrap ( ) ;
474+ assert_eq ! (
475+ response. result. unwrap( ) [ "name" ] ,
476+ serde_json:: json!( "invalid \u{FFFD} value" )
477+ ) ;
478+
479+ let response = client. send_request ( "account.getQuota" , None ) . await . unwrap ( ) ;
480+ assert_eq ! (
481+ response. result. unwrap( ) [ "name" ] ,
482+ serde_json:: json!( "still connected" )
483+ ) ;
484+ server_task. await . unwrap ( ) ;
485+ }
486+
487+ #[ tokio:: test]
488+ async fn unrepairable_frame_remains_fatal ( ) {
489+ let ( client_write, mut server_read) = duplex ( 4096 ) ;
490+ let ( mut server_write, client_read) = duplex ( 4096 ) ;
491+ let ( notification_tx, _) = broadcast:: channel ( 16 ) ;
492+ let ( request_tx, _) = mpsc:: unbounded_channel ( ) ;
493+ let client = JsonRpcClient :: new ( client_write, client_read, notification_tx, request_tx) ;
494+
495+ let server_task = tokio:: spawn ( async move {
496+ let request: JsonRpcRequest =
497+ serde_json:: from_slice ( & read_framed ( & mut server_read) . await ) . unwrap ( ) ;
498+ let response = format ! (
499+ r#"{{"jsonrpc":"2.0","id":{},"result":{{"surrogate":"\ud83d","escape":"\q"}}}}"# ,
500+ request. id
501+ ) ;
502+ write_framed ( & mut server_write, response. as_bytes ( ) ) . await ;
503+ } ) ;
504+
505+ let error = tokio:: time:: timeout (
506+ std:: time:: Duration :: from_secs ( 2 ) ,
507+ client. send_request ( "models.list" , None ) ,
508+ )
509+ . await
510+ . expect ( "unrepairable frame did not terminate the pending request" )
511+ . unwrap_err ( ) ;
512+
513+ assert_eq ! ( error. to_string( ) , "request cancelled" ) ;
514+ assert ! ( error. is_transport_failure( ) ) ;
515+ server_task. await . unwrap ( ) ;
516+ }
517+
518+ #[ tokio:: test]
519+ async fn valid_pairs_and_escaped_backslashes_are_untouched ( ) {
520+ let ( client_write, mut server_read) = duplex ( 4096 ) ;
521+ let ( mut server_write, client_read) = duplex ( 4096 ) ;
522+ let ( notification_tx, _) = broadcast:: channel ( 16 ) ;
523+ let ( request_tx, _) = mpsc:: unbounded_channel ( ) ;
524+ let client = JsonRpcClient :: new ( client_write, client_read, notification_tx, request_tx) ;
525+
526+ let server_task = tokio:: spawn ( async move {
527+ let request: JsonRpcRequest =
528+ serde_json:: from_slice ( & read_framed ( & mut server_read) . await ) . unwrap ( ) ;
529+ let response = format ! (
530+ r#"{{"jsonrpc":"2.0","id":{},"result":{{"emoji":"\ud83d\ude00","path":"C:\\ud83d","invalid":"\ud83d"}}}}"# ,
531+ request. id
532+ ) ;
533+ write_framed ( & mut server_write, response. as_bytes ( ) ) . await ;
534+ } ) ;
535+
536+ let result = client
537+ . send_request ( "models.list" , None )
538+ . await
539+ . unwrap ( )
540+ . result
541+ . unwrap ( ) ;
542+
543+ assert_eq ! ( result[ "emoji" ] , serde_json:: json!( "😀" ) ) ;
544+ assert_eq ! ( result[ "path" ] , serde_json:: json!( r"C:\ud83d" ) ) ;
545+ assert_eq ! ( result[ "invalid" ] , serde_json:: json!( "\u{FFFD} " ) ) ;
546+ server_task. await . unwrap ( ) ;
547+ }
0 commit comments