@@ -782,3 +782,116 @@ describe('probe send-error classification', () => {
782782 expect ( requests ( transport . sent ) . some ( r => r . method === 'initialize' ) ) . toBe ( false ) ;
783783 } ) ;
784784} ) ;
785+
786+ /* ------------------------------------------------------------------------- *
787+ * Probe window handler preservation: handlers pre-set on the transport
788+ * before connect() survive negotiation exactly as they survive a plain
789+ * connect — Protocol.connect() must find and chain them after the window.
790+ * ------------------------------------------------------------------------- */
791+
792+ describe ( 'probe window preserves pre-set transport handlers' , ( ) => {
793+ test ( 'pre-set onerror/onclose are restored after a modern negotiation and reachable through the Protocol chain' , async ( ) => {
794+ const transport = new ScriptedTransport ( modernServerScript ( ) ) ;
795+ const seenErrors : Error [ ] = [ ] ;
796+ let closed = 0 ;
797+ transport . onerror = error => {
798+ seenErrors . push ( error ) ;
799+ } ;
800+ transport . onclose = ( ) => {
801+ closed ++ ;
802+ } ;
803+
804+ const client = new Client ( { name : 'c' , version : '0' } , { versionNegotiation : { mode : 'auto' } } ) ;
805+ await client . connect ( transport ) ;
806+
807+ // The window restored the handler, so Protocol.connect chained it:
808+ // post-connect transport errors still reach the pre-set observer.
809+ const boom = new Error ( 'post-connect transport error' ) ;
810+ transport . onerror ?.( boom ) ;
811+ expect ( seenErrors ) . toContain ( boom ) ;
812+
813+ await client . close ( ) ;
814+ expect ( closed ) . toBeGreaterThan ( 0 ) ;
815+ } ) ;
816+
817+ test ( 'pre-set onerror survives the legacy fallback path too' , async ( ) => {
818+ const transport = new ScriptedTransport ( legacyServerScript ) ;
819+ const seenErrors : Error [ ] = [ ] ;
820+ transport . onerror = error => {
821+ seenErrors . push ( error ) ;
822+ } ;
823+
824+ const client = new Client ( { name : 'c' , version : '0' } , { versionNegotiation : { mode : 'auto' } } ) ;
825+ await client . connect ( transport ) ;
826+ expect ( client . getNegotiatedProtocolVersion ( ) ) . toBe ( '2025-11-25' ) ;
827+
828+ const boom = new Error ( 'post-fallback transport error' ) ;
829+ transport . onerror ?.( boom ) ;
830+ expect ( seenErrors ) . toContain ( boom ) ;
831+
832+ await client . close ( ) ;
833+ } ) ;
834+
835+ test ( 'failed negotiation (pin mode, no fallback) restores handlers via the detach path — onclose fires exactly once' , async ( ) => {
836+ const transport = new ScriptedTransport ( legacyServerScript ) ;
837+ const presetOnError = ( _error : Error ) => { } ;
838+ let closes = 0 ;
839+ transport . onerror = presetOnError ;
840+ transport . onclose = ( ) => {
841+ closes ++ ;
842+ } ;
843+
844+ const client = new Client ( { name : 'c' , version : '0' } , { versionNegotiation : { mode : { pin : MODERN } } } ) ;
845+ await expect ( client . connect ( transport ) ) . rejects . toThrow ( ) ;
846+
847+ // detach() restored the pre-set handlers, and Client's cleanup close
848+ // delivered the close event exactly once.
849+ expect ( transport . onerror ) . toBe ( presetOnError ) ;
850+ expect ( closes ) . toBe ( 1 ) ;
851+ } ) ;
852+
853+ test ( 'a mid-probe transport close reaches the pre-set onclose exactly once (no re-delivery from cleanup close)' , async ( ) => {
854+ const script : Script = ( message , t ) => {
855+ if ( ! isJSONRPCRequest ( message ) ) return ;
856+ if ( message . method === 'server/discover' ) {
857+ t . onclose ?.( ) ;
858+ return ;
859+ }
860+ legacyServerScript ( message , t ) ;
861+ } ;
862+ const transport = new ScriptedTransport ( script ) ;
863+ let closes = 0 ;
864+ transport . onclose = ( ) => {
865+ closes ++ ;
866+ } ;
867+
868+ const client = new Client ( { name : 'c' , version : '0' } , { versionNegotiation : { mode : 'auto' } } ) ;
869+ await client . connect ( transport ) . catch ( ( ) => undefined ) ;
870+
871+ expect ( closes ) . toBe ( 1 ) ;
872+ } ) ;
873+
874+ test ( 'transport errors DURING the probe window are forwarded to the pre-set handler' , async ( ) => {
875+ const duringProbe = new Error ( 'mid-probe transport error' ) ;
876+ const script : Script = ( message , t ) => {
877+ if ( ! isJSONRPCRequest ( message ) ) return ;
878+ if ( message . method === 'server/discover' ) {
879+ t . onerror ?.( duringProbe ) ;
880+ t . reply ( { jsonrpc : '2.0' , id : message . id , error : { code : - 32_601 , message : 'Method not found' } } ) ;
881+ return ;
882+ }
883+ legacyServerScript ( message , t ) ;
884+ } ;
885+ const transport = new ScriptedTransport ( script ) ;
886+ const seenErrors : Error [ ] = [ ] ;
887+ transport . onerror = error => {
888+ seenErrors . push ( error ) ;
889+ } ;
890+
891+ const client = new Client ( { name : 'c' , version : '0' } , { versionNegotiation : { mode : 'auto' } } ) ;
892+ await client . connect ( transport ) ;
893+
894+ expect ( seenErrors ) . toContain ( duringProbe ) ;
895+ await client . close ( ) ;
896+ } ) ;
897+ } ) ;
0 commit comments