@@ -68,6 +68,11 @@ hackney_conn_integration_test_() ->
6868 {" sync stream_body on a non-reusable conn stops it" , {timeout , 30 , fun test_sync_stream_body_no_reuse_stops /0 }},
6969 {" async on a non-reusable pooled conn stops it" , {timeout , 30 , fun test_async_no_reuse_pooled_stops /0 }},
7070 {" sync body on a reusable conn keeps it connected" , {timeout , 30 , fun test_sync_body_reusable_stays_connected /0 }},
71+ % % Truncated body: bypasses finish_sync_request/3 and lands in `closed' (#918)
72+ {" truncated body on an unpooled conn stops it" , {timeout , 30 , fun test_truncated_body_unpooled_stops /0 }},
73+ {" truncated stream_body on an unpooled conn stops it" , {timeout , 30 , fun test_truncated_stream_body_unpooled_stops /0 }},
74+ {" truncated body on a pooled conn keeps the grace window" , {timeout , 30 , fun test_truncated_body_pooled_keeps_grace /0 }},
75+ {" location accessors are noproc-safe" , {timeout , 30 , fun test_location_accessors_noproc_safe /0 }},
7176 % % 1XX response handling
7277 {" skip 1XX informational responses" , {timeout , 30 , fun test_skip_1xx_responses /0 }}
7378 ]}.
@@ -955,3 +960,109 @@ stream_all(Pid, Acc) ->
955960 done ->
956961 Acc
957962 end .
963+
964+ % % A response whose body is cut short (peer closes mid-body) leaves
965+ % % read_full_body/2 with `socket = undefined', so `receiving' goes straight to
966+ % % `closed' and never reaches finish_sync_request/3 -- the #902 fix. For an
967+ % % unpooled connection `closed(enter)' arms no timer and the owner is
968+ % % hackney_conn_sup, so the process parked forever holding the partial body.
969+ test_truncated_body_unpooled_stops () ->
970+ {LSock , Port } = start_truncating_server (1024 * 64 ),
971+ try
972+ Pid = truncating_conn (Port ),
973+ MRef = erlang :monitor (process , Pid ),
974+ % % The short read still reports success, so the caller has no reason
975+ % % (and under hackney 4 no handle) to close anything.
976+ {ok , Body } = hackney_conn :body (Pid ),
977+ ? assertEqual (1024 * 64 , byte_size (Body )),
978+ ? assertEqual (normal , wait_down_reason (Pid , MRef ))
979+ after
980+ catch gen_tcp :close (LSock )
981+ end .
982+
983+ % % Same, draining through stream_body/1 rather than body/1.
984+ test_truncated_stream_body_unpooled_stops () ->
985+ {LSock , Port } = start_truncating_server (1024 * 64 ),
986+ try
987+ Pid = truncating_conn (Port ),
988+ MRef = erlang :monitor (process , Pid ),
989+ _ = stream_all (Pid , <<>>),
990+ ? assertEqual (normal , wait_down_reason (Pid , MRef ))
991+ after
992+ catch gen_tcp :close (LSock )
993+ end .
994+
995+ % % A pooled connection must still park in `closed' so the #836 grace window
996+ % % answers late calls; the pool's own timer stops it shortly after.
997+ test_truncated_body_pooled_keeps_grace () ->
998+ {LSock , Port } = start_truncating_server (1024 * 64 ),
999+ Pool = spawn (fun () -> receive stop -> ok end end ),
1000+ try
1001+ Pid = truncating_conn (Port , #{pool_pid => Pool }),
1002+ {ok , _Body } = hackney_conn :body (Pid ),
1003+ ? assertEqual ({ok , closed }, hackney_conn :get_state (Pid )),
1004+ ? assert (is_process_alive (Pid ))
1005+ after
1006+ Pool ! stop ,
1007+ catch gen_tcp :close (LSock )
1008+ end .
1009+
1010+ truncating_conn (Port ) ->
1011+ truncating_conn (Port , #{}).
1012+
1013+ truncating_conn (Port , Extra ) ->
1014+ Opts = maps :merge (#{
1015+ host => " 127.0.0.1" ,
1016+ port => Port ,
1017+ transport => hackney_tcp ,
1018+ connect_timeout => 5000 ,
1019+ recv_timeout => 5000
1020+ }, Extra ),
1021+ {ok , Pid } = hackney_conn :start_link (Opts ),
1022+ ok = hackney_conn :connect (Pid ),
1023+ {ok , _Status , _Headers } = hackney_conn :request (Pid , <<" GET" >>, <<" /truncated" >>, [], <<>>),
1024+ Pid .
1025+
1026+ % % Announces a Content-Length far larger than what it sends, then closes.
1027+ start_truncating_server (SendBytes ) ->
1028+ {ok , LSock } = gen_tcp :listen (0 , [binary , {active , false }, {reuseaddr , true }]),
1029+ {ok , Port } = inet :port (LSock ),
1030+ spawn (fun () ->
1031+ case gen_tcp :accept (LSock , 5000 ) of
1032+ {ok , Sock } ->
1033+ _ = gen_tcp :recv (Sock , 0 , 5000 ),
1034+ Headers = [" HTTP/1.1 200 OK\r\n " ,
1035+ " Content-Type: application/octet-stream\r\n " ,
1036+ " Content-Length: 100000000\r\n\r\n " ],
1037+ _ = gen_tcp :send (Sock , Headers ),
1038+ _ = gen_tcp :send (Sock , binary :copy (<<" x" >>, SendBytes )),
1039+ gen_tcp :close (Sock );
1040+ _ ->
1041+ ok
1042+ end
1043+ end ),
1044+ {LSock , Port }.
1045+
1046+ wait_down_reason (Pid , MRef ) ->
1047+ receive
1048+ {'DOWN' , MRef , process , Pid , Reason } -> Reason
1049+ after 5000 ->
1050+ timeout
1051+ end .
1052+
1053+ % % hackney:request/5 calls set_location/2 on the original connection after a
1054+ % % redirect chain returns. Now that a dead-end connection stops rather than
1055+ % % parks, that pid can already be gone, so the accessors must not exit with
1056+ % % noproc and propagate out of the caller.
1057+ test_location_accessors_noproc_safe () ->
1058+ Opts = #{
1059+ host => " 127.0.0.1" ,
1060+ port => ? PORT ,
1061+ transport => hackney_tcp ,
1062+ connect_timeout => 5000
1063+ },
1064+ {ok , Pid } = hackney_conn :start_link (Opts ),
1065+ ok = hackney_conn :stop (Pid ),
1066+ ? assertNot (is_process_alive (Pid )),
1067+ ? assertEqual (undefined , hackney_conn :get_location (Pid )),
1068+ ? assertEqual ({error , closed }, hackney_conn :set_location (Pid , <<" http://127.0.0.1/final" >>)).
0 commit comments